(core) Record Cards

Summary:
Adds a new Record Card view section to each non-summary table, which can be from opened from various parts of the Grist UI to view and edit records in a popup card view.

Work is still ongoing, so the feature is locked away behind a flag; follow-up work is planned to finish up the implementation and add end-to-end tests.

Test Plan: Python and server tests. Browser tests will be included in a follow-up.

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4114
This commit is contained in:
George Gevoian
2023-11-19 19:46:32 -05:00
parent 2eec48b685
commit caf830db08
53 changed files with 1261 additions and 456 deletions

View File

@@ -1,23 +1,28 @@
import * as commands from 'app/client/components/commands';
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 {showTransientTooltip} from 'app/client/ui/tooltips';
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
import {buildTableName} from 'app/client/ui/WidgetTitle';
import * as css from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {loadingDots} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {menu, menuDivider, menuIcon, menuItem, menuItemAsync, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
import {Computed, Disposable, dom, fromKo, makeTestId, observable, Observable, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
import * as weasel from 'popweasel';
const testId = makeTestId('test-raw-data-');
const t = makeT('DataTables');
const DATA_TABLES_TOOLTIP_KEY = 'dataTablesTooltip';
export class DataTables extends Disposable {
private _tables: Observable<TableRec[]>;
@@ -47,17 +52,19 @@ export class DataTables extends Disposable {
testId('list'),
cssHeader(t("Raw Data Tables")),
cssList(
dom.forEach(this._tables, tableRec =>
cssItem(
dom.forEach(this._tables, tableRec => {
const isEditingName = observable(false);
return cssTable(
dom.autoDispose(isEditingName),
testId('table'),
cssLeft(
cssTableIcon(
dom.domComputed((use) => cssTableTypeIcon(
use(tableRec.summarySourceTable) !== 0 ? 'PivotLight' : 'TypeTable',
testId(`table-id-${use(tableRec.tableId)}`)
)),
),
cssMiddle(
cssTitleRow(cssTableTitle(this._tableTitle(tableRec), testId('table-title'))),
cssTableNameAndId(
cssTitleRow(cssTableTitle(this._tableTitle(tableRec, isEditingName), testId('table-title'))),
cssDetailsRow(
cssTableIdWrapper(cssHoverWrapper(
cssUpperCase("Table ID: "),
@@ -76,14 +83,34 @@ export class DataTables extends Disposable {
setTestState({clipboard: tableRec.tableId.peek()});
})
)),
this._tableRows(tableRec),
),
),
cssRight(
docMenuTrigger(
this._tableRows(tableRec),
cssTableButtons(
cssRecordCardButton(
icon('TypeCard'),
dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
if (!tableRec.recordCardViewSection().disabled()) {
this._editRecordCard(tableRec);
}
}),
hoverTooltip(
dom.domComputed(use => use(use(tableRec.recordCardViewSection).disabled)
? t('Record Card Disabled')
: t('Record Card')),
{key: DATA_TABLES_TOOLTIP_KEY, closeOnClick: false}
),
dom.hide(!RECORD_CARDS()),
// 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)),
),
cssDotsButton(
testId('table-menu'),
icon('Dots'),
menu(() => this._menuItems(tableRec), {placement: 'bottom-start'}),
menu(() => this._menuItems(tableRec, isEditingName), {placement: 'bottom-start'}),
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
)
),
@@ -94,14 +121,14 @@ export class DataTables extends Disposable {
}
this._gristDoc.viewModel.activeSectionId(sectionId);
})
)
)
);
})
),
),
);
}
private _tableTitle(table: TableRec) {
private _tableTitle(table: TableRec, isEditing: Observable<boolean>) {
return dom.domComputed((use) => {
const rawViewSectionRef = use(fromKo(table.rawViewSectionRef));
const isSummaryTable = use(table.summarySourceTable) !== 0;
@@ -113,37 +140,75 @@ export class DataTables extends Disposable {
].filter(p => Boolean(p?.trim())).join(' ');
return cssTableName(tableName);
} else {
return dom('div', // to disable flex grow in the widget
return cssFlexRow(
dom.domComputed(fromKo(table.rawViewSection), vs =>
buildTableName(vs, testId('widget-title'))
)
buildTableName(vs, {isEditing}, cssRenamableTableName.cls(''), testId('widget-title'))
),
cssRenameTableButton(icon('Pencil'),
dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
isEditing.set(true);
}),
cssRenameTableButton.cls('-active', isEditing),
),
);
}
});
}
private _menuItems(table: TableRec) {
private _menuItems(table: TableRec, isEditingName: Observable<boolean>) {
const {isReadonly, docModel} = this._gristDoc;
return [
menuItem(
() => { isEditingName.set(true); },
t("Rename Table"),
dom.cls('disabled', use => use(isReadonly) || use(table.summarySourceTable) !== 0),
testId('menu-rename-table'),
),
menuItem(
() => this._duplicateTable(table),
t("Duplicate Table"),
testId('menu-duplicate-table'),
dom.cls('disabled', use =>
use(isReadonly) ||
use(table.isHidden) ||
use(table.summarySourceTable) !== 0
),
testId('menu-duplicate-table'),
),
menuItem(
() => this._removeTable(table),
'Remove',
testId('menu-remove'),
t("Remove Table"),
dom.cls('disabled', use => use(isReadonly) || (
// Can't delete last visible table, unless it is a hidden table.
use(docModel.visibleTables.getObservable()).length <= 1 && !use(table.isHidden)
))
)),
testId('menu-remove-table'),
),
dom.maybe(use => RECORD_CARDS() && use(table.summarySourceTable) === 0, () => [
menuDivider(),
menuItem(
() => this._editRecordCard(table),
cssMenuItemIcon('TypeCard'),
t("Edit Record Card"),
dom.cls('disabled', use => use(isReadonly)),
testId('menu-edit-record-card'),
),
dom.domComputed(use => use(use(table.recordCardViewSection).disabled), (isDisabled) => {
return menuItemAsync(
async () => {
if (isDisabled) {
await this._enableRecordCard(table);
} else {
await this._disableRecordCard(table);
}
},
t('{{action}} Record Card', {action: isDisabled ? 'Enable' : 'Disable'}),
dom.cls('disabled', use => use(isReadonly)),
testId(`menu-${isDisabled ? 'enable' : 'disable'}-record-card`),
);
}),
]),
dom.maybe(isReadonly, () => menuText(t("You do not have edit access to this document"))),
];
}
@@ -166,6 +231,24 @@ export class DataTables extends Disposable {
), 'Delete', doRemove);
}
private _editRecordCard(r: TableRec) {
const sectionId = r.recordCardViewSection.peek().getRowId();
if (!sectionId) {
throw new Error(`Table ${r.tableId.peek()} doesn't have a record card view section.`);
}
this._gristDoc.viewModel.activeSectionId(sectionId);
commands.allCommands.editLayout.run();
}
private async _enableRecordCard(r: TableRec) {
await r.recordCardViewSection().disabled.setAndSave(false);
}
private async _disableRecordCard(r: TableRec) {
await r.recordCardViewSection().disabled.setAndSave(true);
}
private _tableRows(table: TableRec) {
return dom.maybe(this._rowCount, (rowCounts) => {
if (rowCounts === 'hidden') { return null; }
@@ -183,6 +266,18 @@ export class DataTables extends Disposable {
}
}
const cssMenuItemIcon = styled(menuIcon, `
--icon-color: ${css.theme.menuItemFg};
.${weasel.cssMenuItem.className}-sel & {
--icon-color: ${css.theme.menuItemSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled & {
--icon-color: ${css.theme.menuItemDisabledFg};
}
`);
const container = styled('div', `
overflow-y: auto;
position: relative;
@@ -198,42 +293,37 @@ const cssList = styled('div', `
gap: 12px;
`);
const cssItem = styled('div', `
display: flex;
align-items: center;
const cssTable = styled('div', `
display: grid;
grid-template-columns: 16px auto 100px 56px;
grid-template-rows: 1fr;
grid-column-gap: 8px;
cursor: pointer;
border-radius: 3px;
width: 100%;
height: calc(1em * 56/13); /* 56px for 13px font */
max-width: 750px;
padding: 0px 12px 0px 12px;
border: 1px solid ${css.theme.rawDataTableBorder};
&:hover {
border-color: ${css.theme.rawDataTableBorderHover};
}
`);
// Holds icon in top left corner
const cssLeft = styled('div', `
const cssTableIcon = styled('div', `
padding-top: 11px;
padding-left: 12px;
margin-right: 8px;
align-self: flex-start;
display: flex;
flex: none;
`);
const cssMiddle = styled('div', `
flex-grow: 1;
const cssTableNameAndId = styled('div', `
min-width: 0px;
display: flex;
flex-wrap: wrap;
margin-top: 6px;
margin-bottom: 4px;
flex-direction: column;
margin-top: 8px;
`);
const cssTitleRow = styled('div', `
min-width: 100%;
margin-right: 4px;
`);
const cssDetailsRow = styled('div', `
@@ -243,13 +333,12 @@ const cssDetailsRow = styled('div', `
`);
// Holds dots menu (which is 24px x 24px, but has its own 4px right margin)
const cssRight = styled('div', `
padding-right: 8px;
margin-left: 8px;
align-self: center;
// Holds dots menu (which is 24px x 24px)
const cssTableButtons = styled('div', `
display: flex;
flex: none;
align-items: center;
justify-content: flex-end;
column-gap: 8px;
`);
const cssTableTypeIcon = styled(icon, `
@@ -270,13 +359,10 @@ const cssTableIdWrapper = styled('div', `
const cssTableRowsWrapper = styled('div', `
display: flex;
flex-shrink: 0;
min-width: 100px;
overflow: hidden;
align-items: baseline;
align-items: center;
color: ${css.theme.lightText};
line-height: 18px;
padding: 0px 2px;
`);
const cssHoverWrapper = styled('div', `
@@ -301,6 +387,8 @@ const cssTableRows = cssTableId;
const cssTableTitle = styled('div', `
color: ${css.theme.text};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`);
@@ -327,3 +415,66 @@ const cssLoadingDots = styled(loadingDots, `
const cssTableName = styled('span', `
color: ${css.theme.text};
`);
const cssRecordCardButton = styled('div', `
display: flex;
align-items: center;
justify-content: center;
height: 24px;
width: 24px;
cursor: default;
padding: 4px;
border-radius: 3px;
--icon-color: ${css.theme.lightText};
&:hover {
background-color: ${css.theme.hover};
--icon-color: ${css.theme.controlFg};
}
&-disabled {
--icon-color: ${css.theme.lightText};
padding: 0px;
opacity: 0.4;
}
&-disabled:hover {
background: none;
--icon-color: ${css.theme.lightText};
}
`);
const cssDotsButton = styled(docMenuTrigger, `
margin: 0px;
&:hover, &.weasel-popup-open {
background-color: ${css.theme.hover};
}
`);
const cssRenameTableButton = styled('div', `
flex-shrink: 0;
width: 16px;
visibility: hidden;
cursor: default;
--icon-color: ${css.theme.lightText};
&:hover {
--icon-color: ${css.theme.controlFg};
}
&-active {
visibility: hidden;
}
.${cssTableTitle.className}:hover & {
visibility: visible;
}
`);
const cssFlexRow = styled('div', `
display: flex;
align-items: center;
column-gap: 8px;
`);
const cssRenamableTableName = styled('div', `
flex: initial;
`);

View File

@@ -17,8 +17,8 @@ const {CopySelection} = require('./CopySelection');
const RecordLayout = require('./RecordLayout');
const commands = require('./commands');
const tableUtil = require('../lib/tableUtil');
const {CardContextMenu} = require('../ui/CardContextMenu');
const {FieldContextMenu} = require('../ui/FieldContextMenu');
const {RowContextMenu} = require('../ui/RowContextMenu');
const {parsePasteForView} = require("./BaseView2");
const {descriptionInfoTooltip} = require("../ui/tooltips");
@@ -39,7 +39,7 @@ function DetailView(gristDoc, viewSectionModel) {
this.recordLayout = this.autoDispose(RecordLayout.create({
viewSection: this.viewSection,
buildFieldDom: this.buildFieldDom.bind(this),
buildRowContextMenu : this.buildRowContextMenu.bind(this),
buildCardContextMenu : this.buildCardContextMenu.bind(this),
buildFieldContextMenu : this.buildFieldContextMenu.bind(this),
resizeCallback: () => {
if (!this._isSingle) {
@@ -246,15 +246,14 @@ DetailView.prototype.getSelection = function() {
);
};
DetailView.prototype.buildRowContextMenu = function(row) {
const rowOptions = this._getRowContextMenuOptions(row);
return RowContextMenu(rowOptions);
DetailView.prototype.buildCardContextMenu = function(row) {
const cardOptions = this._getCardContextMenuOptions(row);
return CardContextMenu(cardOptions);
}
DetailView.prototype.buildFieldContextMenu = function(row) {
const rowOptions = this._getRowContextMenuOptions(row);
DetailView.prototype.buildFieldContextMenu = function() {
const fieldOptions = this._getFieldContextMenuOptions();
return FieldContextMenu(rowOptions, fieldOptions);
return FieldContextMenu(fieldOptions);
}
/**
@@ -490,8 +489,9 @@ DetailView.prototype._canSingleClick = function(field) {
};
DetailView.prototype._clearCardFields = function() {
const {isFormula} = this._getFieldContextMenuOptions();
if (isFormula === true) {
const selection = this.getSelection();
const isFormula = Boolean(selection.fields[0]?.column.peek().isRealFormula.peek());
if (isFormula) {
this.activateEditorAtCursor({init: ''});
} else {
const clearAction = tableUtil.makeDeleteAction(this.getSelection());
@@ -520,7 +520,7 @@ DetailView.prototype._clearCopySelection = function() {
this.copySelection(null);
};
DetailView.prototype._getRowContextMenuOptions = function(row) {
DetailView.prototype._getCardContextMenuOptions = function(row) {
return {
disableInsert: Boolean(
this.gristDoc.isReadonly.get() ||
@@ -542,7 +542,6 @@ DetailView.prototype._getFieldContextMenuOptions = function() {
return {
disableModify: Boolean(selection.fields[0]?.disableModify.peek()),
isReadonly: this.gristDoc.isReadonly.get() || this.isPreview,
isFormula: Boolean(selection.fields[0]?.column.peek().isRealFormula.peek()),
};
}

View File

@@ -55,6 +55,9 @@ 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 {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');
@@ -370,7 +373,17 @@ GridView.gridCommands = {
return;
}
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
}
},
viewAsCard() {
if (!RECORD_CARDS()) { return; }
if (this._isRecordCardDisabled()) { return; }
const selectedRows = this.selectedRows();
const rowId = selectedRows[0];
const sectionId = this.viewSection.tableRecordCard().id();
const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}};
urlState().pushUrl(anchorUrlState, {replace: true}).catch(reportError);
},
};
GridView.prototype.onTableLoaded = function() {
@@ -1909,20 +1922,41 @@ GridView.prototype.rowContextMenu = function() {
GridView.prototype._getRowContextMenuOptions = function() {
return {
disableInsert: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand()),
disableDelete: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.getSelection().onlyAddRowSelected()),
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
numRows: this.getSelection().rowIds.length,
...this._getCellContextMenuOptions(),
disableShowRecordCard: this._isRecordCardDisabled(),
};
};
GridView.prototype._isRecordCardDisabled = function() {
return this.getSelection().onlyAddRowSelected() ||
this.viewSection.isTableRecordCardDisabled() ||
this.viewSection.table().summarySourceTable() !== 0;
}
GridView.prototype.cellContextMenu = function() {
return CellContextMenu(
this._getRowContextMenuOptions(),
this._getCellContextMenuOptions(),
this._getColumnMenuOptions(this.getSelection())
);
};
GridView.prototype._getCellContextMenuOptions = function() {
return {
disableInsert: Boolean(
this.gristDoc.isReadonly.get() ||
this.viewSection.disableAddRemoveRows() ||
this.tableModel.tableMetaRow.onDemand()
),
disableDelete: Boolean(
this.gristDoc.isReadonly.get() ||
this.viewSection.disableAddRemoveRows() ||
this.getSelection().onlyAddRowSelected()
),
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
numRows: this.getSelection().rowIds.length,
};
};
// End Context Menus
GridView.prototype.scrollToCursor = function(sync = true) {

View File

@@ -17,6 +17,7 @@ import {EditorMonitor} from "app/client/components/EditorMonitor";
import GridView from 'app/client/components/GridView';
import {importFromFile, selectAndImport} from 'app/client/components/Importer';
import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
import {RecordCardPopup} from 'app/client/components/RecordCardPopup';
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
@@ -125,7 +126,7 @@ export interface IExtraTool {
content: TabContent[] | IDomComponent;
}
interface RawSectionOptions {
interface PopupSectionOptions {
viewSection: ViewSectionRec;
hash: HashLink;
close: () => void;
@@ -179,7 +180,7 @@ export class GristDoc extends DisposableWithEvents {
// the space.
public maximizedSectionId: Observable<number | null> = Observable.create(this, null);
// This is id of the section that is currently shown in the popup. Probably this is an external
// section, like raw data view, or a section from another view..
// section, like raw data view, or a section from another view.
public externalSectionId: Computed<number | null>;
public viewLayout: ViewLayout | null = null;
@@ -201,15 +202,14 @@ export class GristDoc extends DisposableWithEvents {
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
private _rawSectionOptions: Observable<RawSectionOptions | null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage | RawSectionOptions>;
private _popupSectionOptions: Observable<PopupSectionOptions | null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage | PopupSectionOptions>;
private _docTutorialHolder = Holder.create<DocTutorial>(this);
private _isRickRowing: Observable<boolean> = Observable.create(this, false);
private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false);
private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this);
private _disableAutoStartingTours: boolean = false;
constructor(
public readonly app: App,
public readonly appModel: AppModel,
@@ -256,9 +256,9 @@ export class GristDoc extends DisposableWithEvents {
const viewId = this.docModel.views.tableData.findRow(docPage === 'GristDocTour' ? 'name' : 'id', docPage);
return viewId || use(defaultViewId);
});
this._activeContent = Computed.create(this, use => use(this._rawSectionOptions) ?? use(this.activeViewId));
this._activeContent = Computed.create(this, use => use(this._popupSectionOptions) ?? use(this.activeViewId));
this.externalSectionId = Computed.create(this, use => {
const externalContent = use(this._rawSectionOptions);
const externalContent = use(this._popupSectionOptions);
return externalContent ? use(externalContent.viewSection.id) : null;
});
// This viewModel reflects the currently active view, relying on the fact that
@@ -302,7 +302,7 @@ export class GristDoc extends DisposableWithEvents {
try {
if (state.hash.popup) {
if (state.hash.popup || state.hash.recordCard) {
await this.openPopup(state.hash);
} else {
// Navigate to an anchor if one is present in the url hash.
@@ -615,7 +615,17 @@ export class GristDoc extends DisposableWithEvents {
owner.autoDispose(this.activeViewId.addListener(content.close));
// In case the section is removed, close the popup.
content.viewSection.autoDispose({dispose: content.close});
return dom.create(RawDataPopup, this, content.viewSection, content.close);
const {recordCard} = content.hash;
if (recordCard) {
return dom.create(RecordCardPopup, {
gristDoc: this,
viewSection: content.viewSection,
onClose: content.close,
});
} else {
return dom.create(RawDataPopup, this, content.viewSection, content.close);
}
}) :
dom.create((owner) => {
this.viewLayout = ViewLayout.create(owner, this, content);
@@ -671,7 +681,11 @@ export class GristDoc extends DisposableWithEvents {
return;
}
// If this is completely unknown section (without a parent), it is probably an import preview.
if (!desiredSection.parentId.peek() && !desiredSection.isRaw.peek()) {
if (
!desiredSection.parentId.peek() &&
!desiredSection.isRaw.peek() &&
!desiredSection.isRecordCard.peek()
) {
const view = desiredSection.viewInstance.peek();
// Make sure we have a view instance here - it will prove our assumption that this is
// an import preview. Section might also be disconnected during undo/redo.
@@ -1215,7 +1229,8 @@ export class GristDoc extends DisposableWithEvents {
}, false, silent, visitedSections.concat([section.id.peek()]));
}
const view: ViewRec = section.view.peek();
const docPage: ViewDocPage = section.isRaw.peek() ? "data" : view.getRowId();
const isRawOrRecordCardView = section.isRaw.peek() || section.isRecordCard.peek();
const docPage: ViewDocPage = isRawOrRecordCardView ? 'data' : view.getRowId();
if (docPage != this.activeViewId.get()) {
await this.openDocPage(docPage);
}
@@ -1303,20 +1318,21 @@ export class GristDoc extends DisposableWithEvents {
// We need to make it active, so that cursor on this section will be the
// active one. This will change activeViewSectionId on a parent view of this section,
// which might be a diffrent view from what we currently have. If the section is
// a raw data section it will use `EmptyRowModel` as raw sections don't have parents.
// a raw data or record card section, it will use `EmptyRowModel` as these sections
// don't currently have parent views.
popupSection.hasFocus(true);
this._rawSectionOptions.set({
this._popupSectionOptions.set({
hash,
viewSection: popupSection,
close: () => {
// In case we are already close, do nothing.
if (!this._rawSectionOptions.get()) {
// In case we are already closed, do nothing.
if (!this._popupSectionOptions.get()) {
return;
}
if (popupSection !== prevSection) {
// We need to blur raw view section. Otherwise it will automatically be opened
// on raw data view. Note: raw data section doesn't have its own view, it uses
// empty row model as a parent (which feels like a hack).
// 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);
}
@@ -1328,17 +1344,21 @@ export class GristDoc extends DisposableWithEvents {
prevSection.hasFocus(true);
}
}
// Clearing popup data will close this popup.
this._rawSectionOptions.set(null);
// Clearing popup section data will close this popup.
this._popupSectionOptions.set(null);
}
});
// If the anchor link is valid, set the cursor.
if (hash.colRef && hash.rowId) {
const fieldIndex = popupSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef);
if (fieldIndex >= 0) {
const view = await this._waitForView(popupSection);
view?.setCursorPos({rowId: hash.rowId, fieldIndex});
if (hash.rowId || hash.colRef) {
const {rowId} = hash;
let fieldIndex;
if (hash.colRef) {
const maybeFieldIndex = popupSection.viewFields.peek().all()
.findIndex(f => f.colRef.peek() === hash.colRef);
if (maybeFieldIndex !== -1) { fieldIndex = maybeFieldIndex; }
}
const view = await this._waitForView(popupSection);
view?.setCursorPos({rowId, fieldIndex});
}
}
@@ -1586,8 +1606,8 @@ export class GristDoc extends DisposableWithEvents {
*/
private async _switchToSectionId(sectionId: number) {
const section: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionId);
if (section.isRaw.peek()) {
// This is raw data view
if (section.isRaw.peek() || section.isRecordCard.peek()) {
// This is a raw data or record card view.
await urlState().pushUrl({docPage: 'data'});
this.viewModel.activeSectionId(sectionId);
} else if (section.isVirtual.peek()) {

View File

@@ -23,7 +23,7 @@ export class RawDataPage extends Disposable {
this.autoDispose(commands.createGroup(commandGroup, this, true));
this._lightboxVisible = Computed.create(this, use => {
const section = use(this._gristDoc.viewModel.activeSection);
return Boolean(use(section.id)) && use(section.isRaw);
return Boolean(use(section.id)) && (use(section.isRaw) || use(section.isRecordCard));
});
// When we are disposed, we want to clear active section in the viewModel we got (which is an empty model)
// to not restore the section when user will come back to Raw Data page.
@@ -55,7 +55,7 @@ export class RawDataPage extends Disposable {
/*************** Lightbox section **********/
dom.domComputed(fromKo(this._gristDoc.viewModel.activeSection), (viewSection) => {
const sectionId = viewSection.getRowId();
if (!sectionId || !viewSection.isRaw.peek()) {
if (!sectionId || (!viewSection.isRaw.peek() && !viewSection.isRecordCard.peek())) {
return null;
}
return dom.create(RawDataPopup, this._gristDoc, viewSection, () => this._close());
@@ -97,7 +97,9 @@ export class RawDataPopup extends Disposable {
sectionRowId: this._viewSection.getRowId(),
draggable: false,
focusable: false,
widgetNameHidden: this._viewSection.isRaw.peek(), // We are sometimes used for non raw sections.
// Expanded, non-raw widgets are also rendered in RawDataPopup.
widgetNameHidden: this._viewSection.isRaw.peek(),
renamable: !this._viewSection.isRecordCard.peek(),
})
),
cssCloseButton('CrossBig',
@@ -127,7 +129,7 @@ const cssPage = styled('div', `
}
`);
const cssOverlay = styled('div', `
export const cssOverlay = styled('div', `
background-color: ${theme.modalBackdrop};
inset: 0px;
height: 100%;
@@ -162,7 +164,7 @@ const cssSectionWrapper = styled('div', `
}
`);
const cssCloseButton = styled(icon, `
export const cssCloseButton = styled(icon, `
position: absolute;
top: 16px;
right: 16px;

View File

@@ -0,0 +1,69 @@
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 {theme} from 'app/client/ui2018/cssVars';
import {Disposable, dom, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-record-card-popup-');
interface RecordCardPopupOptions {
gristDoc: GristDoc;
viewSection: ViewSectionRec;
onClose(): void;
}
export class RecordCardPopup extends Disposable {
private _gristDoc = this._options.gristDoc;
private _viewSection = this._options.viewSection;
private _handleClose = this._options.onClose;
constructor(private _options: RecordCardPopupOptions) {
super();
const commandGroup = {
cancel: () => { this._handleClose(); },
};
this.autoDispose(commands.createGroup(commandGroup, this, true));
}
public buildDom() {
ViewSectionHelper.create(this, this._gristDoc, this._viewSection);
return cssOverlay(
testId('overlay'),
cssSectionWrapper(
buildViewSectionDom({
gristDoc: this._gristDoc,
sectionRowId: this._viewSection.getRowId(),
draggable: false,
focusable: false,
renamable: false,
hideTitleControls: true,
}),
),
cssCloseButton('CrossBig',
dom.on('click', () => this._handleClose()),
testId('close'),
),
dom.on('click', (ev, elem) => void (ev.target === elem ? this._handleClose() : null)),
);
}
}
const cssSectionWrapper = styled('div', `
background: ${theme.mainPanelBg};
height: 100%;
display: flex;
flex-direction: column;
border-radius: 5px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
& .viewsection_content {
margin: 0px;
margin-top: 8px;
}
& .viewsection_title {
padding: 0px 12px;
}
`);

View File

@@ -54,7 +54,7 @@ const t = makeT('RecordLayout');
function RecordLayout(options) {
this.viewSection = options.viewSection;
this.buildFieldDom = options.buildFieldDom;
this.buildRowContextMenu = options.buildRowContextMenu;
this.buildCardContextMenu = options.buildCardContextMenu;
this.buildFieldContextMenu = options.buildFieldContextMenu;
this.isEditingLayout = ko.observable(false);
this.editIndex = ko.observable(0);
@@ -342,7 +342,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.layoutEditor(null);
}) : null,
// enables field context menu anywhere on the card
contextMenu(() => this.buildFieldContextMenu(row)),
contextMenu(() => this.buildFieldContextMenu()),
dom('div.detail_row_num',
kd.text(() => (row._index() + 1)),
dom.on('contextmenu', ev => {
@@ -358,7 +358,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.viewSection.hasFocus(true);
commands.allCommands.setCursor.run(row);
}),
menu(() => this.buildRowContextMenu(row)),
menu(() => this.buildCardContextMenu(row)),
testId('card-menu-trigger')
)
),

View File

@@ -168,7 +168,7 @@ export class RefSelect extends Disposable {
this._getReferrerFields(item.value).forEach(refField => {
const sectionId = this._fieldObs()!.viewSection().getRowId();
if (refField.column().viewFields().all()
.filter(field => !field.viewSection().isRaw())
.filter(field => !field.viewSection().isRaw() && !field.viewSection().isRecordCard())
.some(field => field.parentId() !== sectionId)) {
// The col has fields in other sections, remove only the fields in this section.
return this._docModel.viewFields.sendTableAction(['RemoveRecord', refField.getRowId()]);

View File

@@ -8,7 +8,7 @@ var koArray = require('../lib/koArray');
var commands = require('./commands');
var {CustomSectionElement} = require('../lib/CustomSectionElement');
const {ChartConfig} = require('./ChartView');
const {Computed, dom: grainjsDom, makeTestId} = require('grainjs');
const {Computed, dom: grainjsDom, makeTestId, Holder} = require('grainjs');
const {cssRow} = require('app/client/ui/RightPanelStyles');
const {SortFilterConfig} = require('app/client/ui/SortFilterConfig');
@@ -37,6 +37,7 @@ function ViewConfigTab(options) {
var self = this;
this.gristDoc = options.gristDoc;
this.viewModel = options.viewModel;
this._viewSectionDataHolder = Holder.create(this);
// viewModel may point to different views, but viewSectionData is a single koArray reflecting
// the sections of the current view.
@@ -58,18 +59,21 @@ function ViewConfigTab(options) {
return this.viewModel.activeSection().parentKey() === 'custom';}, this));
this.isRaw = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().isRaw();}, this));
this.isRecordCard = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().isRecordCard();}, this));
this.activeRawSectionData = this.autoDispose(ko.computed(function() {
return self.isRaw() ? ViewSectionData.create(self.viewModel.activeSection()) : null;
this.activeRawOrRecordCardSectionData = this.autoDispose(ko.computed(function() {
return self.isRaw() || self.isRecordCard()
? self._viewSectionDataHolder.autoDispose(ViewSectionData.create(self.viewModel.activeSection()))
: null;
}));
this.activeSectionData = this.autoDispose(ko.computed(function() {
return (
_.find(self.viewSectionData.all(), function(sectionData) {
return sectionData.section &&
sectionData.section.getRowId() === self.viewModel.activeSectionId();
})
|| self.activeRawSectionData()
|| self.activeRawOrRecordCardSectionData()
|| self.viewSectionData.at(0)
);
}));

View File

@@ -205,14 +205,14 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
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);
}
&& 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();

View File

@@ -65,9 +65,21 @@ export function buildViewSectionDom(options: {
focusable?: boolean, /* defaults to true */
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
renamable?: boolean,
hideTitleControls?: boolean,
}) {
const isResizing = options.isResizing ?? Observable.create(null, false);
const {gristDoc, sectionRowId, viewModel, draggable = true, focusable = true} = options;
const {
gristDoc,
sectionRowId,
viewModel,
draggable = true,
focusable = true,
tableNameHidden,
widgetNameHidden,
renamable = true,
hideTitleControls = false,
} = options;
// Creating normal section dom
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
@@ -92,8 +104,13 @@ export function buildViewSectionDom(options: {
),
dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () =>
cssSigmaIcon('Pivot', testId('sigma'))),
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
viewInstance.buildTitleControls(),
buildWidgetTitle(
vs,
{tableNameHidden, widgetNameHidden, disabled: !renamable},
testId('viewsection-title'),
cssTestClick(testId("viewsection-blank")),
),
hideTitleControls ? null : viewInstance.buildTitleControls(),
dom('div.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc, vs)
)

View File

@@ -114,6 +114,7 @@ export type CommandName =
| 'clearCopySelection'
| 'detachEditor'
| 'activateAssistant'
| 'viewAsCard'
;
@@ -270,6 +271,11 @@ export const groups: CommendGroupDef[] = [{
keys: [],
desc: 'Activate assistant',
},
{
name: 'viewAsCard',
keys: [],
desc: 'Show the record card widget of the selected record',
},
]
}, {
group: 'Navigation',