gristlabs_grist-core/app/client/ui/buildReassignModal.ts
Jarosław Sadziński e97a45143f (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
2024-09-18 12:07:21 +02:00

377 lines
14 KiB
TypeScript

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};
}
`);