(core) Adding UI for reverse columns

Summary:
- Adding an UI for two-way reference column.
- Reusing table name as label for the reverse column

Test Plan: Updated

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4344
This commit is contained in:
Jarosław Sadziński 2024-09-13 15:20:18 +02:00
parent da6c39aa50
commit e97a45143f
25 changed files with 1356 additions and 137 deletions

View File

@ -32,6 +32,9 @@ const {COMMENTS} = require('app/client/models/features');
const {DismissedPopup} = require('app/common/Prefs');
const {markAsSeen} = require('app/client/models/UserPrefs');
const {buildConfirmDelete, reportUndo} = require('app/client/components/modals');
const {buildReassignModal} = require('app/client/ui/buildReassignModal');
const {MutedError} = require('app/client/models/errors');
/**
* BaseView forms the basis for ViewSection classes.
@ -648,7 +651,17 @@ BaseView.prototype.sendPasteActions = function(cutCallback, actions) {
// If the cut occurs on an edit restricted cell, there may be no cut action.
if (cutAction) { actions.unshift(cutAction); }
}
return this.gristDoc.docData.sendActions(actions);
return this.gristDoc.docData.sendActions(actions).catch(ex => {
if (ex.code === 'UNIQUE_REFERENCE_VIOLATION') {
buildReassignModal({
docModel: this.gristDoc.docModel,
actions,
}).catch(reportError);
throw new MutedError();
} else {
throw ex;
}
});
};
BaseView.prototype.buildDom = function() {

View File

@ -343,6 +343,9 @@ export class Comm extends dispose.Disposable implements GristServerAPI, DocListA
if (message.details) {
err.details = message.details;
}
if (message.error?.startsWith('[Sandbox] UniqueReferenceError')) {
err.code = 'UNIQUE_REFERENCE_VIOLATION';
}
err.shouldFork = message.shouldFork;
log.warn(`Comm response #${reqId} ${r.methodName} ERROR:${code} ${message.error}`
+ (message.shouldFork ? ` (should fork)` : ''));

View File

@ -55,7 +55,8 @@ const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles");
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
const {makeT} = require('app/client/lib/localization');
const { isList } = require('app/common/gristTypes');
const {isList} = require('app/common/gristTypes');
const t = makeT('GridView');
@ -628,27 +629,25 @@ GridView.prototype.paste = async function(data, cutCallback) {
if (actions.length > 0) {
let cursorPos = this.cursor.getCursorPos();
return this.sendPasteActions(cutCallback, actions)
.then(results => {
// If rows were added, get their rowIds from the action results.
let addRowIds = (actions[0][0] === 'BulkAddRecord' ? results[0] : []);
console.assert(addRowIds.length <= updateRowIds.length,
`Unexpected number of added rows: ${addRowIds.length} of ${updateRowIds.length}`);
let newRowIds = updateRowIds.slice(0, updateRowIds.length - addRowIds.length)
.concat(addRowIds);
const results = await this.sendPasteActions(cutCallback, actions);
// If rows were added, get their rowIds from the action results.
let addRowIds = (actions[0][0] === 'BulkAddRecord' ? results[0] : []);
console.assert(addRowIds.length <= updateRowIds.length,
`Unexpected number of added rows: ${addRowIds.length} of ${updateRowIds.length}`);
let newRowIds = updateRowIds.slice(0, updateRowIds.length - addRowIds.length)
.concat(addRowIds);
// Restore the cursor to the right rowId, even if it jumped.
this.cursor.setCursorPos({rowId: cursorPos.rowId === 'new' ? addRowIds[0] : cursorPos.rowId});
// Restore the cursor to the right rowId, even if it jumped.
this.cursor.setCursorPos({rowId: cursorPos.rowId === 'new' ? addRowIds[0] : cursorPos.rowId});
// Restore the selection if it would select the correct rows.
let topRowIndex = this.viewData.getRowIndex(newRowIds[0]);
if (newRowIds.every((r, i) => r === this.viewData.getRowId(topRowIndex + i))) {
this.cellSelector.selectArea(topRowIndex, leftIndex,
topRowIndex + outputHeight - 1, leftIndex + outputWidth - 1);
}
// Restore the selection if it would select the correct rows.
let topRowIndex = this.viewData.getRowIndex(newRowIds[0]);
if (newRowIds.every((r, i) => r === this.viewData.getRowId(topRowIndex + i))) {
this.cellSelector.selectArea(topRowIndex, leftIndex,
topRowIndex + outputHeight - 1, leftIndex + outputWidth - 1);
}
commands.allCommands.clearCopySelection.run();
});
await commands.allCommands.clearCopySelection.run();
}
};

View File

@ -207,6 +207,16 @@ export class GristDoc extends DisposableWithEvents {
public isTimingOn = Observable.create(this, false);
/**
* Checks if it is ok to show raw data popup for currently selected section.
* We can't show raw data if:
* - we already have full screen section (which looks the same)
* - we are already showing raw data
*
* Extracted to single computed as it is used here and in menus.
*/
public canShowRawData: Computed<boolean>;
private _actionLog: ActionLog;
private _undoStack: UndoStack;
private _lastOwnActionGroup: ActionGroupWithCursorPos | null = null;
@ -498,7 +508,21 @@ export class GristDoc extends DisposableWithEvents {
reloadPlugins() {
void this.docComm.reloadPlugins().then(() => G.window.location.reload(false));
},
async showRawData(sectionId: number = 0) {
if (!this.canShowRawData.get()) {
return;
}
if (!sectionId) {
const viewSection = this.viewModel.activeSection();
if (viewSection?.isDisposed()) { return; }
if (viewSection.isRaw.peek()) {
return;
}
sectionId = viewSection.id.peek();
}
const anchorUrlState = { hash: { sectionId, popup: true } };
await urlState().pushUrl(anchorUrlState, { replace: true });
},
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
setCursor: this.onSetCursorPos.bind(this),
@ -603,6 +627,14 @@ export class GristDoc extends DisposableWithEvents {
this._prevSectionId = null;
}
}));
this.canShowRawData = Computed.create(this, (use) => {
const isSinglePage = use(urlState().state).params?.style === 'singlePage';
if (isSinglePage || use(this.maximizedSectionId)) {
return false;
}
return true;
});
}
/**

View File

@ -47,6 +47,25 @@ export function addColTypeSuffix(type: string, column: ColumnRec, docModel: DocM
}
}
/**
* Infers the suffix for a column type, based on the type of the column and the type to convert it to.
* Currently only used for Ref and RefList types, where the suffix is the tableId of the reference.
*/
export function inferColTypeSuffix(newPure: string, column: ColumnRec) {
// We can infer only for Ref and RefList types.
if (newPure !== "Ref" && newPure !== "RefList") {
return null;
}
// If the old type was also Ref/RefList, just return the tableId from the old type.
const existingTable = column.type.peek().split(':')[1];
const oldPure = gristTypes.extractTypeFromColType(column.type.peek());
if (existingTable && (oldPure === "Ref" || oldPure === "RefList")) {
return `${newPure}:${existingTable}`;
}
return null;
}
/**
* Looks through the data of the given column to find the first value of the form
* [R|r, <tableId>, <rowId>] (a Reference(List) value returned from a formula), and returns the tableId

View File

@ -5,7 +5,8 @@ import DataTableModel from 'app/client/models/DataTableModel';
import { IRowModel } from 'app/client/models/DocModel';
import { ValidationRec } from 'app/client/models/entities/ValidationRec';
import * as modelUtil from 'app/client/models/modelUtil';
import { CellValue, ColValues } from 'app/common/DocActions';
import { buildReassignModal } from 'app/client/ui/buildReassignModal';
import { CellValue, ColValues, DocAction } from 'app/common/DocActions';
import * as ko from 'knockout';
/**
@ -63,6 +64,19 @@ export class DataRowModel extends BaseRowModel {
try {
return await this._table.sendTableAction(action);
} catch(ex) {
if (ex.code === 'UNIQUE_REFERENCE_VIOLATION') {
// Show modal to repeat the save.
await buildReassignModal({
docModel: this._table.docModel,
actions: [
action as DocAction,
]
});
// Ignore the error here, no point in returning it.
} else {
throw ex;
}
} finally {
// If the action doesn't actually result in an update to a row, it's important to reset the
// observable to the data (if the data did get updated, this will be a no-op). This is also

View File

@ -43,6 +43,11 @@ export class ViewFieldConfig {
return list.filter(f => !f.isDisposed() && !f.column().isDisposed());
}));
// Helper that lists all not disposed widgets. Many methods below gets all fields
// list which still can contain disposed fields, this helper will filter them out.
const listFields = () => this.fields().filter(f => !f.isDisposed());
// Just a helper field to see if we have multiple selected columns or not.
this.multiselect = owner.autoDispose(ko.pureComputed(() => this.fields().length > 1));
@ -50,7 +55,7 @@ export class ViewFieldConfig {
// we have normal TextBox and Spinner). This will be used to allow the user to change
// this type if such columns are selected.
this.sameWidgets = owner.autoDispose(ko.pureComputed(() => {
const list = this.fields();
const list = listFields();
// If we have only one field selected, list is always the same.
if (list.length <= 1) { return true; }
// Now get all widget list and calculate intersection of the Sets.
@ -71,7 +76,7 @@ export class ViewFieldConfig {
}
// If all have the same value, return it, otherwise
// return a default value for this option "undefined"
const values = this.fields().map(f => f.widget());
const values = listFields().map(f => f.widget());
if (allSame(values)) {
return values[0];
} else {
@ -80,7 +85,7 @@ export class ViewFieldConfig {
},
write: (widget) => {
// Go through all the fields, and reset them all.
for(const field of this.fields.peek()) {
for(const field of listFields()) {
// Reset the entire JSON, so that all options revert to their defaults.
const previous = field.widgetOptionsJson.peek();
// We don't need to bundle anything (actions send in the same tick, are bundled
@ -100,7 +105,7 @@ export class ViewFieldConfig {
// We will use this, to know which options are allowed to be changed
// when multiple columns are selected.
const commonOptions = owner.autoDispose(ko.pureComputed(() => {
const fields = this.fields();
const fields = listFields();
// Put all options of first widget in the Set, and then remove
// them one by one, if they are not present in other fields.
let options: Set<string>|null = null;
@ -134,7 +139,7 @@ export class ViewFieldConfig {
// Assemble final json object.
const result: any = {};
// First get all widgetOption jsons from all columns/fields.
const optionList = this.fields().map(f => f.widgetOptionsJson());
const optionList = listFields().map(f => f.widgetOptionsJson());
// And fill only those that are common
const common = commonOptions();
for(const key of common) {
@ -162,7 +167,7 @@ export class ViewFieldConfig {
}
// Now update all options, for all fields, by amending the options
// object from the field/column.
for(const item of this.fields.peek()) {
for(const item of listFields()) {
const previous = item.widgetOptionsJson.peek();
setter(item.widgetOptionsJson, {
...previous,
@ -177,9 +182,9 @@ export class ViewFieldConfig {
// Property is not supported by set of columns if it is not a common option.
disabled: prop => ko.pureComputed(() => !commonOptions().has(prop)),
// Property has mixed value, if not all options are the same.
mixed: prop => ko.pureComputed(() => !allSame(this.fields().map(f => f.widgetOptionsJson.prop(prop)()))),
mixed: prop => ko.pureComputed(() => !allSame(listFields().map(f => f.widgetOptionsJson.prop(prop)()))),
// Property has empty value, if all options are empty (are null, undefined, empty Array or empty Object).
empty: prop => ko.pureComputed(() => allEmpty(this.fields().map(f => f.widgetOptionsJson.prop(prop)()))),
empty: prop => ko.pureComputed(() => allEmpty(listFields().map(f => f.widgetOptionsJson.prop(prop)()))),
}));
// This is repeated logic for wrap property in viewFieldRec,
@ -196,8 +201,8 @@ export class ViewFieldConfig {
// To support this use case we need to compute a snapshot of fields, and use it to save style. Style
// picker will be rebuild every time fields change, and it will have access to last selected fields
// when it will be disposed.
this.style = ko.pureComputed(() => {
const fields = this.fields();
this.style = owner.autoDispose(ko.pureComputed(() => {
const fields = listFields();
const multiSelect = fields.length > 1;
const savableOptions = modelUtil.savingComputed({
read: () => {
@ -256,10 +261,10 @@ export class ViewFieldConfig {
});
result.revert = () => { zip(fields, state).forEach(([f, s]) => f!.style(s!)); };
return result;
});
}));
this.headerStyle = ko.pureComputed(() => {
const fields = this.fields();
this.headerStyle = owner.autoDispose(ko.pureComputed(() => {
const fields = listFields();
const multiSelect = fields.length > 1;
const savableOptions = modelUtil.savingComputed({
read: () => {
@ -318,7 +323,7 @@ export class ViewFieldConfig {
});
result.revert = () => { zip(fields, state).forEach(([f, s]) => f!.headerStyle(s!)); };
return result;
});
}));
}
// Helper for Choice/ChoiceList columns, that saves widget options and renames values in a document
@ -328,7 +333,7 @@ export class ViewFieldConfig {
const tableId = this._field.column.peek().table.peek().tableId.peek();
if (this.multiselect.peek()) {
this._field.config.options.update(options);
const colIds = this.fields.peek().map(f => f.colId.peek());
const colIds = this.fields.peek().filter(f => !f.isDisposed()).map(f => f.colId.peek());
return this._docModel.docData.bundleActions("Update choices configuration", () => Promise.all([
this._field.config.options.save(),
!hasRenames ? null : this._docModel.docData.sendActions(

View File

@ -63,6 +63,11 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
displayColModel: ko.Computed<ColumnRec>;
visibleColModel: ko.Computed<ColumnRec>;
// Reverse Ref/RefList column for this column. Only for Ref/RefList columns in two-way relations.
reverseColModel: ko.Computed<ColumnRec>;
// If this column has a relation.
hasReverse: ko.Computed<boolean>;
disableModifyBase: ko.Computed<boolean>; // True if column config can't be modified (name, type, etc.)
disableModify: ko.Computed<boolean>; // True if column can't be modified (is summary) or is being transformed.
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
@ -94,6 +99,12 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
saveDisplayFormula(formula: string): Promise<void>|undefined;
createValueParser(): (value: string) => any;
/** Helper method to add a reverse column (only for Ref/RefList) */
addReverseColumn(): Promise<void>;
/** Helper method to remove a reverse column (only for Ref/RefList) */
removeReverseColumn(): Promise<void>;
}
export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
@ -138,6 +149,8 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
// The display column to use for the column, or the column itself when no displayCol is set.
this.displayColModel = refRecord(docModel.columns, this.displayColRef);
this.reverseColModel = refRecord(docModel.columns, this.reverseCol);
this.hasReverse = this.autoDispose(ko.pureComputed(() => Boolean(this.reverseColModel().id())));
this.visibleColModel = refRecord(docModel.columns, this.visibleCol);
this.disableModifyBase = ko.pureComputed(() => Boolean(this.summarySourceCol()));
@ -184,6 +197,21 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
}
return JSON.stringify(options);
});
this.addReverseColumn = () => {
return docModel.docData.sendAction(['AddReverseColumn', this.table.peek().tableId.peek(), this.colId.peek()]);
};
this.removeReverseColumn = async () => {
if (!this.hasReverse.peek()) {
throw new Error("Column does not have a reverse column");
}
// Remove the other column. Data engine will take care of removing the relation.
const column = this.reverseColModel.peek();
const tableId = column.table.peek().tableId.peek();
const colId = column.colId.peek();
return await docModel.docData.sendAction(['RemoveColumn', tableId, colId]);
};
}
export function formatterForRec(

View File

@ -129,11 +129,10 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.colId = this.autoDispose(ko.pureComputed(() => this.column().colId()));
this.label = this.autoDispose(ko.pureComputed(() => this.column().label()));
this.origLabel = this.autoDispose(ko.pureComputed(() => this.origCol().label()));
this.description = modelUtil.savingComputed({
this.description = this.autoDispose(modelUtil.savingComputed({
read: () => this.column().description(),
write: (setter, val) => setter(this.column().description, val)
});
}));
// displayLabel displays label by default but switches to the more helpful colId whenever a
// formula field in the view is being edited.
this.displayLabel = modelUtil.savingComputed({
@ -143,17 +142,17 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
// The field knows when we are editing a formula, so that all rows can reflect that.
const _editingFormula = ko.observable(false);
this.editingFormula = ko.pureComputed({
this.editingFormula = this.autoDispose(ko.pureComputed({
read: () => _editingFormula(),
write: val => {
// Whenever any view field changes its editingFormula status, let the docModel know.
docModel.editingFormula(val);
_editingFormula(val);
}
});
}));
// CSS class to add to formula cells, incl. to show that we are editing this field's formula.
this.formulaCssClass = ko.pureComputed<string|null>(() => {
this.formulaCssClass = this.autoDispose(ko.pureComputed<string|null>(() => {
const col = this.column();
// If the current column is transforming, assign the CSS class "transform_field"
@ -175,7 +174,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
else {
return null;
}
});
}));
// The fields's display column
this._displayColModel = refRecord(docModel.columns, this.displayCol);
@ -203,10 +202,10 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
// Display col ref to use for the field, defaulting to the plain column itself.
this.displayColRef = this.autoDispose(ko.pureComputed(() => this._fieldOrColumn().displayCol() || this.colRef()));
this.visibleColRef = modelUtil.addSaveInterface(ko.pureComputed({
this.visibleColRef = modelUtil.addSaveInterface(this.autoDispose(ko.pureComputed({
read: () => this._fieldOrColumn().visibleCol(),
write: (colRef) => this._fieldOrColumn().visibleCol(colRef),
}),
})),
colRef => docModel.docData.bundleActions(null, async () => {
const col = docModel.columns.getRowModel(colRef);
await Promise.all([
@ -222,9 +221,13 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
// Helper for Reference/ReferenceList columns, which returns a formatter according to the visibleCol
// associated with this field. If no visible column available, return formatting for the field itself.
this.visibleColFormatter = ko.pureComputed(() => formatterForRec(this, this.column(), docModel, 'vcol'));
this.visibleColFormatter = this.autoDispose(
ko.pureComputed(() => formatterForRec(this, this.column(), docModel, 'vcol'))
);
this.formatter = ko.pureComputed(() => formatterForRec(this, this.column(), docModel, 'full'));
this.formatter = this.autoDispose(
ko.pureComputed(() => formatterForRec(this, this.column(), docModel, 'full'))
);
this.createValueParser = function() {
const fieldRef = this.useColOptions.peek() ? undefined : this.id.peek();
@ -264,8 +267,8 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.headerFontStrikethrough = this.widgetOptionsJson.prop('headerFontStrikethrough');
this.question = this.widgetOptionsJson.prop('question');
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
this.style = ko.pureComputed({
this.documentSettings = this.autoDispose(ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson()));
this.style = this.autoDispose(ko.pureComputed({
read: () => ({
textColor: this.textColor(),
fillColor: this.fillColor(),
@ -277,8 +280,8 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
write: (style: Style) => {
this.widgetOptionsJson.update(style);
},
});
this.headerStyle = ko.pureComputed({
}));
this.headerStyle = this.autoDispose(ko.pureComputed({
read: () => ({
headerTextColor: this.headerTextColor(),
headerFillColor: this.headerFillColor(),
@ -290,19 +293,21 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
write: (headerStyle: HeaderStyle) => {
this.widgetOptionsJson.update(headerStyle);
},
});
}));
this.tableId = ko.pureComputed(() => this.column().table().tableId());
this.tableId = this.autoDispose(ko.pureComputed(() => this.column().table().tableId()));
this.rulesList = modelUtil.savingComputed({
read: () => this._fieldOrColumn().rules(),
write: (setter, val) => setter(this._fieldOrColumn().rules, val)
});
this.rulesCols = refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules()));
this.rulesColsIds = ko.pureComputed(() => this.rulesCols().map(c => c.colId()));
this.rulesCols = this.autoDispose(
refListRecords(docModel.columns, ko.pureComputed(() => this._fieldOrColumn().rules()))
);
this.rulesColsIds = this.autoDispose(ko.pureComputed(() => this.rulesCols().map(c => c.colId())));
this.rulesStyles = modelUtil.fieldWithDefault(
this.widgetOptionsJson.prop("rulesOptions") as modelUtil.KoSaveableObservable<Style[]>,
[]);
this.hasRules = ko.pureComputed(() => this.rulesCols().length > 0);
this.hasRules = this.autoDispose(ko.pureComputed(() => this.rulesCols().length > 0));
// Helper method to add an empty rule (either initial or additional one).
// Style options are added to widget options directly and can be briefly out of sync,

View File

@ -10,6 +10,15 @@ const G = getBrowserGlobals('document', 'window');
let _notifier: Notifier;
/**
* Doesn't show or trigger any UI when thrown. Use it when you will handle it yourself, but
* need to stop any futher actions from the app. Currently only used in the model that tries
* to react in response of UNIQUE reference constraint validation.
*/
export class MutedError extends Error {
}
export class UserError extends Error {
public name: string = "UserError";
public key?: string;
@ -106,6 +115,9 @@ const unhelpfulErrors = new Set<string>();
* this function might show a simple toast message.
*/
export function reportError(err: Error|string, ev?: ErrorEvent): void {
if (err instanceof MutedError) {
return;
}
log.error(`ERROR:`, err);
if (String(err).match(/GristWSConnection disposed/)) {
// This error can be emitted while a page is reloaded, and isn't worth reporting.

View File

@ -109,7 +109,7 @@ export function buildFormulaConfig(
) {
// If we can't modify anything about the column.
const disableModify = Computed.create(owner, use => use(origColumn.disableModify));
const disableModify = Computed.create(owner, use => use(origColumn.disableModify) || use(origColumn.hasReverse));
// Intermediate state - user wants to specify formula, but haven't done yet
const maybeFormula = Observable.create(owner, false);
@ -320,8 +320,10 @@ export function buildFormulaConfig(
// Should we disable all other action buttons and formula editor. For now
// we will disable them when multiple columns are selected, or any of the column selected
// can't be modified.
const disableOtherActions = Computed.create(owner, use => use(disableModify) || use(isMultiSelect));
// can't be modified or if the column has a reverse column.
const disableOtherActions = Computed.create(owner,
use => use(disableModify) || use(isMultiSelect) || use(origColumn.hasReverse)
);
const errorMessage = createFormulaErrorObs(owner, gristDoc, origColumn);
// Helper that will create different flavors for formula builder.

View File

@ -43,7 +43,9 @@ export type Tooltip =
| 'accessRulesTableWide'
| 'setChoiceDropdownCondition'
| 'setRefDropdownCondition'
| 'communityWidgets';
| 'communityWidgets'
| 'twoWayReferences'
| 'reasignTwoWayReference';
export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents;
@ -162,6 +164,21 @@ see or edit which parts of your document.')
),
...args,
),
twoWayReferences: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t('Creates a reverse column in target table that can be edited from either end.')
),
...args,
),
reasignTwoWayReference: (...args: DomElementArg[]) => cssTooltipContent(
dom('div',
t('This limitation occurs when one end of a two-way reference is configured as a single Reference.')
),
dom('div',
t('To allow multiple assignments, change the type of the Reference column to Reference List.')
),
...args,
),
};
export interface BehavioralPromptContent {

View File

@ -36,7 +36,7 @@ import {GridOptions} from 'app/client/ui/GridOptions';
import {textarea} from 'app/client/ui/inputs';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSectionConfig";
import {cssLabel} from 'app/client/ui/RightPanelStyles';
import {cssLabel, cssSeparator} from 'app/client/ui/RightPanelStyles';
import {linkId, NoLink, selectBy} from 'app/client/ui/selectBy';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {getTelemetryWidgetTypeFromVS, getWidgetTypes} from "app/client/ui/widgetTypesMap";
@ -1271,10 +1271,6 @@ const cssTabContents = styled('div', `
overflow: auto;
`);
const cssSeparator = styled('div', `
border-bottom: 1px solid ${theme.pagePanelsBorder};
margin-top: 16px;
`);
const cssConfigContainer = styled('div.test-config-container', `
overflow: auto;

View File

@ -15,6 +15,13 @@ export const cssLabel = styled('div', `
font-size: ${vars.xsmallFontSize};
`);
export const cssLabelText = styled('span', `
color: ${theme.text};
text-transform: uppercase;
font-size: ${vars.xsmallFontSize};
`);
export const cssHelp = styled('div', `
color: ${theme.lightText};
margin: -8px 16px 12px 16px;

View File

@ -118,12 +118,11 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
*/
export function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: GristDoc) {
const isReadonly = gristDoc.isReadonly.get();
const isSinglePage = urlState().state.get().params?.style === 'singlePage';
const sectionId = viewSection.table.peek().rawViewSectionRef.peek();
const anchorUrlState = { hash: { sectionId, popup: true } };
const rawUrl = urlState().makeUrl(anchorUrlState);
return [
dom.maybe((use) => !use(viewSection.isRaw) && !isSinglePage && !use(gristDoc.maximizedSectionId),
dom.maybe((use) => !use(viewSection.isRaw) && use(gristDoc.canShowRawData),
() => menuItemLink(
{ href: rawUrl}, t("Show raw data"), testId('show-raw-data'),
dom.on('click', () => {

View File

@ -0,0 +1,376 @@
import * as commands from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {ColumnRec, DocModel} from 'app/client/models/DocModel';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {bigBasicButton, bigPrimaryButton, textButton} from 'app/client/ui2018/buttons';
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
import {theme} from 'app/client/ui2018/cssVars';
import {cssModalBody, cssModalButtons, cssModalTitle, cssModalWidth, modal} from 'app/client/ui2018/modals';
import {DocAction} from 'app/common/DocActions';
import {cached} from 'app/common/gutil';
import {decodeObject, encodeObject} from 'app/plugin/objtypes';
import {dom, Observable, styled} from 'grainjs';
import mapValues from 'lodash/mapValues';
const t = makeT('ReassignModal');
/**
* Builds a modal that shows the user that they can't reassign records because of uniqueness
* constraints on the Ref/RefList column. It shows the user the conflicts and provides option
* to resolve the confilic and retry the change.
*
* Currently we support uniquness only on 2-way referenced columns. While it is techincally
* possible to support it on plain Ref/RefList columns, the implementation assumes that we
* have the reverse column somewhere and can use it to find the conflicts without building
* a dedicated index.
*
* Mental model of data structure:
* Left table: Owners
* Columns: [Name, Pets: RefList(Pets)]
*
* Right table: Pets
* Columns: [Name, Owner: Ref(Owners)]
*
* Actions that were send to the server were updating the Owners table.
*
* Note: They could affect multiple columns, not only the Pets column.
*/
export async function buildReassignModal(options: {
docModel: DocModel,
actions: DocAction[],
}) {
const {docModel, actions} = options;
const tableRec = cached((tableId: string) => {
return docModel.getTableModel(tableId).tableMetaRow;
});
const columnRec = cached((tableId: string, colId: string) => {
const result = tableRec(tableId).columns().all().find(c => c.colId() === colId);
if (!result) {
throw new Error(`Column ${colId} not found in table ${tableId}`);
}
return result;
});
// Helper that gets records, but caches and copies them, so that we can amend them when needed.
const amended = new Map<string, any>();
const getRow = (tableId: string, rowId: number) => {
const key = `${tableId}:${rowId}`;
if (amended.has(key)) {
return amended.get(key);
}
const tableData = docModel.getTableModel(tableId).tableData;
const origRow = tableData.getRecord(rowId);
if (!origRow) {
return null;
}
const row = structuredClone(origRow);
amended.set(key, row);
return row;
};
// Helper that returns name of the row (as seen in Ref editor).
const rowDisplay = cached((tableId: string, rowId: number, colId: string) => {
const col = columnRec(tableId, colId);
// Name of the row (for 2-way reference) is the value of visible column in reverse table.
const visibleCol = col.reverseColModel().visibleColModel().colId();
const record = getRow(tableId, rowId);
return record?.[visibleCol] ?? String(rowId);
});
// We will generate set of problems, and then explain it.
class Problem {
constructor(public data: {
tableId: string,
colRec: ColumnRec,
revRec: ColumnRec,
pointer: number,
newRowId: number,
oldRowId: number,
}) {}
public buildReason() {
// Pets record Azor is already assigned to Owners record Bob.
const {colRec, revRec, pointer, oldRowId} = this.data;
const Pets = revRec.table().tableNameDef();
const Owners = colRec.table().tableNameDef();
const Azor = rowDisplay(revRec.table().tableId(), pointer, revRec.colId()) as string;
const Bob = rowDisplay(colRec.table().tableId(), oldRowId, colRec.colId()) as string;
const text = t(
`{{targetTable}} record {{targetName}} is already assigned to {{sourceTable}} record \
{{oldSourceName}}.`,
{
targetTable: dom('i', Pets),
sourceTable: dom('i', Owners),
targetName: dom('b', Azor),
oldSourceName: dom('b', Bob),
});
return cssBulletLine(text);
}
public buildHeader() {
// Generally we try to show a text like this:
// Each Pets record may only be assigned to a single Owners record.
const {colRec, revRec} = this.data;
// Task is the name of the revRec table
const Pets = revRec.table().tableNameDef();
const Owners = colRec.table().tableNameDef();
return dom('div', [
t(`Each {{targetTable}} record may only be assigned to a single {{sourceTable}} record.`,
{
targetTable: dom('i', Pets),
sourceTable: dom('i', Owners),
})
]);
}
public fixUserAction() {
// Fix action is the action that removes Task 17 from Bob.
const tableId = this.data.tableId;
const colId = this.data.colRec.colId();
const oldRowId = this.data.oldRowId;
const oldRecord = getRow(tableId, oldRowId);
const oldValue = decodeObject(oldRecord[colId]);
let newValue: any = Array.isArray(oldValue)
? oldValue.filter(v => v !== this.data.pointer)
: 0;
if (Array.isArray(newValue) && newValue.length === 0) {
newValue = null;
}
oldRecord[colId] = encodeObject(newValue);
return ['UpdateRecord', tableId, oldRowId, {[colId]: oldRecord[colId]}];
}
public buildAction(checked: Observable<boolean>, multiple: boolean = false) {
// Shows a checkbox and explanation what can be done, checkbox has a text
// Reassing to People record Ann
// Reasing to new Poeple records.
const {colRec, newRowId} = this.data;
const Ann = rowDisplay(colRec.table().tableId(), newRowId, colRec.colId()) as string;
const singleText = () => t(`Reassign to {{sourceTable}} record {{sourceName}}.`,
{
sourceTable: dom('i', colRec.table().tableNameDef()),
sourceName: dom('b', Ann),
});
const multiText = () => t(`Reassign to new {{sourceTable}} records.`,
{
sourceTable: dom('i', colRec.table().tableNameDef()),
});
return labeledSquareCheckbox(checked, multiple ? multiText() : singleText());
}
}
// List of problems we found in actions.
const problems: Problem[] = [];
const uniqueColumns: ColumnRec[] = [];
const newOwners = new Set<number|null>();
// We will hold changes in references, so that we can clear the action itself.
const newValues = new Map<string, Map<number, number>>();
const assignPet = (colId: string, petId: number, ownerId: number) => {
if (!newValues.has(colId)) {
newValues.set(colId, new Map());
}
newValues.get(colId)!.set(petId, ownerId);
};
const wasPetJustAssigned = (colId: string, petId: number) => {
return newValues.has(colId) && newValues.get(colId)!.get(petId);
};
const properActions = [] as DocAction[];
// Helper that unassigns a pet from the owner, by amanding the value stored in Ref/RefList column.
function unassign(value: any, pet: number) {
const newValue = decodeObject(value);
const newValueArray = Array.isArray(newValue) ? newValue : [newValue] as any;
const filteredOut = newValueArray.filter((v: any) => v !== pet);
const wasArray = Array.isArray(newValue);
if (wasArray) {
if (newValueArray.length === 0) {
return null;
}
return encodeObject(filteredOut);
} else {
return filteredOut[0] ?? null;
}
}
// We will go one by one for each action (either update or add), we will flat bulk actions
// and simulate applying them to the data, to test if the following actions won't produce
// conflicts.
for(const origAction of bulkToSingle(actions)) {
const action = structuredClone(origAction);
if (action[0] === 'UpdateRecord' || action[0] === 'AddRecord') {
const ownersTable = action[1]; // this is same for each action.
const newOwnerId = action[2];
newOwners.add(newOwnerId);
const valuesInAction = action[3];
for(const colId of Object.keys(valuesInAction)) {
// We are only interested in uqniue ref columns with reverse column.
const petsCol = columnRec(ownersTable, colId);
const ownerRevCol = petsCol.reverseColModel();
if (!ownerRevCol || !ownerRevCol.id()) {
continue;
}
if (petsCol.reverseColModel().pureType() !== 'Ref') {
continue;
}
const petsTable = ownerRevCol.table().tableId();
uniqueColumns.push(petsCol); // TODO: what it does
// Prepare the data for testing, we will treat Ref as RefList to simplify the code.
const newValue = decodeObject(valuesInAction[colId]);
let petsAfter: number[] = Array.isArray(newValue) ? newValue : [newValue] as any;
const prevValue = decodeObject(getRow(ownersTable, newOwnerId)?.[colId]) ?? [];
const petsBefore: number[] = Array.isArray(prevValue) ? prevValue : [prevValue] as any;
// The new owner will have new pets. We are only interested in a situation
// where owner is assigned with a new pet, if pet was removed, we don't care as this
// won't cause a conflict.
petsAfter = petsAfter.filter(p => !petsBefore.includes(p));
if (petsAfter.length === 0) {
continue;
}
// Now find current owners of the pets that will be assigned to the new owner.
for(const pet of petsAfter) {
// We will use data available in that other table (Pets). Notice that we assume, that
// the reverse column (Owner in Pets) is Ref column.
const oldOwner = getRow(petsTable, pet)?.[ownerRevCol.colId()] as number;
// If the pet didn't have an owner previously, we don't care, we are fine reasigning it.
if (!oldOwner || (typeof oldOwner !== 'number')) {
// We ignore it, but there might be other actions that will try to move this pet
// to other owner, so remember that one.
// But before remembering, check if that hasn't happend already.
const assignedTo = wasPetJustAssigned(petsCol.colId(), pet);
if (assignedTo) {
// We have two actions that will assign the same pet to two different owners.
// We can't allow that, so we will remove this update from the action.
valuesInAction[colId] = unassign(valuesInAction[colId], pet);
} else {
assignPet(colId, pet, newOwnerId);
}
} else {
// If we will assign it to someone else in previous action, ignore this update.
if (wasPetJustAssigned(petsCol.colId(), pet)) {
valuesInAction[colId] = unassign(valuesInAction[colId], pet);
continue;
} else {
assignPet(colId, pet, newOwnerId);
problems.push(new Problem({
tableId: ownersTable,
pointer: pet,
colRec: petsCol,
revRec: ownerRevCol,
newRowId: newOwnerId,
oldRowId: oldOwner,
}));
}
}
}
}
properActions.push(action);
} else {
throw new Error(`Unsupported action ${action[0]}`);
}
}
if (!problems.length) {
throw new Error('No problems found');
}
const checked = Observable.create(null, false);
const multipleOrNew = newOwners.size > 1 || newOwners.has(null);
modal((ctl) => {
const reassign = async () => {
await docModel.docData.sendActions([
...problems.map(p => p.fixUserAction()).filter(Boolean),
...properActions
]);
ctl.close();
};
const configureReference = async () => {
ctl.close();
if (!uniqueColumns.length) { return; }
const revCol = uniqueColumns[0].reverseColModel();
const rawViewSection = revCol.table().rawViewSection();
if (!rawViewSection) { return; }
await commands.allCommands.showRawData.run(rawViewSection.id());
const reverseColId = revCol.colId.peek();
if (!reverseColId) { return; } // might happen if it is censored.
const targetField = rawViewSection.viewFields.peek().all()
.find(f => f.colId.peek() === reverseColId);
if (!targetField) { return; }
await commands.allCommands.setCursor.run(null, targetField);
await commands.allCommands.rightPanelOpen.run();
await commands.allCommands.fieldTabOpen.run();
};
return [
cssModalWidth('normal'),
cssModalTitle(t('Record already assigned', {count: problems.length})),
cssModalBody(() => {
// Show single problem in a simple way.
return dom('div',
problems[0].buildHeader(),
dom('div',
dom.style('margin-top', '18px'),
dom('div', problems.slice(0, 4).map(p => p.buildReason())),
problems.length <= 4 ? null : dom('div', `... and ${problems.length - 4} more`),
dom('div',
problems[0].buildAction(checked, multipleOrNew),
dom.style('margin-top', '18px'),
),
),
);
}),
cssModalButtons(
dom.style('display', 'flex'),
dom.style('justify-content', 'space-between'),
dom.style('align-items', 'baseline'),
dom.domComputed(checked, (v) => [
v ? bigPrimaryButton(t('Reassign'), dom.on('click', reassign))
: bigBasicButton(t('Cancel'), dom.on('click', () => ctl.close())),
]),
dom('div',
withInfoTooltip(
textButton('Configure reference', dom.on('click', configureReference)),
'reasignTwoWayReference',
)
)
)
];
});
}
/**
* This function is used to traverse through the actions, and if there are bulk actions, it will
* flatten them to equivalent single actions.
*/
function* bulkToSingle(actions: DocAction[]): Iterable<DocAction> {
for(const a of actions) {
if (a[0].startsWith('Bulk')) {
const name = a[0].replace('Bulk', '') as 'AddRecord' | 'UpdateRecord';
const rowIds = a[2] as number[];
const tableId = a[1];
const colValues = a[3] as any;
for (let i = 0; i < rowIds.length; i++) {
yield [name, tableId, rowIds[i], mapValues(colValues, (values) => values[i])];
}
} else {
yield a;
}
}
}
const cssBulletLine = styled('div', `
margin-bottom: 8px;
&::before {
content: '•';
margin-right: 4px;
color: ${theme.lightText};
}
`);

View File

@ -6,7 +6,7 @@
*/
import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {GristTooltips, Tooltip} from 'app/client/ui/GristTooltips';
import {GristTooltips, Tooltip, TooltipContentFunc} from 'app/client/ui/GristTooltips';
import {prepareForTransition} from 'app/client/ui/transitions';
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
@ -324,12 +324,12 @@ export type InfoTooltipVariant = 'click' | 'hover';
* Renders an info icon that shows a tooltip with the specified `content`.
*/
export function infoTooltip(
tooltip: Tooltip,
tooltip: Tooltip|TooltipContentFunc,
options: InfoTooltipOptions = {},
...domArgs: DomElementArg[]
) {
const {variant = 'click'} = options;
const content = GristTooltips[tooltip]();
const content = typeof tooltip === 'function' ? tooltip() : GristTooltips[tooltip]();
const onOpen = () => logTelemetryEvent('viewedTip', {full: {tipName: tooltip}});
switch (variant) {
case 'click': {
@ -437,7 +437,7 @@ export function withInfoTooltip(
options: WithInfoTooltipOptions = {},
) {
const {variant = 'click', domArgs, iconDomArgs, popupOptions} = options;
return cssDomWithTooltip(
return cssInfoTooltip(
domContents,
infoTooltip(tooltip, {variant, popupOptions}, iconDomArgs),
...(domArgs ?? [])
@ -475,6 +475,12 @@ export function descriptionInfoTooltip(
);
}
const cssInfoTooltip = styled('div', `
display: flex;
align-items: center;
column-gap: 8px;
`);
const cssTooltipCorner = styled('div', `
position: absolute;
width: 0;
@ -606,9 +612,3 @@ const cssInfoTooltipPopupCloseButton = styled('div', `
background-color: ${theme.hover};
}
`);
const cssDomWithTooltip = styled('div', `
display: flex;
align-items: center;
column-gap: 8px;
`);

View File

@ -2,7 +2,7 @@ import { ColumnTransform } from 'app/client/components/ColumnTransform';
import { Cursor } from 'app/client/components/Cursor';
import { FormulaTransform } from 'app/client/components/FormulaTransform';
import { GristDoc } from 'app/client/components/GristDoc';
import { addColTypeSuffix, guessWidgetOptionsSync } from 'app/client/components/TypeConversion';
import { addColTypeSuffix, guessWidgetOptionsSync, inferColTypeSuffix } from 'app/client/components/TypeConversion';
import { TypeTransform } from 'app/client/components/TypeTransform';
import { FloatingEditor } from 'app/client/widgets/FloatingEditor';
import { UnsavedChange } from 'app/client/components/UnsavedChanges';
@ -365,7 +365,7 @@ export class FieldBuilder extends Disposable {
// the full type, and set it. If multiple columns are selected (and all are formulas/empty),
// then we will set the type for all of them using full type guessed from the first column.
const column = this.field.column(); // same as this.origColumn.
const calculatedType = addColTypeSuffix(newType, column, this._docModel);
const calculatedType = inferColTypeSuffix(newType, column) ?? addColTypeSuffix(newType, column, this._docModel);
const fields = this.field.viewSection.peek().selectedFields.peek();
// If we selected multiple empty/formula columns, make the change for all of them.
if (

View File

@ -1,9 +1,9 @@
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
import {
FormFieldRulesConfig,
FormOptionsSortConfig,
FormSelectConfig
} from 'app/client/components/Forms/FormConfig';
import {DropdownConditionConfig} from 'app/client/components/DropdownConditionConfig';
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
@ -15,6 +15,7 @@ import {hideInPrintView, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, select} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {ReverseReferenceConfig} from 'app/client/widgets/ReverseReferenceConfig';
import {isFullReferencingType, isVersions} from 'app/common/gristTypes';
import {UIRowId} from 'app/plugin/GristAPI';
import {Computed, dom, styled} from 'grainjs';
@ -58,6 +59,7 @@ export class Reference extends NTextBox {
return [
this.buildTransformConfigDom(),
dom.create(DropdownConditionConfig, this.field, gristDoc),
dom.create(ReverseReferenceConfig, this.field),
cssLabel(t('CELL FORMAT')),
super.buildConfigDom(gristDoc),
];

View File

@ -0,0 +1,236 @@
import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {TableRec} from 'app/client/models/DocModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {
cssLabelText,
cssRow,
cssSeparator
} from 'app/client/ui/RightPanelStyles';
import {withInfoTooltip} from 'app/client/ui/tooltips';
import {textButton} from 'app/client/ui2018/buttons';
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
import {cssIconButton, icon} from 'app/client/ui2018/icons';
import {confirmModal} from 'app/client/ui2018/modals';
import {not} from 'app/common/gutil';
import {Computed, Disposable, dom, styled} from 'grainjs';
const t = makeT('ReverseReferenceConfig');
/**
* Configuratino for two-way reference column shown in the right panel.
*/
export class ReverseReferenceConfig extends Disposable {
private _refTable: Computed<TableRec | null>;
private _isConfigured: Computed<boolean>;
private _reverseTable: Computed<string>;
private _reverseColumn: Computed<string>;
private _reverseType: Computed<string>;
private _disabled: Computed<boolean>;
constructor(private _field: ViewFieldRec) {
super();
this._refTable = Computed.create(this, (use) => use(use(this._field.column).refTable));
this._isConfigured = Computed.create(this, (use) => {
const column = use(this._field.column);
return use(column.hasReverse);
});
this._reverseTable = Computed.create(this, this._refTable, (use, refTable) => {
return refTable ? use(refTable.tableNameDef) : '';
});
this._reverseColumn = Computed.create(this, (use) => {
const column = use(this._field.column);
const reverseCol = use(column.reverseColModel);
return reverseCol ? use(reverseCol.label) ?? use(reverseCol.colId) : '';
});
this._reverseType = Computed.create(this, (use) => {
const column = use(this._field.column);
const reverseCol = use(column.reverseColModel);
return reverseCol ? use(reverseCol.pureType) : '';
});
this._disabled = Computed.create(this, (use) => {
// If is formula or is trigger formula.
const column = use(this._field.column);
return Boolean(use(column.formula));
});
}
public buildDom() {
return dom('div',
dom.maybe(not(this._isConfigured), () => [
cssRow(
dom.style('margin-top', '16px'),
cssRow.cls('-disabled', this._disabled),
withInfoTooltip(
textButton(
t('Add two-way reference'),
dom.on('click', (e) => this._toggle(e)),
testId('add-reverse-columm'),
dom.prop('disabled', this._disabled),
),
'twoWayReferences'
),
),
]),
dom.maybe(this._isConfigured, () => cssTwoWayConfig(
// TWO-WAY REFERENCE (?) [Remove]
cssRow(
dom.style('justify-content', 'space-between'),
withInfoTooltip(
cssLabelText(
t('Two-way Reference'),
),
'twoWayReferences'
),
cssIconButton(
icon('Remove'),
dom.on('click', (e) => this._toggle(e)),
dom.style('cursor', 'pointer'),
testId('remove-reverse-column'),
),
),
cssRow(
cssContent(
cssClipLine(
cssClipItem(
cssCapitalize(t('Table'), dom.style('margin-right', '8px')),
dom('span', dom.text(this._reverseTable)),
),
),
cssFlexBetween(
cssClipItem(
cssCapitalize(t('Column'), dom.style('margin-right', '8px')),
dom('span', dom.text(this._reverseColumn)),
cssGrayText('(', dom.text(this._reverseType), ')')
),
cssIconButton(
cssShowOnHover.cls(''),
cssNoClip.cls(''),
cssIconAccent('Pencil'),
dom.on('click', () => this._editConfigClick()),
dom.style('cursor', 'pointer'),
testId('edit-reverse-column'),
),
),
),
testId('reverse-column-label'),
),
cssSeparator(
dom.style('margin-top', '16px'),
),
)),
);
}
private async _toggle(e: Event) {
e.stopPropagation();
e.preventDefault();
const column = this._field.column.peek();
if (!this._isConfigured.get()) {
await column.addReverseColumn();
return;
}
const onConfirm = async () => {
await column.removeReverseColumn();
};
const revColumnLabel = column.reverseColModel.peek().label.peek() || column.reverseColModel.peek().colId.peek();
const revTableName = column.reverseColModel.peek().table.peek().tableNameDef.peek();
const promptTitle = t('Delete column {{column}} in table {{table}}?', {
column: dom('b', revColumnLabel),
table: dom('b', revTableName),
});
const myTable = column.table.peek().tableNameDef.peek();
const myName = column.label.peek() || column.colId.peek();
const explanation = t('It is the reverse of the reference column {{column}} in table {{table}}.', {
column: dom('b', myName),
table: dom('b', myTable)
});
confirmModal(
promptTitle,
t('Delete'),
onConfirm,
{
explanation,
width: 'fixed-wide'
}
);
}
private async _editConfigClick() {
const rawViewSection = this._refTable.get()?.rawViewSection.peek();
if (!rawViewSection) { return; }
await allCommands.showRawData.run(this._refTable.get()?.rawViewSectionRef.peek());
const reverseColId = this._field.column.peek().reverseColModel.peek().colId.peek();
if (!reverseColId) { return; } // might happen if it is censored.
const targetField = rawViewSection.viewFields.peek().all()
.find(f => f.colId.peek() === reverseColId);
if (!targetField) { return; }
await allCommands.setCursor.run(null, targetField);
}
}
const cssTwoWayConfig = styled('div', ``);
const cssShowOnHover = styled('div', `
visibility: hidden;
.${cssTwoWayConfig.className}:hover & {
visibility: visible;
}
`);
const cssContent = styled('div', `
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
`);
const cssFlexRow = styled('div', `
display: flex;
align-items: center;
overflow: hidden;
`);
const cssFlexBetween = styled(cssFlexRow, `
justify-content: space-between;
overflow: hidden;
`);
const cssCapitalize = styled('span', `
text-transform: uppercase;
font-size: ${vars.xsmallFontSize};
color: ${theme.lightText};
`);
const cssClipLine = styled('div', `
display: flex;
align-items: baseline;
gap: 3px;
overflow: hidden;
flex: 1;
`);
const cssClipItem = styled('div', `
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`);
const cssNoClip = styled('div', `
flex: none;
`);
const cssGrayText = styled('span', `
color: ${theme.lightText};
margin-left: 4px;
`);
const cssIconAccent = styled(icon, `
--icon-color: ${theme.accentIcon};
`);

View File

@ -1069,3 +1069,19 @@ export function computedOwned<T>(
}
export type Constructor<T> = new (...args: any[]) => T;
/**
* Simple memoization function that caches the result of a function call based on its arguments.
* Unlike lodash's memoize, it uses all arguments to generate the key.
*/
export function cached<T>(fn: T): T {
const dict = new Map();
const impl = (...args: any[]) => {
const key = JSON.stringify(args);
if (!dict.has(key)) {
dict.set(key, (fn as any)(...args));
}
return dict.get(key);
};
return impl as any as T;
}

View File

@ -545,6 +545,8 @@ class BaseReferenceColumn(BaseColumn):
value = objtypes.decode_object(value)
return self._target_table.lookup_one_record(**{col_id: value})
class UniqueReferenceError(ValueError):
pass
class ReferenceColumn(BaseReferenceColumn):
"""
@ -564,7 +566,7 @@ class ReferenceColumn(BaseReferenceColumn):
def _list_to_value(self, value_as_list):
if len(value_as_list) > 1:
raise ValueError("UNIQUE reference constraint failed for action")
raise UniqueReferenceError("UNIQUE reference constraint violated")
return value_as_list[0] if value_as_list else 0
def _clean_up_value(self, value):

View File

@ -1157,6 +1157,41 @@ class TestTwoWayReferences(test_engine.EngineTestCase):
[Azor, "Azor", Alice],
])
def test_back_update_empty_column(self):
"""
There was a bug. When user cretes a reverse column for an empty column, and then updates the
reverse column first, the empty column wasn't updated (as it was seen as empty).
"""
# Load pets sample
self.load_pets()
# Remove owner and add it back as empty column.
self.apply_user_action(["RemoveColumn", "Pets", "Owner"])
self.apply_user_action(["AddColumn", "Pets", "Owner", {
"type": "Ref:Owners",
"isFormula": True,
"formula": '',
}])
# Now add reverse column for Owner
self.apply_user_action(["AddReverseColumn", 'Pets', 'Owner'])
# And now add Rex with Alice as an owner using Owners table
self.apply_user_action(["UpdateRecord", "Owners", Alice, {"Pets": ['L', Rex]}])
# Make sure we see the data
self.assertTableData("Owners", cols="subset", data=[
["id", "Name", "Pets"],
[1, "Alice", [Rex]],
[2, "Bob", EmptyList],
])
self.assertTableData("Pets", cols="subset", data=[
["id", "Name", "Owner"],
[Rex, "Rex", Alice],
])
def uniqueReferences(rec):
return rec.reverseCol and rec.reverseCol.type.startswith('Ref:')

View File

@ -257,6 +257,26 @@ class UserActions(object):
self._engine.out_actions.direct.append(self._indirection_level == DIRECT_ACTION)
self._engine.apply_doc_action(action)
def _do_extra_doc_action(self, action):
# It this is Update, Add (or Bulks), run thouse actions through ensure_column_accepts_data
# to ensure that the data is valid.
converted_action = action
if isinstance(action, (actions.BulkAddRecord, actions.BulkUpdateRecord)):
if isinstance(action, actions.BulkAddRecord):
ActionType = actions.BulkAddRecord
else:
ActionType = actions.BulkUpdateRecord
# Iterate over every column and make sure it accepts data.
table_id, row_ids, column_values = action
for col_id, values in six.iteritems(column_values):
column_values[col_id] = self._ensure_column_accepts_data(table_id, col_id, values)
converted_action = ActionType(table_id, row_ids, column_values)
return self._do_doc_action(converted_action)
def _bulk_action_iter(self, table_id, row_ids, col_values=None):
"""
Helper for processing Bulk actions, which generates a list of (i, record, value_dict) tuples,
@ -408,7 +428,7 @@ class UserActions(object):
# If any extra actions were generated (e.g. to adjust positions), apply them.
for a in extra_actions:
self._do_doc_action(a)
self._do_extra_doc_action(a)
# We could set static default values for omitted data columns, or we can ensure that other
# code (JS, DocStorage) is aware of the static defaults. Since other code is already aware,
@ -504,7 +524,7 @@ class UserActions(object):
# If any extra actions were generated (e.g. to adjust positions), apply them.
for a in extra_actions:
self._do_doc_action(a)
self._do_extra_doc_action(a)
# Finally, update the record
self._do_doc_action(action)
@ -1781,6 +1801,13 @@ class UserActions(object):
if widgetOptions is None:
widgetOptions = src_col.widgetOptions
# If we are changing type, and this column is reverse column, make sure it is compatible.
# If not, break the connection first, UI should have already warned the user.
existing_type = dst_col.type
new_type = src_col.type
if not is_compatible_ref_type(new_type, existing_type) and dst_col.reverseCol:
self._docmodel.update([dst_col, src_col], reverseCol=0)
# Update the destination column to match the source's type and options. Also unset displayCol,
# except if src_col has a displayCol, then keep it unchanged until SetDisplayFormula below.
self._docmodel.update([dst_col], type=src_col.type, widgetOptions=[widgetOptions],
@ -1936,6 +1963,7 @@ class UserActions(object):
ret = self.AddVisibleColumn(target_table_id, reverse_label, {
"isFormula": False,
"type": "RefList:" + table_id,
"label": reverse_label,
})
added_col = self._docmodel.columns.table.get_record(ret['colRef'])
self._docmodel.update([col_rec], reverseCol=added_col.id)

View File

@ -7,12 +7,12 @@ describe('TwoWayReference', function() {
this.timeout('3m');
let session: Session;
let docId: string;
let revert: () => Promise<void>;
const cleanup = setupTestSuite();
afterEach(() => gu.checkForErrors());
before(async function() {
session = await gu.session().login();
docId = await session.tempNewDoc(cleanup);
await gu.toggleSidePanel('left', 'close');
await petsSetup();
});
@ -34,12 +34,124 @@ describe('TwoWayReference', function() {
await gu.addNewSection('Table', 'Pets');
await gu.openColumnPanel('Owner');
await gu.setRefShowColumn('Name');
await addReverseColumn('Pets', 'Owner');
await addReverseColumn();
}
it('works after reload', async function() {
const revert = await gu.begin();
await gu.selectSectionByTitle('OWNERS');
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2]), ['', 'Rex']);
await session.createHomeApi().getDocAPI(docId).forceReload();
await driver.navigate().refresh();
await gu.waitForDocToLoad();
// Change Rex owner to Alice.
await gu.selectSectionByTitle('PETS');
await gu.getCell('Owner', 1).click();
await gu.sendKeys('Alice', Key.ENTER);
await gu.waitForServer();
await gu.selectSectionByTitle('OWNERS');
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2]), ['Rex', '']);
await revert();
});
it('creates proper names when labels are not standard', async function() {
const revert = await gu.begin();
await gu.toggleSidePanel('left', 'close');
// Remove the reverse column, then rename the table to contain illegal characters
// in label, and add ref columns to it.
await gu.selectSectionByTitle('PETS');
await gu.openColumnPanel('Owner');
await removeTwoWay();
await removeModal.wait();
await removeModal.confirm();
await gu.waitForServer();
// Now add another Ref:Owners column to Pets table.
await gu.sendActions([
['AddVisibleColumn', 'Pets', 'Friend', {type: 'Ref:Owners'}],
]);
await gu.selectColumn('Friend');
await gu.setRefShowColumn('Name');
await gu.getCell('Friend', 1).click();
await gu.enterCell('Bob', Key.ENTER);
await gu.waitForServer();
// Now rename the Pets table to start with a number and contain a space + person emoji.
const LABEL = '2 🧑 + 🐕';
await gu.renameTable('Pets', LABEL);
// Now create reverse column for Owner and Friend.
await gu.openColumnPanel('Owner');
await addReverseColumn();
await gu.openColumnPanel('Friend');
await addReverseColumn();
// Hide side panels.
await gu.toggleSidePanel('left', 'close');
await gu.toggleSidePanel('right', 'close');
// Make sure we see proper data.
await gu.assertGridData(LABEL, [
[0, "Name", "Owner", "Friend"],
[1, "Rex", "Alice", "Bob"],
]);
await gu.assertGridData("OWNERS", [
[0, "Name", LABEL, `${LABEL}-Friend`],
[1, "Alice", "Rex", ""],
[2, "Bob", "", "Rex"],
]);
await gu.selectSectionByTitle("OWNERS");
// Check that creator panel contains proper names.
await gu.openColumnPanel(LABEL);
assert.equal(await driver.find('.test-field-col-id').value(), '$c2_');
await revert();
});
it('properly reasings reflists', async function() {
const revert = await gu.begin();
// Add two more dogs and move all of them to Alice
await gu.sendActions([
['AddRecord', 'Pets', null, {Name: 'Pluto', Owner: 1}],
['AddRecord', 'Pets', null, {Name: 'Azor', Owner: 1}],
['UpdateRecord', 'Pets', 1, {Owner: 1}],
]);
// Now reasign Azor to Bob using Owners table.
await gu.selectSectionByTitle('OWNERS');
await gu.getCell('Pets', 2).click();
await gu.sendKeys(Key.ENTER, 'Azor', Key.ENTER, Key.ENTER);
await gu.waitForServer();
// Make sure we see it.
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2]), ['Rex\nPluto\nAzor', '']);
// We are now in a modal dialog.
assert.equal(
await driver.findWait('.test-modal-dialog label', 100).getText(),
'Reassign to Owners record Bob.'
);
// Reassign it.
await driver.findWait('.test-modal-dialog input', 100).click();
await driver.findWait('.test-modal-dialog button', 100).click();
await gu.waitForServer();
// Make sure we see correct value.
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2]), ['Rex\nPluto', 'Azor']);
await revert();
});
it('deletes tables with 2 way references', async function() {
const revert = await gu.begin();
await gu.toggleSidePanel('left', 'open');
const beforeRemove = await gu.begin();
await driver.find('.test-tools-raw').click();
const removeTable = async (tableId: string) => {
await driver.findWait(`.test-raw-data-table-menu-${tableId}`, 1000).click();
@ -48,10 +160,11 @@ describe('TwoWayReference', function() {
await gu.waitForServer();
};
await removeTable('Pets');
await revert();
await beforeRemove();
await removeTable('Owners');
await gu.checkForErrors();
await revert();
await gu.toggleSidePanel('left', 'open');
await gu.openPage('Table1');
});
@ -59,7 +172,7 @@ describe('TwoWayReference', function() {
const revert = await gu.begin();
await gu.selectSectionByTitle('Owners');
await gu.selectColumn('Pets');
await gu.openColumnPanel('Pets');
await gu.setType('Reference', {apply: true});
await gu.setType('Reference List', {apply: true});
@ -79,6 +192,16 @@ describe('TwoWayReference', function() {
await gu.toggleSidePanel('left', 'close');
await gu.toggleSidePanel('right', 'close');
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
[1, "Alice", "Rex"],
[2, "Bob", ""],
]);
await gu.assertGridData("PETS", [
[0, "Name", "Owner"],
[1, "Rex", "Alice"],
]);
// Remove the reverse column.
await gu.selectSectionByTitle('OWNERS');
await gu.deleteColumn('Pets');
@ -89,46 +212,74 @@ describe('TwoWayReference', function() {
['Name'],
['Name', 'Owner']
]);
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1], 'PETS'), ['Bob']);
await gu.assertGridData("PETS", [
[0, "Name", "Owner"],
[1, "Rex", "Alice"],
]);
await gu.undo();
// Check data.
assert.deepEqual(await columns(), [
['Name', 'Pets'],
['Name', 'Owner']
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
[1, "Alice", "Rex"],
[2, "Bob", ""],
]);
await gu.assertGridData("PETS", [
[0, "Name", "Owner"],
[1, "Rex", "Alice"],
]);
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2], 'OWNERS'), ['', 'Rex']);
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1], 'PETS'), ['Bob']);
// Check that connection works.
// Make sure we can change data.
await gu.selectSectionByTitle('PETS');
await gu.getCell('Owner', 1).click();
await gu.enterCell('Alice', Key.ENTER);
await gu.enterCell('Bob', Key.ENTER);
await gu.waitForServer();
await gu.checkForErrors();
// Check data.
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1], 'PETS'), ['Alice']);
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2], 'OWNERS'), ['Rex', '']);
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
[1, "Alice", ""],
[2, "Bob", "Rex"],
]);
await gu.assertGridData("PETS", [
[0, "Name", "Owner"],
[1, "Rex", "Bob"],
]);
// Now delete Owner column, and redo it
await gu.selectSectionByTitle('Pets');
await gu.deleteColumn('Owner');
await gu.checkForErrors();
await gu.undo();
await gu.redo();
await gu.undo();
await gu.checkForErrors();
// Check data.
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1], 'PETS'), ['Alice']);
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2], 'OWNERS'), ['Rex', '']);
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
[1, "Alice", ""],
[2, "Bob", "Rex"],
]);
await gu.assertGridData("PETS", [
[0, "Name", "Owner"],
[1, "Rex", "Bob"],
]);
await revert();
});
it('breaks connection after removing reverseCol', async function() {
const revert = await gu.begin();
// Move Rex to Bob.
await gu.selectSectionByTitle('PETS');
await gu.getCell('Owner', 1).click();
await gu.enterCell('Bob', Key.ENTER);
await gu.waitForServer();
// Make sure Rex is owned by Bob, in both tables.
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
@ -206,25 +357,7 @@ describe('TwoWayReference', function() {
await revert();
});
it('works after reload', async function() {
const revert = await gu.begin();
await gu.selectSectionByTitle('OWNERS');
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2]), ['', 'Rex']);
await session.createHomeApi().getDocAPI(docId).forceReload();
await driver.navigate().refresh();
await gu.waitForDocToLoad();
// Change Rex owner to Alice.
await gu.selectSectionByTitle('PETS');
await gu.getCell('Owner', 1).click();
await gu.sendKeys('Alice', Key.ENTER);
await gu.waitForServer();
await gu.selectSectionByTitle('OWNERS');
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2]), ['Rex', '']);
await revert();
});
async function projectSetup() {
it('common setup', async function() {
await gu.sendActions([
['AddTable', 'Projects', []],
['AddTable', 'People', []],
@ -239,17 +372,104 @@ describe('TwoWayReference', function() {
await gu.selectSectionByTitle('Projects');
await gu.openColumnPanel();
await gu.toggleSidePanel('left', 'close');
}
revert = await gu.begin();
});
it('undo works for adding reverse column', async function() {
await projectSetup();
const revert = await gu.begin();
it('clicking show on creates a new column', async function() {
await gu.selectColumn('Owner');
await addReverseColumn();
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name', 'Projects']
]);
await gu.selectSectionByTitle('People');
await gu.openColumnPanel('Projects');
assert.equal(await configText(), 'Projects.Owner(Ref)');
});
it('can remove two way reference', async function() {
await gu.selectSectionByTitle('Projects');
await gu.openColumnPanel('Owner');
await removeTwoWay();
await removeModal.wait();
await removeModal.confirm();
await gu.waitForServer();
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name']
]);
await addReverseColumn('Projects', 'Owner');
});
it('right column looks ok', async function() {
await addReverseColumn();
await gu.waitForServer();
await gu.selectSectionByTitle('People');
await gu.openColumnPanel('Projects');
assert.equal(await gu.getType(), 'Reference List');
assert.equal(await gu.getRefTable(), 'Projects');
});
it('right column has same options', async function() {
await gu.openColumnPanel('Projects');
assert.equal(await gu.getType(), 'Reference List');
assert.equal(await configText(), 'Projects.Owner(Ref)');
});
it('reloading the page keeps the options', async function() {
await gu.reloadDoc();
await gu.selectSectionByTitle('Projects');
await gu.openColumnPanel('Owner');
assert.equal(await configText(), 'People.Projects(RefList)');
await gu.selectSectionByTitle('People');
await gu.openColumnPanel('Projects');
assert.equal(await configText(), 'Projects.Owner(Ref)');
});
it('relationship can be removed through the right column', async function() {
await removeTwoWay();
await removeModal.confirm();
await gu.waitForServer();
assert.deepEqual(await columns(), [
['Name'],
['Name', 'Projects']
]);
});
it('undo works', async function() {
// First revert all changes.
await revert();
await gu.checkForErrors();
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name']
]);
// Now redo all changes.
await gu.redoAll();
await gu.checkForErrors();
assert.deepEqual(await columns(), [
['Name'],
['Name', 'Projects']
]);
await revert();
await gu.checkForErrors();
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name']
]);
// And now check individual changes.
await gu.selectSectionByTitle('Projects');
await gu.openColumnPanel('Owner');
assert.isTrue(await canAddReverseColumn());
// Now add and do a single undo to make sure it is bundled.
await addReverseColumn();
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name', 'Projects']
@ -259,17 +479,145 @@ describe('TwoWayReference', function() {
['Name', 'Owner'],
['Name']
]);
await gu.redo(1);
});
it('can delete left column', async function() {
await gu.selectSectionByTitle('Projects');
await gu.openColumnPanel('Owner');
await addReverseColumn();
await gu.deleteColumn('Owner');
await gu.checkForErrors();
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name'],
['Name', 'Projects']
]);
await gu.selectSectionByTitle('People');
await gu.openColumnPanel('Projects');
assert.isTrue(await canAddReverseColumn());
await gu.deleteColumn('Projects');
await gu.checkForErrors();
await revert();
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name']
]);
});
it('can delete right column', async function() {
await gu.selectSectionByTitle('Projects');
await gu.openColumnPanel('Owner');
await addReverseColumn();
await gu.selectSectionByTitle('People');
await gu.openColumnPanel('Projects');
await gu.deleteColumn('Projects');
await gu.checkForErrors();
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name']
]);
await gu.selectSectionByTitle('Projects');
await gu.openColumnPanel('Owner');
assert.isFalse(await isConfigured());
});
it('syncs columns', async function() {
await gu.selectSectionByTitle('Projects');
await gu.openColumnPanel('Owner');
await gu.setRefShowColumn('Name');
await addReverseColumn();
// Show better names.
await gu.selectSectionByTitle('People');
await gu.openColumnPanel('Projects');
await gu.setRefShowColumn('Name');
// Add two projects.
await gu.sendActions([
['AddRecord', 'Projects', null, {Name: 'Apps'}],
['AddRecord', 'Projects', null, {Name: 'Backend'}],
]);
// Add two people.
await gu.sendActions([
['AddRecord', 'People', null, {Name: 'Alice'}],
['AddRecord', 'People', null, {Name: 'Bob'}],
]);
// Now assign Bob to Backend and Alice to Apps.
await gu.selectSectionByTitle('Projects');
await gu.getCell('Owner', 1).click();
await gu.enterCell('Alice');
await gu.getCell('Owner', 2).click();
await gu.enterCell('Bob');
// And now make sure the reverse reference is correct.
await gu.selectSectionByTitle('People');
assert.deepEqual(await gu.getVisibleGridCells('Name', [1, 2]), ['Alice', 'Bob']);
assert.deepEqual(await gu.getVisibleGridCells('Projects', [1, 2]), ['Apps', 'Backend']);
});
it('sync columns when edited from right', async function() {
await gu.getCell('Projects', 1).click();
// Remove the project from Alice.
await gu.sendKeys(Key.DELETE);
await gu.waitForServer();
assert.deepEqual(await gu.getVisibleGridCells('Projects', [1, 2], 'People'), ['', 'Backend']);
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1, 2], 'Projects'), ['', 'Bob']);
// Single undo restores it.
await gu.undo(1);
assert.deepEqual(await gu.getVisibleGridCells('Projects', [1, 2], 'People'), ['Apps', 'Backend']);
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1, 2], 'Projects'), ['Alice', 'Bob']);
await gu.redo(1);
assert.deepEqual(await gu.getVisibleGridCells('Projects', [1, 2], 'People'), ['', 'Backend']);
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1, 2], 'Projects'), ['', 'Bob']);
await gu.undo(1);
assert.deepEqual(await gu.getVisibleGridCells('Projects', [1, 2], 'People'), ['Apps', 'Backend']);
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1, 2], 'Projects'), ['Alice', 'Bob']);
});
it('honors relations from list to single', async function() {
// Now make Alice owner of Backend project. Apps project should now have no owner,
// and Bob shouldn't be owner of Backend.
const checkInitial = async () => {
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1, 2], 'Projects'), ['Alice', 'Bob']);
assert.deepEqual(await gu.getVisibleGridCells('Projects', [1, 2], 'People'), ['Apps', 'Backend']);
};
await checkInitial();
await gu.selectSectionByTitle('People');
await gu.getCell('Projects', 1).click();
await gu.sendKeys('Backend');
await gu.sendKeys(Key.ENTER);
await gu.sendKeys(Key.ENTER);
await gu.waitForServer();
// We should see a modal dialog
await driver.findWait('.test-modal-dialog', 100);
// We should have an option there.
assert.equal(
await driver.findWait('.test-modal-dialog label', 100).getText(),
'Reassign to People record Alice.'
);
// Reassign it.
await driver.findWait('.test-modal-dialog input', 100).click();
await driver.findWait('.test-modal-dialog button', 100).click();
await gu.waitForServer();
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1, 2], 'Projects'), ['', 'Alice']);
assert.deepEqual(await gu.getVisibleGridCells('Projects', [1, 2], 'People'), ['Backend', '']);
// Single undo restores it.
await gu.undo(1);
await checkInitial();
});
it('creates proper names when added multiple times', async function() {
const revert = await gu.begin();
await addReverseColumn('Projects', 'Owner');
// Add another reference to Projects from People.
await gu.selectSectionByTitle('Projects');
@ -278,22 +626,22 @@ describe('TwoWayReference', function() {
await gu.setRefShowColumn('Name');
// And now show it on People.
await addReverseColumn('Projects', 'Tester');
await addReverseColumn();
// We should now see 3 columns on People.
await gu.selectSectionByTitle('People');
assert.deepEqual(await gu.getColumnNames(), ['Name', 'Projects', 'Projects_Tester']);
assert.deepEqual(await gu.getColumnNames(), ['Name', 'Projects', 'Projects-Tester']);
// Add yet another one.
await gu.selectSectionByTitle('Projects');
await gu.addColumn('PM', 'Reference');
await gu.setRefTable('People');
await gu.setRefShowColumn('Name');
await addReverseColumn('Projects', 'PM');
await addReverseColumn();
// We should now see 4 columns on People.
await gu.selectSectionByTitle('People');
assert.deepEqual(await gu.getColumnNames(), ['Name', 'Projects', 'Projects_Tester', 'Projects_PM']);
assert.deepEqual(await gu.getColumnNames(), ['Name', 'Projects', 'Projects-Tester', 'Projects-PM']);
await revert();
});
@ -318,7 +666,7 @@ describe('TwoWayReference', function() {
['AddRecord', 'Tasks', null, {Name: 'Child', Parent: -1}],
]);
await gu.openColumnPanel('Parent');
await addReverseColumn('Tasks', 'Parent');
await addReverseColumn();
// We should now see 3 columns on Tasks.
assert.deepEqual(await gu.getColumnNames(), ['Name', 'Parent', 'Tasks']);
@ -362,11 +710,36 @@ describe('TwoWayReference', function() {
});
});
async function addReverseColumn(tableId: string, colId: string) {
await gu.sendActions([
['AddReverseColumn', tableId, colId],
]);
}
const canAddReverseColumn = async () => {
return await driver.findWait('.test-add-reverse-columm', 100).isPresent();
};
const isConfigured = async () => {
if (!await driver.find('.test-reverse-column-label').isPresent()) {
return false;
}
return await driver.findWait('.test-reverse-column-label', 100).isDisplayed();
};
const addReverseColumn = () => driver.findWait('.test-add-reverse-columm', 100)
.click().then(() => gu.waitForServer());
const removeTwoWay = () => driver.findWait('.test-remove-reverse-column', 100).click()
.then(() => gu.waitForServer());
const configText = async () => {
const text = await driver.findWait('.test-reverse-column-label', 100).getText();
return text.trim().split('\n').join('').replace('COLUMN', '.').replace("TABLE", "");
};
const removeModal = {
wait: async () => assert.isTrue(await driver.findWait('.test-modal-confirm', 100).isDisplayed()),
confirm: () => driver.findWait('.test-modal-confirm', 100).click().then(() => gu.waitForServer()),
cancel: () => driver.findWait('.test-modal-cancel', 100).click(),
checkUnlink: () => driver.findWait('.test-option-unlink', 100).click(),
checkRemove: () => driver.findWait('.test-option-remove', 100).click(),
};
/**
* Returns an array of column headers for each table in the document.