mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
da6c39aa50
commit
e97a45143f
@ -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() {
|
||||
|
@ -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)` : ''));
|
||||
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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', () => {
|
||||
|
376
app/client/ui/buildReassignModal.ts
Normal file
376
app/client/ui/buildReassignModal.ts
Normal 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};
|
||||
}
|
||||
`);
|
@ -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;
|
||||
`);
|
||||
|
@ -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 (
|
||||
|
@ -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),
|
||||
];
|
||||
|
236
app/client/widgets/ReverseReferenceConfig.ts
Normal file
236
app/client/widgets/ReverseReferenceConfig.ts
Normal 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};
|
||||
`);
|
@ -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;
|
||||
}
|
||||
|
@ -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):
|
||||
|
@ -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:')
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user