(core) Enable Record Cards

Summary:
Adds remaining functionality, fixes, and polish to Record Cards and
removes their feature flag, enabling them by default.

Test Plan: Tests deferred; will be included in a follow-up diff.

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: paulfitz, jarek

Differential Revision: https://phab.getgrist.com/D4121
pull/774/head
George Gevoian 7 months ago
parent 84329404a4
commit 707a8c7b32

@ -274,7 +274,9 @@ BaseView.prototype.deleteRecords = function(source) {
buildConfirmDelete(selectedCell, onSave, rowIds.length <= 1); buildConfirmDelete(selectedCell, onSave, rowIds.length <= 1);
} else { } else {
onSave().then(() => { onSave().then(() => {
reportUndo(this.gristDoc, `You deleted ${rowIds.length} row${rowIds.length > 1 ? 's' : ''}.`); if (!this.isDisposed()) {
reportUndo(this.gristDoc, `You deleted ${rowIds.length} row${rowIds.length > 1 ? 's' : ''}.`);
}
return true; return true;
}); });
} }

@ -3,7 +3,6 @@ import {GristDoc} from 'app/client/components/GristDoc';
import {copyToClipboard} from 'app/client/lib/clipboardUtils'; import {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {setTestState} from 'app/client/lib/testState'; import {setTestState} from 'app/client/lib/testState';
import {TableRec} from 'app/client/models/DocModel'; import {TableRec} from 'app/client/models/DocModel';
import {RECORD_CARDS} from 'app/client/models/features';
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable'; import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable';
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
@ -99,13 +98,14 @@ export class DataTables extends Disposable {
hoverTooltip( hoverTooltip(
dom.domComputed(use => use(use(tableRec.recordCardViewSection).disabled) dom.domComputed(use => use(use(tableRec.recordCardViewSection).disabled)
? t('Record Card Disabled') ? t('Record Card Disabled')
: t('Record Card')), : t('Edit Record Card')),
{key: DATA_TABLES_TOOLTIP_KEY, closeOnClick: false} {key: DATA_TABLES_TOOLTIP_KEY, closeOnClick: false}
), ),
dom.hide(!RECORD_CARDS()), dom.hide(this._gristDoc.isReadonly),
// Make the button invisible to maintain consistent alignment with non-summary tables. // Make the button invisible to maintain consistent alignment with non-summary tables.
dom.style('visibility', u => u(tableRec.summarySourceTable) === 0 ? 'visible' : 'hidden'), dom.style('visibility', u => u(tableRec.summarySourceTable) === 0 ? 'visible' : 'hidden'),
cssRecordCardButton.cls('-disabled', use => use(use(tableRec.recordCardViewSection).disabled)), cssRecordCardButton.cls('-disabled', use => use(use(tableRec.recordCardViewSection).disabled)),
testId('table-record-card'),
), ),
cssDotsButton( cssDotsButton(
testId('table-menu'), testId('table-menu'),
@ -120,7 +120,8 @@ export class DataTables extends Disposable {
throw new Error(`Table ${tableRec.tableId.peek()} doesn't have a raw view section.`); throw new Error(`Table ${tableRec.tableId.peek()} doesn't have a raw view section.`);
} }
this._gristDoc.viewModel.activeSectionId(sectionId); this._gristDoc.viewModel.activeSectionId(sectionId);
}) }),
cssTable.cls('-readonly', this._gristDoc.isReadonly),
); );
}) })
), ),
@ -132,7 +133,8 @@ export class DataTables extends Disposable {
return dom.domComputed((use) => { return dom.domComputed((use) => {
const rawViewSectionRef = use(fromKo(table.rawViewSectionRef)); const rawViewSectionRef = use(fromKo(table.rawViewSectionRef));
const isSummaryTable = use(table.summarySourceTable) !== 0; const isSummaryTable = use(table.summarySourceTable) !== 0;
if (!rawViewSectionRef || isSummaryTable) { const isReadonly = use(this._gristDoc.isReadonly);
if (!rawViewSectionRef || isSummaryTable || isReadonly) {
// Some very old documents might not have a rawViewSection, and raw summary // Some very old documents might not have a rawViewSection, and raw summary
// tables can't currently be renamed. // tables can't currently be renamed.
const tableName = [ const tableName = [
@ -185,7 +187,7 @@ export class DataTables extends Disposable {
)), )),
testId('menu-remove-table'), testId('menu-remove-table'),
), ),
dom.maybe(use => RECORD_CARDS() && use(table.summarySourceTable) === 0, () => [ dom.maybe(use => use(table.summarySourceTable) === 0, () => [
menuDivider(), menuDivider(),
menuItem( menuItem(
() => this._editRecordCard(table), () => this._editRecordCard(table),
@ -308,6 +310,10 @@ const cssTable = styled('div', `
&:hover { &:hover {
border-color: ${css.theme.rawDataTableBorderHover}; border-color: ${css.theme.rawDataTableBorderHover};
} }
&-readonly {
/* Row count column is hidden when document is read-only. */
grid-template-columns: 16px auto 56px;
}
`); `);
const cssTableIcon = styled('div', ` const cssTableIcon = styled('div', `

@ -33,6 +33,7 @@ function DetailView(gristDoc, viewSectionModel) {
this.viewFields = gristDoc.docModel.viewFields; this.viewFields = gristDoc.docModel.viewFields;
this._isSingle = (this.viewSection.parentKey.peek() === 'single'); this._isSingle = (this.viewSection.parentKey.peek() === 'single');
this._isExternalSectionPopup = gristDoc.externalSectionId.get() === this.viewSection.id();
//-------------------------------------------------- //--------------------------------------------------
// Create and attach the DOM for the view. // Create and attach the DOM for the view.
@ -191,7 +192,9 @@ DetailView.prototype.deleteRows = async function(rowIds) {
try { try {
await BaseView.prototype.deleteRows.call(this, rowIds); await BaseView.prototype.deleteRows.call(this, rowIds);
} finally { } finally {
this.cursor.rowIndex(index); if (!this.isDisposed()) {
this.cursor.rowIndex(index);
}
} }
}; };
@ -365,7 +368,13 @@ DetailView.prototype.buildTitleControls = function() {
// the controls can be confusing in this case. // the controls can be confusing in this case.
// Note that the controls should still be visible with a filter link. // Note that the controls should still be visible with a filter link.
const showControls = ko.computed(() => { const showControls = ko.computed(() => {
if (!this._isSingle || this.recordLayout.layoutEditor()) { return false; } if (
!this._isSingle||
this.recordLayout.layoutEditor() ||
this._isExternalSectionPopup
) {
return false;
}
const linkingState = this.viewSection.linkingState(); const linkingState = this.viewSection.linkingState();
return !(linkingState && Boolean(linkingState.cursorPos)); return !(linkingState && Boolean(linkingState.cursorPos));
}); });

@ -56,7 +56,6 @@ const {CombinedStyle} = require("app/client/models/Styles");
const {buildRenameColumn} = require('app/client/ui/ColumnTitle'); const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
const {makeT} = require('app/client/lib/localization'); const {makeT} = require('app/client/lib/localization');
const {reportError} = require('app/client/models/AppModel'); const {reportError} = require('app/client/models/AppModel');
const {RECORD_CARDS} = require('app/client/models/features');
const {urlState} = require('app/client/models/gristUrlState'); const {urlState} = require('app/client/models/gristUrlState');
const t = makeT('GridView'); const t = makeT('GridView');
@ -375,13 +374,15 @@ GridView.gridCommands = {
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen); this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
}, },
viewAsCard() { viewAsCard() {
if (!RECORD_CARDS()) { return; }
if (this._isRecordCardDisabled()) { return; } if (this._isRecordCardDisabled()) { return; }
const selectedRows = this.selectedRows(); const selectedRows = this.selectedRows();
if (selectedRows.length !== 1) { return; }
const colRef = this.viewSection.viewFields().at(this.cursor.fieldIndex()).column().id();
const rowId = selectedRows[0]; const rowId = selectedRows[0];
const sectionId = this.viewSection.tableRecordCard().id(); const sectionId = this.viewSection.tableRecordCard().id();
const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}}; const anchorUrlState = {hash: {colRef, rowId, sectionId, recordCard: true}};
urlState().pushUrl(anchorUrlState, {replace: true}).catch(reportError); urlState().pushUrl(anchorUrlState, {replace: true}).catch(reportError);
}, },
}; };

@ -209,6 +209,8 @@ export class GristDoc extends DisposableWithEvents {
private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false); private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false);
private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this); private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this);
private _disableAutoStartingTours: boolean = false; private _disableAutoStartingTours: boolean = false;
private _isShowingPopupSection = false;
private _prevSectionId: number | null = null;
constructor( constructor(
public readonly app: App, public readonly app: App,
@ -565,6 +567,13 @@ export class GristDoc extends DisposableWithEvents {
commands.allCommands.viewTabFocus.run(); commands.allCommands.viewTabFocus.run();
} }
})); }));
this.autoDispose(this._popupSectionOptions.addListener((popupOptions) => {
if (!popupOptions) {
this._isShowingPopupSection = false;
this._prevSectionId = null;
}
}));
} }
/** /**
@ -616,10 +625,16 @@ export class GristDoc extends DisposableWithEvents {
// In case the section is removed, close the popup. // In case the section is removed, close the popup.
content.viewSection.autoDispose({dispose: content.close}); content.viewSection.autoDispose({dispose: content.close});
const {recordCard} = content.hash; const {recordCard, rowId} = content.hash;
if (recordCard) { if (recordCard) {
if (!rowId || rowId === 'new') {
// Should be unreachable, but just to be sure (and to satisfy type checking)...
throw new Error('Unable to open Record Card: undefined row id');
}
return dom.create(RecordCardPopup, { return dom.create(RecordCardPopup, {
gristDoc: this, gristDoc: this,
rowId,
viewSection: content.viewSection, viewSection: content.viewSection,
onClose: content.close, onClose: content.close,
}); });
@ -629,7 +644,15 @@ export class GristDoc extends DisposableWithEvents {
}) : }) :
dom.create((owner) => { dom.create((owner) => {
this.viewLayout = ViewLayout.create(owner, this, content); this.viewLayout = ViewLayout.create(owner, this, content);
this.viewLayout.maximized.addListener(n => this.maximizedSectionId.set(n)); this.viewLayout.maximized.addListener(sectionId => {
this.maximizedSectionId.set(sectionId);
if (sectionId === null && !this._isShowingPopupSection) {
// If we didn't navigate to another section in the popup, move focus
// back to the previous section.
this._focusPreviousSection();
}
});
owner.onDispose(() => this.viewLayout = null); owner.onDispose(() => this.viewLayout = null);
return this.viewLayout; return this.viewLayout;
}) })
@ -1290,11 +1313,11 @@ export class GristDoc extends DisposableWithEvents {
if (!hash.sectionId) { if (!hash.sectionId) {
return; return;
} }
if (!this._prevSectionId) {
this._prevSectionId = this.viewModel.activeSection.peek().id();
}
// We might open popup either for a section in this view or some other section (like Raw Data Page). // We might open popup either for a section in this view or some other section (like Raw Data Page).
if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) { if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) {
if (this.viewLayout) {
this.viewLayout.previousSectionId = this.viewModel.activeSectionId.peek();
}
this.viewModel.activeSectionId(hash.sectionId); this.viewModel.activeSectionId(hash.sectionId);
// If the anchor link is valid, set the cursor. // If the anchor link is valid, set the cursor.
if (hash.colRef && hash.rowId) { if (hash.colRef && hash.rowId) {
@ -1308,10 +1331,10 @@ export class GristDoc extends DisposableWithEvents {
this.viewLayout?.maximized.set(hash.sectionId); this.viewLayout?.maximized.set(hash.sectionId);
return; return;
} }
this._isShowingPopupSection = true;
// We will borrow active viewModel and will trick him into believing that // We will borrow active viewModel and will trick him into believing that
// the section from the link is his viewSection and it is active. Fortunately // the section from the link is his viewSection and it is active. Fortunately
// he doesn't care. After popup is closed, we will restore the original. // he doesn't care. After popup is closed, we will restore the original.
const prevSection = this.viewModel.activeSection.peek();
this.viewModel.activeSectionId(hash.sectionId); this.viewModel.activeSectionId(hash.sectionId);
// Now we have view section we want to show in the popup. // Now we have view section we want to show in the popup.
const popupSection = this.viewModel.activeSection.peek(); const popupSection = this.viewModel.activeSection.peek();
@ -1329,20 +1352,17 @@ export class GristDoc extends DisposableWithEvents {
if (!this._popupSectionOptions.get()) { if (!this._popupSectionOptions.get()) {
return; return;
} }
if (popupSection !== prevSection) { if (popupSection.id() !== this._prevSectionId) {
// We need to blur the popup section. Otherwise it will automatically be opened // We need to blur the popup section. Otherwise it will automatically be opened
// on raw data view. Note: raw data and record card sections don't have parent views; // on raw data view. Note: raw data and record card sections don't have parent views;
// they use the empty row model as a parent (which feels like a hack). // they use the empty row model as a parent (which feels like a hack).
if (!popupSection.isDisposed()) { if (!popupSection.isDisposed()) {
popupSection.hasFocus(false); popupSection.hasFocus(false);
} }
// We need to restore active viewSection for a view that we borrowed.
// When this popup was opened we tricked active view by setting its activeViewSection // When this popup was opened we tricked active view by setting its activeViewSection
// to our viewSection (which might be a completely diffrent section or a raw data section) not // to our viewSection (which might be a completely different section or a raw data section) not
// connected to this view. // connected to this view. We need to return focus back to the previous section.
if (!prevSection.isDisposed()) { this._focusPreviousSection();
prevSection.hasFocus(true);
}
} }
// Clearing popup section data will close this popup. // Clearing popup section data will close this popup.
this._popupSectionOptions.set(null); this._popupSectionOptions.set(null);
@ -1401,6 +1421,19 @@ export class GristDoc extends DisposableWithEvents {
this._showBackgroundVideoPlayer.set(false); this._showBackgroundVideoPlayer.set(false);
} }
private _focusPreviousSection() {
const prevSectionId = this._prevSectionId;
if (!prevSectionId) { return; }
if (
this.viewModel.viewSections.peek().all().some(s =>
!s.isDisposed() && s.id.peek() === prevSectionId)
) {
this.viewModel.activeSectionId(prevSectionId);
}
this._prevSectionId = null;
}
/** /**
* Waits for a view to be ready * Waits for a view to be ready
*/ */

@ -2,22 +2,27 @@ import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
import * as commands from 'app/client/components/commands'; import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {cssCloseButton, cssOverlay} from 'app/client/components/RawDataPage'; import {cssCloseButton, cssOverlay} from 'app/client/components/RawDataPage';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {ViewSectionHelper} from 'app/client/components/ViewLayout'; import {ViewSectionHelper} from 'app/client/components/ViewLayout';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {ChangeType, RowList} from 'app/client/models/rowset';
import {theme} from 'app/client/ui2018/cssVars'; import {theme} from 'app/client/ui2018/cssVars';
import {Disposable, dom, makeTestId, styled} from 'grainjs'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {dom, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-record-card-popup-'); const testId = makeTestId('test-record-card-popup-');
interface RecordCardPopupOptions { interface RecordCardPopupOptions {
gristDoc: GristDoc; gristDoc: GristDoc;
rowId: number;
viewSection: ViewSectionRec; viewSection: ViewSectionRec;
onClose(): void; onClose(): void;
} }
export class RecordCardPopup extends Disposable { export class RecordCardPopup extends DisposableWithEvents {
private _gristDoc = this._options.gristDoc; private _gristDoc = this._options.gristDoc;
private _rowId = this._options.rowId;
private _viewSection = this._options.viewSection; private _viewSection = this._options.viewSection;
private _tableModel = this._gristDoc.getTableModel(this._viewSection.table().tableId());
private _handleClose = this._options.onClose; private _handleClose = this._options.onClose;
constructor(private _options: RecordCardPopupOptions) { constructor(private _options: RecordCardPopupOptions) {
@ -26,6 +31,11 @@ export class RecordCardPopup extends Disposable {
cancel: () => { this._handleClose(); }, cancel: () => { this._handleClose(); },
}; };
this.autoDispose(commands.createGroup(commandGroup, this, true)); this.autoDispose(commands.createGroup(commandGroup, this, true));
// Close the popup if the underlying row is removed.
const onRowChange = this._onRowChange.bind(this);
this._tableModel.on('rowChange', onRowChange);
this.onDispose(() => this._tableModel.off('rowChange', onRowChange));
} }
public buildDom() { public buildDom() {
@ -39,7 +49,6 @@ export class RecordCardPopup extends Disposable {
draggable: false, draggable: false,
focusable: false, focusable: false,
renamable: false, renamable: false,
hideTitleControls: true,
}), }),
), ),
cssCloseButton('CrossBig', cssCloseButton('CrossBig',
@ -49,6 +58,12 @@ export class RecordCardPopup extends Disposable {
dom.on('click', (ev, elem) => void (ev.target === elem ? this._handleClose() : null)), dom.on('click', (ev, elem) => void (ev.target === elem ? this._handleClose() : null)),
); );
} }
private _onRowChange(type: ChangeType, rows: RowList) {
if (type === 'remove' && [...rows].includes(this._rowId)) {
this._handleClose();
}
}
} }
const cssSectionWrapper = styled('div', ` const cssSectionWrapper = styled('div', `

@ -84,7 +84,6 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
public viewModel: ViewRec; public viewModel: ViewRec;
public layoutSpec: ko.Computed<BoxSpec>; public layoutSpec: ko.Computed<BoxSpec>;
public maximized: Observable<number|null>; public maximized: Observable<number|null>;
public previousSectionId = 0; // Used to restore focus after a maximized section is closed.
public isResizing = Observable.create(this, false); public isResizing = Observable.create(this, false);
public layout: Layout; public layout: Layout;
public layoutEditor: LayoutEditor; public layoutEditor: LayoutEditor;
@ -203,16 +202,6 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
// If we are closing popup, resize all sections. // If we are closing popup, resize all sections.
if (!sectionId) { if (!sectionId) {
this._onResize(); this._onResize();
// Reset active section to the first one if the section is popup is collapsed.
if (prev
&& this.viewModel.activeCollapsedSections.peek().includes(prev)
&& this.previousSectionId) {
// Make sure that previous section exists still.
if (this.viewModel.viewSections.peek().all()
.some(s => !s.isDisposed() && s.id.peek() === this.previousSectionId)) {
this.viewModel.activeSectionId(this.previousSectionId);
}
}
} else { } else {
// Otherwise resize only active one (the one in popup). // Otherwise resize only active one (the one in popup).
const section = this.viewModel.activeSection.peek(); const section = this.viewModel.activeSection.peek();

@ -78,7 +78,6 @@ export function buildViewSectionDom(options: {
tableNameHidden, tableNameHidden,
widgetNameHidden, widgetNameHidden,
renamable = true, renamable = true,
hideTitleControls = false,
} = options; } = options;
// Creating normal section dom // Creating normal section dom
@ -110,7 +109,7 @@ export function buildViewSectionDom(options: {
testId('viewsection-title'), testId('viewsection-title'),
cssTestClick(testId("viewsection-blank")), cssTestClick(testId("viewsection-blank")),
), ),
hideTitleControls ? null : viewInstance.buildTitleControls(), viewInstance.buildTitleControls(),
dom('div.viewsection_buttons', dom('div.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc, vs) dom.create(viewSectionMenu, gristDoc, vs)
) )

@ -33,7 +33,3 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
} }
return G.window.PERMITTED_CUSTOM_WIDGETS; return G.window.PERMITTED_CUSTOM_WIDGETS;
} }
export function RECORD_CARDS() {
return Boolean(getGristConfig().experimentalPlugins);
}

@ -1,6 +1,5 @@
import { allCommands } from 'app/client/components/commands'; import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization'; import { makeT } from 'app/client/lib/localization';
import { RECORD_CARDS } from 'app/client/models/features';
import { menuDivider, menuIcon, menuItemCmd, menuItemCmdLabel } from 'app/client/ui2018/menus'; import { menuDivider, menuIcon, menuItemCmd, menuItemCmdLabel } from 'app/client/ui2018/menus';
import { dom } from 'grainjs'; import { dom } from 'grainjs';
@ -22,7 +21,7 @@ export function RowContextMenu({
numRows numRows
}: IRowContextMenu) { }: IRowContextMenu) {
const result: Element[] = []; const result: Element[] = [];
if (RECORD_CARDS() && numRows === 1) { if (numRows === 1) {
result.push( result.push(
menuItemCmd( menuItemCmd(
allCommands.viewAsCard, allCommands.viewAsCard,

@ -2,7 +2,6 @@ import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel'; import {DataRowModel} from 'app/client/models/DataRowModel';
import {TableRec} from 'app/client/models/DocModel'; import {TableRec} from 'app/client/models/DocModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {RECORD_CARDS} from 'app/client/models/features';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {hideInPrintView, testId, theme} from 'app/client/ui2018/cssVars'; import {hideInPrintView, testId, theme} from 'app/client/ui2018/cssVars';
@ -20,7 +19,7 @@ const t = makeT('Reference');
* Reference - The widget for displaying references to another table's records. * Reference - The widget for displaying references to another table's records.
*/ */
export class Reference extends NTextBox { export class Reference extends NTextBox {
private _refTable: Computed<TableRec | null>; protected _refTable: Computed<TableRec | null>;
private _visibleColRef: Computed<number>; private _visibleColRef: Computed<number>;
private _validCols: Computed<Array<IOptionFull<number>>>; private _validCols: Computed<Array<IOptionFull<number>>>;
@ -120,23 +119,20 @@ export class Reference extends NTextBox {
dom.cls('text_wrapping', this.wrapping), dom.cls('text_wrapping', this.wrapping),
cssRefIcon('FieldReference', cssRefIcon('FieldReference',
cssRefIcon.cls('-view-as-card', use => cssRefIcon.cls('-view-as-card', use =>
RECORD_CARDS() && use(referenceId) !== 0 && use(formattedValue).hasRecordCard), use(referenceId) !== 0 && use(formattedValue).hasRecordCard),
dom.on('click', async () => { dom.on('click', async () => {
if (!RECORD_CARDS()) { return; }
if (referenceId.get() === 0 || !formattedValue.get().hasRecordCard) { return; } if (referenceId.get() === 0 || !formattedValue.get().hasRecordCard) { return; }
const rowId = referenceId.get() as UIRowId; const rowId = referenceId.get() as UIRowId;
const sectionId = this._refTable.get()?.recordCardViewSectionRef(); const sectionId = this._refTable.get()?.recordCardViewSectionRef();
if (sectionId === undefined) { if (sectionId === undefined) {
throw new Error('Unable to find Record Card section'); throw new Error('Unable to open Record Card: undefined section id');
} }
const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}}; const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}};
await urlState().pushUrl(anchorUrlState, {replace: true}); await urlState().pushUrl(anchorUrlState, {replace: true});
}), }),
dom.on('mousedown', (ev) => { dom.on('mousedown', (ev) => {
if (!RECORD_CARDS()) { return; }
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
}), }),

@ -1,7 +1,9 @@
import {DataRowModel} from 'app/client/models/DataRowModel'; import {DataRowModel} from 'app/client/models/DataRowModel';
import {testId} from 'app/client/ui2018/cssVars'; import {urlState} from 'app/client/models/gristUrlState';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {isList} from 'app/common/gristTypes'; import {isList} from 'app/common/gristTypes';
import {dom} from 'grainjs'; import {Computed, dom, styled} from 'grainjs';
import {cssChoiceList, cssToken} from "app/client/widgets/ChoiceListCell"; import {cssChoiceList, cssToken} from "app/client/widgets/ChoiceListCell";
import {Reference} from "app/client/widgets/Reference"; import {Reference} from "app/client/widgets/Reference";
import {choiceToken} from "app/client/widgets/ChoiceToken"; import {choiceToken} from "app/client/widgets/ChoiceToken";
@ -10,24 +12,33 @@ import {choiceToken} from "app/client/widgets/ChoiceToken";
* ReferenceList - The widget for displaying lists of references to another table's records. * ReferenceList - The widget for displaying lists of references to another table's records.
*/ */
export class ReferenceList extends Reference { export class ReferenceList extends Reference {
private _hasRecordCard = Computed.create(this, (use) => {
const table = use(this._refTable);
if (!table) { return false; }
return !use(use(table.recordCardViewSection).disabled);
});
public buildDom(row: DataRowModel) { public buildDom(row: DataRowModel) {
return cssChoiceList( return cssChoiceList(
dom.cls('field_clip'), dom.cls('field_clip'),
cssChoiceList.cls('-wrap', this.wrapping), cssChoiceList.cls('-wrap', this.wrapping),
dom.style('justify-content', use => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)), dom.style('justify-content', use => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)),
dom.domComputed((use) => { dom.domComputed((use) => {
if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) { if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {
// Work around JS errors during certain changes (noticed when visibleCol field gets removed // Work around JS errors during certain changes (noticed when visibleCol field gets removed
// for a column using per-field settings). // for a column using per-field settings).
return null; return null;
} }
const value = row.cells[use(use(this.field.displayColModel).colId)];
if (!value) { const valueObs = row.cells[use(this.field.colId)];
return null; const value = valueObs && use(valueObs);
} if (!value) { return null; }
const content = use(value);
if (!content) { return null; } const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)];
const displayValue = displayValueObs && use(displayValueObs);
if (!displayValue) { return null; }
// TODO: Figure out what the implications of this block are for ReferenceList. // TODO: Figure out what the implications of this block are for ReferenceList.
// if (isVersions(content)) { // if (isVersions(content)) {
// // We can arrive here if the reference value is unchanged (viewed as a foreign key) // // We can arrive here if the reference value is unchanged (viewed as a foreign key)
@ -36,20 +47,51 @@ export class ReferenceList extends Reference {
// // just showing one version of the cell. TODO: elaborate. // // just showing one version of the cell. TODO: elaborate.
// return use(this._formatValue)(content[1].local || content[1].parent); // return use(this._formatValue)(content[1].local || content[1].parent);
// } // }
const items = isList(content) ? content.slice(1) : [content]; const values = isList(value) ? value.slice(1) : [value];
const displayValues = isList(displayValue) ? displayValue.slice(1) : [displayValue];
// Use field.visibleColFormatter instead of field.formatter // Use field.visibleColFormatter instead of field.formatter
// because we're formatting each list element to render tokens, not the whole list. // because we're formatting each list element to render tokens, not the whole list.
const formatter = use(this.field.visibleColFormatter); const formatter = use(this.field.visibleColFormatter);
return items.map(item => formatter.formatAny(item)); return values.map((referenceId, i) => {
return {
referenceId,
formattedValue: formatter.formatAny(displayValues[i]),
};
});
}, },
(input) => { (values) => {
if (!input) { if (!values) {
return null; return null;
} }
return input.map(token => { return values.map(({referenceId, formattedValue}) => {
const isBlankReference = token.trim() === ''; const isBlankReference = formattedValue.trim() === '';
return choiceToken( return choiceToken(
isBlankReference ? '[Blank]' : token, [
cssRefIcon('FieldReference',
cssRefIcon.cls('-view-as-card', use =>
referenceId !== 0 && use(this._hasRecordCard)),
dom.on('click', async () => {
if (referenceId === 0 || !this._hasRecordCard.get()) { return; }
const rowId = referenceId as number;
const sectionId = this._refTable.get()?.recordCardViewSectionRef();
if (sectionId === undefined) {
throw new Error('Unable to open Record Card: undefined section id');
}
const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}};
await urlState().pushUrl(anchorUrlState, {replace: true});
}),
dom.on('mousedown', (ev) => {
ev.stopPropagation();
ev.preventDefault();
}),
),
cssLabel(isBlankReference ? '[Blank]' : formattedValue,
testId('ref-list-cell-token-label'),
),
dom.cls(cssRefIconAndLabel.className),
],
{ {
blank: isBlankReference, blank: isBlankReference,
}, },
@ -61,3 +103,26 @@ export class ReferenceList extends Reference {
); );
} }
} }
const cssRefIcon = styled(icon, `
--icon-color: ${theme.lightText};
flex-shrink: 0;
&-view-as-card {
cursor: pointer;
}
&-view-as-card:hover {
--icon-color: ${theme.controlFg};
}
`);
const cssRefIconAndLabel = styled('div', `
display: flex;
align-items: center;
`);
const cssLabel = styled('div', `
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`);

@ -5,7 +5,6 @@ import {Session} from 'test/nbrowser/gristUtils';
describe('ReferenceList', function() { describe('ReferenceList', function() {
this.timeout(60000); this.timeout(60000);
setupTestSuite();
let session: Session; let session: Session;
const cleanup = setupTestSuite({team: true}); const cleanup = setupTestSuite({team: true});
@ -79,6 +78,7 @@ describe('ReferenceList', function() {
await gu.sendKeys(Key.ARROW_DOWN, Key.ENTER, 'The Avengers', Key.ENTER, Key.ENTER); await gu.sendKeys(Key.ARROW_DOWN, Key.ENTER, 'The Avengers', Key.ENTER, Key.ENTER);
// Check that the cells are rendered correctly. // Check that the cells are rendered correctly.
await gu.resizeColumn({col: 'Favorite Film'}, 100);
assert.deepEqual(await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6]), assert.deepEqual(await gu.getVisibleGridCells('Favorite Film', [1, 2, 3, 4, 5, 6]),
[ [
'Forrest Gump\nAlien', 'Forrest Gump\nAlien',
@ -273,6 +273,7 @@ describe('ReferenceList', function() {
await driver.find('.test-fbuilder-ref-col-select').click(); await driver.find('.test-fbuilder-ref-col-select').click();
await driver.findContent('.test-select-row', /Name/).click(); await driver.findContent('.test-select-row', /Name/).click();
await gu.waitForServer(); await gu.waitForServer();
await gu.resizeColumn({col: 'A'}, 100);
assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]), assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),
['Roger\nTom', 'Tom', 'Sydney\nBill\nEvan', '', '', '']); ['Roger\nTom', 'Tom', 'Sydney\nBill\nEvan', '', '', '']);
@ -311,7 +312,7 @@ describe('ReferenceList', function() {
const cell = gu.getCell({col: 'A', rowNum: 1}); const cell = gu.getCell({col: 'A', rowNum: 1});
await server.pauseUntil(async () => { await server.pauseUntil(async () => {
assert.equal(await cell.getText(), 'Friends[1]\nFriends[2]'); assert.equal(await cell.getText(), 'Friends[1]\nFriends[2]');
await cell.click(); await gu.clickReferenceListCell(cell);
await gu.sendKeys('5'); await gu.sendKeys('5');
// Check that the autocomplete has no items yet. // Check that the autocomplete has no items yet.
assert.isEmpty(await driver.findAll('.test-autocomplete .test-ref-editor-new-item')); assert.isEmpty(await driver.findAll('.test-autocomplete .test-ref-editor-new-item'));
@ -324,7 +325,7 @@ describe('ReferenceList', function() {
assert.equal(await cell.getText(), 'Friends[1]\nFriends[2]'); assert.equal(await cell.getText(), 'Friends[1]\nFriends[2]');
// Once server is responsive, a valid value should not offer a "new item". // Once server is responsive, a valid value should not offer a "new item".
await cell.click(); await gu.clickReferenceListCell(cell);
await gu.sendKeys('5'); await gu.sendKeys('5');
await driver.findWait('.test-ref-editor-item', 500); await driver.findWait('.test-ref-editor-item', 500);
assert.isFalse(await driver.find('.test-ref-editor-new-item').isPresent()); assert.isFalse(await driver.find('.test-ref-editor-new-item').isPresent());
@ -750,7 +751,8 @@ describe('ReferenceList', function() {
}); });
it('should update choices as user types into textbox', async function() { it('should update choices as user types into textbox', async function() {
let cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1}).doClick(); let cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1});
await gu.clickReferenceListCell(cell);
assert.equal(await cell.getText(), 'TECHNOLOGY, ARTS AND SCIENCES STUDIO'); assert.equal(await cell.getText(), 'TECHNOLOGY, ARTS AND SCIENCES STUDIO');
await driver.sendKeys('TECHNOLOGY, ARTS AND SCIENCES STUDIO'); await driver.sendKeys('TECHNOLOGY, ARTS AND SCIENCES STUDIO');
assert.deepEqual(await getACOptions(3), [ assert.deepEqual(await getACOptions(3), [
@ -759,7 +761,8 @@ describe('ReferenceList', function() {
'SCHOOL OF SCIENCE AND TECHNOLOGY', 'SCHOOL OF SCIENCE AND TECHNOLOGY',
]); ]);
await driver.sendKeys(Key.ESCAPE); await driver.sendKeys(Key.ESCAPE);
cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 2}).doClick(); cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 2});
await gu.clickReferenceListCell(cell);
await driver.sendKeys('stuy'); await driver.sendKeys('stuy');
assert.deepEqual(await getACOptions(3), [ assert.deepEqual(await getACOptions(3), [
'STUYVESANT HIGH SCHOOL', 'STUYVESANT HIGH SCHOOL',
@ -790,7 +793,8 @@ describe('ReferenceList', function() {
it('should highlight matching parts of items', async function() { it('should highlight matching parts of items', async function() {
await driver.sendKeys(Key.HOME); await driver.sendKeys(Key.HOME);
let cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 2}).doClick(); let cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 2});
await gu.clickReferenceListCell(cell);
assert.equal(await cell.getText(), 'Red'); assert.equal(await cell.getText(), 'Red');
await driver.sendKeys(Key.ENTER, 'Red'); await driver.sendKeys(Key.ENTER, 'Red');
await driver.findWait('.test-ref-editor-item', 1000); await driver.findWait('.test-ref-editor-item', 1000);
@ -802,7 +806,8 @@ describe('ReferenceList', function() {
['Re']); ['Re']);
await driver.sendKeys(Key.ESCAPE); await driver.sendKeys(Key.ESCAPE);
cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1}).doClick(); cell = await gu.getCell({section: 'References', col: 'Schools', rowNum: 1});
await gu.clickReferenceListCell(cell);
await driver.sendKeys('br tech'); await driver.sendKeys('br tech');
assert.deepEqual( assert.deepEqual(
await driver.findContentWait('.test-ref-editor-item', /BROOKLYN TECH/, 1000).findAll('span', e => e.getText()), await driver.findContentWait('.test-ref-editor-item', /BROOKLYN TECH/, 1000).findAll('span', e => e.getText()),
@ -819,19 +824,20 @@ describe('ReferenceList', function() {
it('should reflect changes to the target column', async function() { it('should reflect changes to the target column', async function() {
await driver.sendKeys(Key.HOME); await driver.sendKeys(Key.HOME);
const cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 4}).doClick(); const cell = await gu.getCell({section: 'References', col: 'Colors', rowNum: 4});
await gu.clickReferenceListCell(cell);
assert.equal(await cell.getText(), ''); assert.equal(await cell.getText(), '');
await driver.sendKeys(Key.ENTER); await driver.sendKeys(Key.ENTER);
assert.deepEqual(await getACOptions(2), ['Alice Blue', 'Añil']); assert.deepEqual(await getACOptions(2), ['Alice Blue', 'Añil']);
await driver.sendKeys(Key.ESCAPE); await driver.sendKeys(Key.ESCAPE);
// Change a color // Change a color
await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); await gu.clickReferenceListCell(await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}));
await driver.sendKeys('HAZELNUT', Key.ENTER); await driver.sendKeys('HAZELNUT', Key.ENTER, Key.ENTER);
await gu.waitForServer(); await gu.waitForServer();
// See that the old value is gone from the autocomplete, and the new one is present. // See that the old value is gone from the autocomplete, and the new one is present.
await cell.click(); await gu.clickReferenceListCell(cell);
await driver.sendKeys(Key.ENTER); await driver.sendKeys(Key.ENTER);
assert.deepEqual(await getACOptions(2), ['Añil', 'Aqua']); assert.deepEqual(await getACOptions(2), ['Añil', 'Aqua']);
await driver.sendKeys('H'); await driver.sendKeys('H');
@ -839,11 +845,11 @@ describe('ReferenceList', function() {
await driver.sendKeys(Key.ESCAPE); await driver.sendKeys(Key.ESCAPE);
// Delete a row. // Delete a row.
await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick(); await gu.clickReferenceListCell(await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}));
await gu.removeRow(1); await gu.removeRow(1);
// See that the value is gone from the autocomplete. // See that the value is gone from the autocomplete.
await cell.click(); await gu.clickReferenceListCell(cell);
await driver.sendKeys('H'); await driver.sendKeys('H');
assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']); assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']);
await driver.sendKeys(Key.ESCAPE); await driver.sendKeys(Key.ESCAPE);
@ -856,7 +862,7 @@ describe('ReferenceList', function() {
await gu.waitForServer(); await gu.waitForServer();
// See that the new value is visible in the autocomplete. // See that the new value is visible in the autocomplete.
await cell.click(); await gu.clickReferenceListCell(cell);
await driver.sendKeys('H'); await driver.sendKeys('H');
assert.deepEqual(await getACOptions(2), ['HELIOTROPE', 'Honeydew']); assert.deepEqual(await getACOptions(2), ['HELIOTROPE', 'Honeydew']);
await driver.sendKeys(Key.BACK_SPACE); await driver.sendKeys(Key.BACK_SPACE);
@ -866,7 +872,7 @@ describe('ReferenceList', function() {
// Undo all the changes. // Undo all the changes.
await gu.undo(4); await gu.undo(4);
await cell.click(); await gu.clickReferenceListCell(cell);
await driver.sendKeys('H'); await driver.sendKeys('H');
assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']); assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']);
await driver.sendKeys(Key.BACK_SPACE); await driver.sendKeys(Key.BACK_SPACE);

@ -183,7 +183,12 @@ async function checkSelectingRecords(selectBy: string, sourceData: string[][], n
await gu.waitForServer(); await gu.waitForServer();
const selectByTable = selectBy.split(' ')[0]; const selectByTable = selectBy.split(' ')[0];
await gu.getCell({section: selectByTable, col: 0, rowNum: 3}).click(); const cell = await gu.getCell({section: selectByTable, col: 0, rowNum: 3});
if (selectByTable === 'REFLISTS') {
await gu.clickReferenceListCell(cell);
} else {
await cell.click();
}
let numSourceRows = 0; let numSourceRows = 0;
@ -207,7 +212,12 @@ async function checkSelectingRecords(selectBy: string, sourceData: string[][], n
} }
for (let i = 0; i < sourceData.length; i++) { for (let i = 0; i < sourceData.length; i++) {
await gu.getCell({section: selectByTable, col: 0, rowNum: i + 1}).click(); const cell = await gu.getCell({section: selectByTable, col: 0, rowNum: i + 1});
if (selectByTable === 'REFLISTS') {
await gu.clickReferenceListCell(cell);
} else {
await cell.click();
}
await checkSourceGroup(i); await checkSourceGroup(i);
} }

@ -572,6 +572,19 @@ export async function rightClick(cell: WebElement) {
await driver.withActions((actions) => actions.contextClick(cell)); await driver.withActions((actions) => actions.contextClick(cell));
} }
/**
* Clicks a Reference List cell, taking care not to click the icon (which can
* cause an unexpected Record Card popup to appear).
*/
export async function clickReferenceListCell(cell: WebElement) {
const tokens = await cell.findAll('.test-ref-list-cell-token-label');
if (tokens.length > 0) {
await tokens[0].click();
} else {
await cell.click();
}
}
/** /**
* Gets the selector position in the Grid view section (or null if not present). * Gets the selector position in the Grid view section (or null if not present).
* Selector is the black box around the row number. * Selector is the black box around the row number.

Loading…
Cancel
Save