mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
84329404a4
commit
707a8c7b32
@ -274,7 +274,9 @@ BaseView.prototype.deleteRecords = function(source) {
|
||||
buildConfirmDelete(selectedCell, onSave, rowIds.length <= 1);
|
||||
} else {
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {copyToClipboard} from 'app/client/lib/clipboardUtils';
|
||||
import {setTestState} from 'app/client/lib/testState';
|
||||
import {TableRec} from 'app/client/models/DocModel';
|
||||
import {RECORD_CARDS} from 'app/client/models/features';
|
||||
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
|
||||
import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable';
|
||||
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
|
||||
@ -99,13 +98,14 @@ export class DataTables extends Disposable {
|
||||
hoverTooltip(
|
||||
dom.domComputed(use => use(use(tableRec.recordCardViewSection).disabled)
|
||||
? t('Record Card Disabled')
|
||||
: t('Record Card')),
|
||||
: t('Edit Record Card')),
|
||||
{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.
|
||||
dom.style('visibility', u => u(tableRec.summarySourceTable) === 0 ? 'visible' : 'hidden'),
|
||||
cssRecordCardButton.cls('-disabled', use => use(use(tableRec.recordCardViewSection).disabled)),
|
||||
testId('table-record-card'),
|
||||
),
|
||||
cssDotsButton(
|
||||
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.`);
|
||||
}
|
||||
this._gristDoc.viewModel.activeSectionId(sectionId);
|
||||
})
|
||||
}),
|
||||
cssTable.cls('-readonly', this._gristDoc.isReadonly),
|
||||
);
|
||||
})
|
||||
),
|
||||
@ -132,7 +133,8 @@ export class DataTables extends Disposable {
|
||||
return dom.domComputed((use) => {
|
||||
const rawViewSectionRef = use(fromKo(table.rawViewSectionRef));
|
||||
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
|
||||
// tables can't currently be renamed.
|
||||
const tableName = [
|
||||
@ -185,7 +187,7 @@ export class DataTables extends Disposable {
|
||||
)),
|
||||
testId('menu-remove-table'),
|
||||
),
|
||||
dom.maybe(use => RECORD_CARDS() && use(table.summarySourceTable) === 0, () => [
|
||||
dom.maybe(use => use(table.summarySourceTable) === 0, () => [
|
||||
menuDivider(),
|
||||
menuItem(
|
||||
() => this._editRecordCard(table),
|
||||
@ -308,6 +310,10 @@ const cssTable = styled('div', `
|
||||
&:hover {
|
||||
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', `
|
||||
|
@ -33,6 +33,7 @@ function DetailView(gristDoc, viewSectionModel) {
|
||||
|
||||
this.viewFields = gristDoc.docModel.viewFields;
|
||||
this._isSingle = (this.viewSection.parentKey.peek() === 'single');
|
||||
this._isExternalSectionPopup = gristDoc.externalSectionId.get() === this.viewSection.id();
|
||||
|
||||
//--------------------------------------------------
|
||||
// Create and attach the DOM for the view.
|
||||
@ -191,7 +192,9 @@ DetailView.prototype.deleteRows = async function(rowIds) {
|
||||
try {
|
||||
await BaseView.prototype.deleteRows.call(this, rowIds);
|
||||
} 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.
|
||||
// Note that the controls should still be visible with a filter link.
|
||||
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();
|
||||
return !(linkingState && Boolean(linkingState.cursorPos));
|
||||
});
|
||||
|
@ -56,7 +56,6 @@ const {CombinedStyle} = require("app/client/models/Styles");
|
||||
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
|
||||
const {makeT} = require('app/client/lib/localization');
|
||||
const {reportError} = require('app/client/models/AppModel');
|
||||
const {RECORD_CARDS} = require('app/client/models/features');
|
||||
const {urlState} = require('app/client/models/gristUrlState');
|
||||
|
||||
const t = makeT('GridView');
|
||||
@ -375,13 +374,15 @@ GridView.gridCommands = {
|
||||
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
|
||||
},
|
||||
viewAsCard() {
|
||||
if (!RECORD_CARDS()) { return; }
|
||||
if (this._isRecordCardDisabled()) { return; }
|
||||
|
||||
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 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);
|
||||
},
|
||||
};
|
||||
|
@ -209,6 +209,8 @@ export class GristDoc extends DisposableWithEvents {
|
||||
private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false);
|
||||
private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this);
|
||||
private _disableAutoStartingTours: boolean = false;
|
||||
private _isShowingPopupSection = false;
|
||||
private _prevSectionId: number | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly app: App,
|
||||
@ -565,6 +567,13 @@ export class GristDoc extends DisposableWithEvents {
|
||||
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.
|
||||
content.viewSection.autoDispose({dispose: content.close});
|
||||
|
||||
const {recordCard} = content.hash;
|
||||
const {recordCard, rowId} = content.hash;
|
||||
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, {
|
||||
gristDoc: this,
|
||||
rowId,
|
||||
viewSection: content.viewSection,
|
||||
onClose: content.close,
|
||||
});
|
||||
@ -629,7 +644,15 @@ export class GristDoc extends DisposableWithEvents {
|
||||
}) :
|
||||
dom.create((owner) => {
|
||||
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);
|
||||
return this.viewLayout;
|
||||
})
|
||||
@ -1290,11 +1313,11 @@ export class GristDoc extends DisposableWithEvents {
|
||||
if (!hash.sectionId) {
|
||||
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).
|
||||
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);
|
||||
// If the anchor link is valid, set the cursor.
|
||||
if (hash.colRef && hash.rowId) {
|
||||
@ -1308,10 +1331,10 @@ export class GristDoc extends DisposableWithEvents {
|
||||
this.viewLayout?.maximized.set(hash.sectionId);
|
||||
return;
|
||||
}
|
||||
this._isShowingPopupSection = true;
|
||||
// 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
|
||||
// he doesn't care. After popup is closed, we will restore the original.
|
||||
const prevSection = this.viewModel.activeSection.peek();
|
||||
this.viewModel.activeSectionId(hash.sectionId);
|
||||
// Now we have view section we want to show in the popup.
|
||||
const popupSection = this.viewModel.activeSection.peek();
|
||||
@ -1329,20 +1352,17 @@ export class GristDoc extends DisposableWithEvents {
|
||||
if (!this._popupSectionOptions.get()) {
|
||||
return;
|
||||
}
|
||||
if (popupSection !== prevSection) {
|
||||
if (popupSection.id() !== this._prevSectionId) {
|
||||
// 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;
|
||||
// they use the empty row model as a parent (which feels like a hack).
|
||||
if (!popupSection.isDisposed()) {
|
||||
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
|
||||
// to our viewSection (which might be a completely diffrent section or a raw data section) not
|
||||
// connected to this view.
|
||||
if (!prevSection.isDisposed()) {
|
||||
prevSection.hasFocus(true);
|
||||
}
|
||||
// to our viewSection (which might be a completely different section or a raw data section) not
|
||||
// connected to this view. We need to return focus back to the previous section.
|
||||
this._focusPreviousSection();
|
||||
}
|
||||
// Clearing popup section data will close this popup.
|
||||
this._popupSectionOptions.set(null);
|
||||
@ -1401,6 +1421,19 @@ export class GristDoc extends DisposableWithEvents {
|
||||
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
|
||||
*/
|
||||
|
@ -2,22 +2,27 @@ import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {cssCloseButton, cssOverlay} from 'app/client/components/RawDataPage';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
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 {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-');
|
||||
|
||||
interface RecordCardPopupOptions {
|
||||
gristDoc: GristDoc;
|
||||
rowId: number;
|
||||
viewSection: ViewSectionRec;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export class RecordCardPopup extends Disposable {
|
||||
export class RecordCardPopup extends DisposableWithEvents {
|
||||
private _gristDoc = this._options.gristDoc;
|
||||
private _rowId = this._options.rowId;
|
||||
private _viewSection = this._options.viewSection;
|
||||
private _tableModel = this._gristDoc.getTableModel(this._viewSection.table().tableId());
|
||||
private _handleClose = this._options.onClose;
|
||||
|
||||
constructor(private _options: RecordCardPopupOptions) {
|
||||
@ -26,6 +31,11 @@ export class RecordCardPopup extends Disposable {
|
||||
cancel: () => { this._handleClose(); },
|
||||
};
|
||||
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() {
|
||||
@ -39,7 +49,6 @@ export class RecordCardPopup extends Disposable {
|
||||
draggable: false,
|
||||
focusable: false,
|
||||
renamable: false,
|
||||
hideTitleControls: true,
|
||||
}),
|
||||
),
|
||||
cssCloseButton('CrossBig',
|
||||
@ -49,6 +58,12 @@ export class RecordCardPopup extends Disposable {
|
||||
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', `
|
||||
|
@ -84,7 +84,6 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
public viewModel: ViewRec;
|
||||
public layoutSpec: ko.Computed<BoxSpec>;
|
||||
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 layout: Layout;
|
||||
public layoutEditor: LayoutEditor;
|
||||
@ -203,16 +202,6 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||
// If we are closing popup, resize all sections.
|
||||
if (!sectionId) {
|
||||
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 {
|
||||
// Otherwise resize only active one (the one in popup).
|
||||
const section = this.viewModel.activeSection.peek();
|
||||
|
@ -78,7 +78,6 @@ export function buildViewSectionDom(options: {
|
||||
tableNameHidden,
|
||||
widgetNameHidden,
|
||||
renamable = true,
|
||||
hideTitleControls = false,
|
||||
} = options;
|
||||
|
||||
// Creating normal section dom
|
||||
@ -110,7 +109,7 @@ export function buildViewSectionDom(options: {
|
||||
testId('viewsection-title'),
|
||||
cssTestClick(testId("viewsection-blank")),
|
||||
),
|
||||
hideTitleControls ? null : viewInstance.buildTitleControls(),
|
||||
viewInstance.buildTitleControls(),
|
||||
dom('div.viewsection_buttons',
|
||||
dom.create(viewSectionMenu, gristDoc, vs)
|
||||
)
|
||||
|
@ -33,7 +33,3 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
|
||||
}
|
||||
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 { 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 { dom } from 'grainjs';
|
||||
|
||||
@ -22,7 +21,7 @@ export function RowContextMenu({
|
||||
numRows
|
||||
}: IRowContextMenu) {
|
||||
const result: Element[] = [];
|
||||
if (RECORD_CARDS() && numRows === 1) {
|
||||
if (numRows === 1) {
|
||||
result.push(
|
||||
menuItemCmd(
|
||||
allCommands.viewAsCard,
|
||||
|
@ -2,7 +2,6 @@ import {makeT} from 'app/client/lib/localization';
|
||||
import {DataRowModel} from 'app/client/models/DataRowModel';
|
||||
import {TableRec} from 'app/client/models/DocModel';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {RECORD_CARDS} from 'app/client/models/features';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
|
||||
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.
|
||||
*/
|
||||
export class Reference extends NTextBox {
|
||||
private _refTable: Computed<TableRec | null>;
|
||||
protected _refTable: Computed<TableRec | null>;
|
||||
private _visibleColRef: Computed<number>;
|
||||
private _validCols: Computed<Array<IOptionFull<number>>>;
|
||||
|
||||
@ -120,23 +119,20 @@ export class Reference extends NTextBox {
|
||||
dom.cls('text_wrapping', this.wrapping),
|
||||
cssRefIcon('FieldReference',
|
||||
cssRefIcon.cls('-view-as-card', use =>
|
||||
RECORD_CARDS() && use(referenceId) !== 0 && use(formattedValue).hasRecordCard),
|
||||
use(referenceId) !== 0 && use(formattedValue).hasRecordCard),
|
||||
dom.on('click', async () => {
|
||||
if (!RECORD_CARDS()) { return; }
|
||||
if (referenceId.get() === 0 || !formattedValue.get().hasRecordCard) { return; }
|
||||
|
||||
const rowId = referenceId.get() as UIRowId;
|
||||
const sectionId = this._refTable.get()?.recordCardViewSectionRef();
|
||||
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}};
|
||||
await urlState().pushUrl(anchorUrlState, {replace: true});
|
||||
}),
|
||||
dom.on('mousedown', (ev) => {
|
||||
if (!RECORD_CARDS()) { return; }
|
||||
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}),
|
||||
|
@ -1,7 +1,9 @@
|
||||
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 {dom} from 'grainjs';
|
||||
import {Computed, dom, styled} from 'grainjs';
|
||||
import {cssChoiceList, cssToken} from "app/client/widgets/ChoiceListCell";
|
||||
import {Reference} from "app/client/widgets/Reference";
|
||||
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.
|
||||
*/
|
||||
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) {
|
||||
return cssChoiceList(
|
||||
dom.cls('field_clip'),
|
||||
cssChoiceList.cls('-wrap', this.wrapping),
|
||||
dom.style('justify-content', use => use(this.alignment) === 'right' ? 'flex-end' : use(this.alignment)),
|
||||
dom.domComputed((use) => {
|
||||
|
||||
if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {
|
||||
// Work around JS errors during certain changes (noticed when visibleCol field gets removed
|
||||
// for a column using per-field settings).
|
||||
return null;
|
||||
}
|
||||
const value = row.cells[use(use(this.field.displayColModel).colId)];
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const content = use(value);
|
||||
if (!content) { return null; }
|
||||
|
||||
const valueObs = row.cells[use(this.field.colId)];
|
||||
const value = valueObs && use(valueObs);
|
||||
if (!value) { 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.
|
||||
// if (isVersions(content)) {
|
||||
// // 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.
|
||||
// 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
|
||||
// because we're formatting each list element to render tokens, not the whole list.
|
||||
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) => {
|
||||
if (!input) {
|
||||
(values) => {
|
||||
if (!values) {
|
||||
return null;
|
||||
}
|
||||
return input.map(token => {
|
||||
const isBlankReference = token.trim() === '';
|
||||
return values.map(({referenceId, formattedValue}) => {
|
||||
const isBlankReference = formattedValue.trim() === '';
|
||||
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,
|
||||
},
|
||||
@ -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() {
|
||||
this.timeout(60000);
|
||||
setupTestSuite();
|
||||
let session: Session;
|
||||
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);
|
||||
|
||||
// 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]),
|
||||
[
|
||||
'Forrest Gump\nAlien',
|
||||
@ -273,6 +273,7 @@ describe('ReferenceList', function() {
|
||||
await driver.find('.test-fbuilder-ref-col-select').click();
|
||||
await driver.findContent('.test-select-row', /Name/).click();
|
||||
await gu.waitForServer();
|
||||
await gu.resizeColumn({col: 'A'}, 100);
|
||||
assert.deepEqual(await gu.getVisibleGridCells(3, [1, 2, 3, 4, 5, 6]),
|
||||
['Roger\nTom', 'Tom', 'Sydney\nBill\nEvan', '', '', '']);
|
||||
|
||||
@ -311,7 +312,7 @@ describe('ReferenceList', function() {
|
||||
const cell = gu.getCell({col: 'A', rowNum: 1});
|
||||
await server.pauseUntil(async () => {
|
||||
assert.equal(await cell.getText(), 'Friends[1]\nFriends[2]');
|
||||
await cell.click();
|
||||
await gu.clickReferenceListCell(cell);
|
||||
await gu.sendKeys('5');
|
||||
// Check that the autocomplete has no items yet.
|
||||
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]');
|
||||
|
||||
// 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 driver.findWait('.test-ref-editor-item', 500);
|
||||
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() {
|
||||
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');
|
||||
await driver.sendKeys('TECHNOLOGY, ARTS AND SCIENCES STUDIO');
|
||||
assert.deepEqual(await getACOptions(3), [
|
||||
@ -759,7 +761,8 @@ describe('ReferenceList', function() {
|
||||
'SCHOOL OF SCIENCE AND TECHNOLOGY',
|
||||
]);
|
||||
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');
|
||||
assert.deepEqual(await getACOptions(3), [
|
||||
'STUYVESANT HIGH SCHOOL',
|
||||
@ -790,7 +793,8 @@ describe('ReferenceList', function() {
|
||||
it('should highlight matching parts of items', async function() {
|
||||
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');
|
||||
await driver.sendKeys(Key.ENTER, 'Red');
|
||||
await driver.findWait('.test-ref-editor-item', 1000);
|
||||
@ -802,7 +806,8 @@ describe('ReferenceList', function() {
|
||||
['Re']);
|
||||
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');
|
||||
assert.deepEqual(
|
||||
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() {
|
||||
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(), '');
|
||||
await driver.sendKeys(Key.ENTER);
|
||||
assert.deepEqual(await getACOptions(2), ['Alice Blue', 'Añil']);
|
||||
await driver.sendKeys(Key.ESCAPE);
|
||||
|
||||
// Change a color
|
||||
await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}).doClick();
|
||||
await driver.sendKeys('HAZELNUT', Key.ENTER);
|
||||
await gu.clickReferenceListCell(await gu.getCell({section: 'Colors', col: 'Color Name', rowNum: 1}));
|
||||
await driver.sendKeys('HAZELNUT', Key.ENTER, Key.ENTER);
|
||||
await gu.waitForServer();
|
||||
|
||||
// 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);
|
||||
assert.deepEqual(await getACOptions(2), ['Añil', 'Aqua']);
|
||||
await driver.sendKeys('H');
|
||||
@ -839,11 +845,11 @@ describe('ReferenceList', function() {
|
||||
await driver.sendKeys(Key.ESCAPE);
|
||||
|
||||
// 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);
|
||||
|
||||
// See that the value is gone from the autocomplete.
|
||||
await cell.click();
|
||||
await gu.clickReferenceListCell(cell);
|
||||
await driver.sendKeys('H');
|
||||
assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']);
|
||||
await driver.sendKeys(Key.ESCAPE);
|
||||
@ -856,7 +862,7 @@ describe('ReferenceList', function() {
|
||||
await gu.waitForServer();
|
||||
|
||||
// See that the new value is visible in the autocomplete.
|
||||
await cell.click();
|
||||
await gu.clickReferenceListCell(cell);
|
||||
await driver.sendKeys('H');
|
||||
assert.deepEqual(await getACOptions(2), ['HELIOTROPE', 'Honeydew']);
|
||||
await driver.sendKeys(Key.BACK_SPACE);
|
||||
@ -866,7 +872,7 @@ describe('ReferenceList', function() {
|
||||
// Undo all the changes.
|
||||
await gu.undo(4);
|
||||
|
||||
await cell.click();
|
||||
await gu.clickReferenceListCell(cell);
|
||||
await driver.sendKeys('H');
|
||||
assert.deepEqual(await getACOptions(2), ['Honeydew', 'Hot Pink']);
|
||||
await driver.sendKeys(Key.BACK_SPACE);
|
||||
|
@ -183,7 +183,12 @@ async function checkSelectingRecords(selectBy: string, sourceData: string[][], n
|
||||
await gu.waitForServer();
|
||||
|
||||
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;
|
||||
|
||||
@ -207,7 +212,12 @@ async function checkSelectingRecords(selectBy: string, sourceData: string[][], n
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -572,6 +572,19 @@ export async function rightClick(cell: WebElement) {
|
||||
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).
|
||||
* Selector is the black box around the row number.
|
||||
|
Loading…
Reference in New Issue
Block a user