(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',

View File

@@ -15,6 +15,7 @@ export interface TableRec extends IRowModel<"_grist_Tables"> {
primaryView: ko.Computed<ViewRec>;
rawViewSection: ko.Computed<ViewSectionRec>;
recordCardViewSection: ko.Computed<ViewSectionRec>;
summarySource: ko.Computed<TableRec>;
// A Set object of colRefs for all summarySourceCols of table.
@@ -52,6 +53,7 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
this.primaryView = refRecord(docModel.views, this.primaryViewId);
this.rawViewSection = refRecord(docModel.viewSections, this.rawViewSectionRef);
this.recordCardViewSection = refRecord(docModel.viewSections, this.recordCardViewSectionRef);
this.summarySource = refRecord(docModel.tables, this.summarySourceTable);
this.isHidden = this.autoDispose(
// This is repeated logic from isHiddenTable.

View File

@@ -85,6 +85,19 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// true if this record is its table's rawViewSection, i.e. a 'raw data view'
// in which case the UI prevents various things like hiding columns or changing the widget type.
isRaw: ko.Computed<boolean>;
tableRecordCard: ko.Computed<ViewSectionRec>
isRecordCard: ko.Computed<boolean>;
/** True if this section is disabled. Currently only used by Record Card sections. */
disabled: modelUtil.KoSaveableObservable<boolean>;
/**
* True if the Record Card section of this section's table is disabled. Shortcut for
* `this.tableRecordCard().disabled()`.
*/
isTableRecordCardDisabled: ko.Computed<boolean>;
isVirtual: ko.Computed<boolean>;
isCollapsed: ko.Computed<boolean>;
@@ -443,7 +456,13 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
// true if this record is its table's rawViewSection, i.e. a 'raw data view'
// in which case the UI prevents various things like hiding columns or changing the widget type.
this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.getRowId()));
this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.id()));
this.tableRecordCard = this.autoDispose(ko.pureComputed(() => this.table().recordCardViewSection()));
this.isRecordCard = this.autoDispose(ko.pureComputed(() =>
this.table().recordCardViewSectionRef() === this.id()));
this.disabled = modelUtil.fieldWithDefault(this.optionsObj.prop('disabled'), false);
this.isTableRecordCardDisabled = ko.pureComputed(() => this.tableRecordCard().disabled());
this.isVirtual = this.autoDispose(ko.pureComputed(() => typeof this.id() === 'string'));
@@ -818,7 +837,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
let newColInfo: NewColInfo;
await docModel.docData.bundleActions('Insert column', async () => {
newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action);
if (!this.isRaw.peek()) {
if (!this.isRaw.peek() && !this.isRecordCard.peek()) {
const fieldInfo = {
colRef: newColInfo.colRef,
parentId: this.id.peek(),

View File

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

View File

@@ -0,0 +1,49 @@
import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { dom } from 'grainjs';
const t = makeT('CardContextMenu');
export interface ICardContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
numRows: number;
}
export function CardContextMenu({
disableInsert,
disableDelete,
isViewSorted,
numRows
}: ICardContextMenu) {
const result: Element[] = [];
if (isViewSorted) {
result.push(
menuItemCmd(allCommands.insertRecordAfter, t("Insert card"),
dom.cls('disabled', disableInsert)),
);
} else {
result.push(
menuItemCmd(allCommands.insertRecordBefore, t("Insert card above"),
dom.cls('disabled', disableInsert)),
menuItemCmd(allCommands.insertRecordAfter, t("Insert card below"),
dom.cls('disabled', disableInsert)),
);
}
result.push(
menuItemCmd(allCommands.duplicateRows, t("Duplicate card"),
dom.cls('disabled', disableInsert || numRows === 0)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.deleteRecords, t("Delete card"),
dom.cls('disabled', disableDelete)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.copyLink, t("Copy anchor link"))
);
return result;
}

View File

@@ -2,32 +2,36 @@ import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus';
import { IRowContextMenu } from 'app/client/ui/RowContextMenu';
import { COMMENTS } from 'app/client/models/features';
import { dom } from 'grainjs';
const t = makeT('CellContextMenu');
export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) {
export interface ICellContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
numRows: number;
}
const { disableInsert, disableDelete, isViewSorted } = rowOptions;
const { disableModify, isReadonly } = colOptions;
export function CellContextMenu(cellOptions: ICellContextMenu, colOptions: IMultiColumnContextMenu) {
const { disableInsert, disableDelete, isViewSorted, numRows } = cellOptions;
const { numColumns, disableModify, isReadonly, isFiltered } = colOptions;
// disableModify is true if the column is a summary column or is being transformed.
// isReadonly is true for readonly mode.
const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
const disableForReadonlyView = dom.cls('disabled', isReadonly);
const numCols: number = colOptions.numColumns;
const nameClearColumns = colOptions.isFiltered ?
t("Reset {{count}} entire columns", {count: numCols}) :
t("Reset {{count}} columns", {count: numCols});
const nameDeleteColumns = t("Delete {{count}} columns", {count: numCols});
const nameClearColumns = isFiltered ?
t("Reset {{count}} entire columns", {count: numColumns}) :
t("Reset {{count}} columns", {count: numColumns});
const nameDeleteColumns = t("Delete {{count}} columns", {count: numColumns});
const numRows: number = rowOptions.numRows;
const nameDeleteRows = t("Delete {{count}} rows", {count: numRows});
const nameClearCells = (numRows > 1 || numCols > 1) ? t("Clear values") : t("Clear cell");
const nameClearCells = (numRows > 1 || numColumns > 1) ? t("Clear values") : t("Clear cell");
const result: Array<Element|null> = [];
@@ -42,13 +46,13 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
...(
(numCols > 1 || numRows > 1) ? [] : [
(numColumns > 1 || numRows > 1) ? [] : [
menuDivider(),
menuItemCmd(allCommands.copyLink, t("Copy anchor link")),
menuDivider(),
menuItemCmd(allCommands.filterByThisCellValue, t("Filter by this value")),
menuItemCmd(allCommands.openDiscussion, t('Comment'), dom.cls('disabled', (
isReadonly || numRows === 0 || numCols === 0
isReadonly || numRows === 0 || numColumns === 0
)), dom.hide(use => !use(COMMENTS()))) //TODO: i18next
]
),

View File

@@ -1,6 +1,5 @@
import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {IRowContextMenu} from 'app/client/ui/RowContextMenu';
import {menuDivider, menuItemCmd} from 'app/client/ui2018/menus';
import {dom} from 'grainjs';
@@ -11,7 +10,7 @@ export interface IFieldContextMenu {
isReadonly: boolean;
}
export function FieldContextMenu(_rowOptions: IRowContextMenu, fieldOptions: IFieldContextMenu) {
export function FieldContextMenu(fieldOptions: IFieldContextMenu) {
const {disableModify, isReadonly} = fieldOptions;
const disableForReadonlyColumn = dom.cls('disabled', disableModify || isReadonly);
return [

View File

@@ -86,7 +86,7 @@ function removeView(activeDoc: GristDoc, viewId: number, pageName: string) {
const docData = activeDoc.docData;
// Create a set with tables on other pages (but not on this one).
const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels
.filter(vs => !vs.isRaw.peek() && vs.parentId.peek() !== viewId)
.filter(vs => !vs.isRaw.peek() && !vs.isRecordCard.peek() && vs.parentId.peek() !== viewId)
.map(vs => vs.tableRef.peek()));
// Check if this page is a last page for some tables.

View File

@@ -356,7 +356,10 @@ export class RightPanel extends Disposable {
dom.maybe(this._validSection, (activeSection) => (
buildConfigContainer(
subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) :
subTab === 'sortAndFilter' ? dom.create(this._buildPageSortFilterConfig.bind(this)) :
subTab === 'sortAndFilter' ? [
dom.create(this._buildPageSortFilterConfig.bind(this)),
cssConfigContainer.cls('-disabled', activeSection.isRecordCard),
] :
subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) :
null
)
@@ -397,33 +400,35 @@ export class RightPanel extends Disposable {
return dom.maybe(viewConfigTab, (vct) => [
this._disableIfReadonly(),
cssLabel(dom.text(use => use(activeSection.isRaw) ? t("DATA TABLE NAME") : t("WIDGET TITLE")),
dom.style('margin-bottom', '14px'),
),
cssRow(cssTextInput(
Computed.create(owner, (use) => use(activeSection.titleDef)),
val => activeSection.titleDef.saveOnly(val),
dom.boolAttr('disabled', use => {
const isRawTable = use(activeSection.isRaw);
const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0;
return isRawTable && isSummaryTable;
}),
testId('right-widget-title')
)),
dom.maybe(use => !use(activeSection.isRecordCard), () => [
cssLabel(dom.text(use => use(activeSection.isRaw) ? t("DATA TABLE NAME") : t("WIDGET TITLE")),
dom.style('margin-bottom', '14px'),
),
cssRow(cssTextInput(
Computed.create(owner, (use) => use(activeSection.titleDef)),
val => activeSection.titleDef.saveOnly(val),
dom.boolAttr('disabled', use => {
const isRawTable = use(activeSection.isRaw);
const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0;
return isRawTable && isSummaryTable;
}),
testId('right-widget-title')
)),
cssSection(
dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }),
),
cssSection(
dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }),
),
]),
dom.maybe(
(use) => !use(activeSection.isRaw),
(use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard),
() => cssRow(
primaryButton(t("Change Widget"), this._createPageWidgetPicker()),
cssRow.cls('-top-space')
),
),
cssSeparator(),
cssSeparator(dom.hide(activeSection.isRecordCard)),
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
cssLabel(t("Theme")),
@@ -744,7 +749,7 @@ export class RightPanel extends Disposable {
dom.hide((use) => !use(use(table).summarySourceTable)),
),
dom.maybe((use) => !use(activeSection.isRaw), () =>
dom.maybe((use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () =>
cssButtonRow(primaryButton(t("Edit Data Selection"), this._createPageWidgetPicker(),
testId('pwc-editDataSelection')),
dom.maybe(
@@ -764,9 +769,9 @@ export class RightPanel extends Disposable {
dom.maybe(viewConfigTab, (vct) => cssRow(
dom('div', vct._buildAdvancedSettingsDom()),
)),
cssSeparator(),
dom.maybe((use) => !use(activeSection.isRaw), () => [
dom.maybe((use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () => [
cssSeparator(),
cssLabel(t("SELECT BY")),
cssRow(
dom.update(
@@ -1033,6 +1038,10 @@ const cssConfigContainer = styled('div.test-config-container', `
& .fieldbuilder_settings {
margin: 16px 0 0 0;
}
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssDataLabel = styled('div', `

View File

@@ -1,6 +1,7 @@
import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { RECORD_CARDS } from 'app/client/models/features';
import { menuDivider, menuIcon, menuItemCmd, menuItemCmdLabel } from 'app/client/ui2018/menus';
import { dom } from 'grainjs';
const t = makeT('RowContextMenu');
@@ -8,12 +9,29 @@ const t = makeT('RowContextMenu');
export interface IRowContextMenu {
disableInsert: boolean;
disableDelete: boolean;
disableShowRecordCard: boolean;
isViewSorted: boolean;
numRows: number;
}
export function RowContextMenu({ disableInsert, disableDelete, isViewSorted, numRows }: IRowContextMenu) {
export function RowContextMenu({
disableInsert,
disableDelete,
disableShowRecordCard,
isViewSorted,
numRows
}: IRowContextMenu) {
const result: Element[] = [];
if (RECORD_CARDS() && numRows === 1) {
result.push(
menuItemCmd(
allCommands.viewAsCard,
() => menuItemCmdLabel(menuIcon('TypeCard'), t("View as card")),
dom.cls('disabled', disableShowRecordCard),
),
menuDivider(),
);
}
if (isViewSorted) {
// When the view is sorted, any newly added records get shifts instantly at the top or
// bottom. It could be very confusing for users who might expect the record to stay above or

View File

@@ -16,7 +16,7 @@ import {buildUrlId, isFeatureEnabled, parseUrlId} from 'app/common/gristUrls';
import * as roles from 'app/common/roles';
import {Document} from 'app/common/UserAPI';
import {dom, DomContents, styled} from 'grainjs';
import {MenuCreateFunc} from 'popweasel';
import {cssMenuItem, MenuCreateFunc} from 'popweasel';
import {makeT} from 'app/client/lib/localization';
const t = makeT('ShareMenu');
@@ -378,9 +378,12 @@ const cssMenuIconLink = styled('a', `
padding: 8px 24px;
--icon-color: ${theme.controlFg};
&:hover {
background-color: ${theme.hover};
--icon-color: ${theme.controlHoverFg};
.${cssMenuItem.className}-sel > & {
--icon-color: ${theme.menuItemIconSelectedFg};
}
.${cssMenuItem.className}.disabled & {
--icon-color: ${theme.menuItemDisabledFg};
}
`);

View File

@@ -58,6 +58,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
const showRawData = (use: UseCB) => {
return !use(viewSection.isRaw)// Don't show raw data if we're already in raw data.
&& !use(viewSection.isRecordCard)
&& !isSinglePage // Don't show raw data in single page mode.
;
};
@@ -88,20 +89,22 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
dom.maybe(!isSinglePage, () => [
menuDivider(),
menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter")),
menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection")),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)),
menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)),
]),
menuDivider(),
menuDivider(dom.hide(viewSection.isRecordCard)),
dom.maybe((use) => use(viewSection.parentKey) === 'custom' && use(viewSection.hasCustomOptions), () =>
menuItemCmd(allCommands.openWidgetConfiguration, t("Open configuration"),
testId('section-open-configuration')),
),
menuItemCmd(allCommands.collapseSection, t("Collapse widget"),
dom.cls('disabled', dontCollapseSection()),
dom.hide(viewSection.isRecordCard),
testId('section-collapse')),
menuItemCmd(allCommands.deleteSection, t("Delete widget"),
dom.cls('disabled', dontRemoveSection()),
dom.hide(viewSection.isRecordCard),
testId('section-delete')),
];
}

View File

@@ -69,6 +69,7 @@ export function viewSectionMenu(
&& use(gristDoc.maximizedSectionId) !== use(viewSection.id) // not in when we are maximized
&& use(gristDoc.externalSectionId) !== use(viewSection.id) // not in when we are external
&& !use(viewSection.isRaw) // not in raw mode
&& !use(viewSection.isRecordCard)
&& !use(singleVisible) // not in single section
;
});
@@ -145,6 +146,7 @@ export function viewSectionMenu(
ctl.close();
}),
]}),
dom.hide(viewSection.isRecordCard),
),
cssMenu(
testId('viewLayout'),

View File

@@ -7,7 +7,7 @@ import { theme } from 'app/client/ui2018/cssVars';
import {menuCssClass} from 'app/client/ui2018/menus';
import {ModalControl} from 'app/client/ui2018/modals';
import { Computed, dom, DomElementArg, makeTestId, Observable, styled } from 'grainjs';
import {IOpenController, setPopupToCreateDom} from 'popweasel';
import {IOpenController, IPopupOptions, PopupControl, setPopupToCreateDom} from 'popweasel';
import { descriptionInfoTooltip } from './tooltips';
import { autoGrow } from './forms';
import { cssInput, cssLabel, cssRenamePopup, cssTextArea } from 'app/client/ui/RenamePopupStyles';
@@ -18,41 +18,105 @@ const t = makeT('WidgetTitle');
interface WidgetTitleOptions {
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
disabled?: boolean,
}
export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) {
const title = Computed.create(null, use => use(vs.titleDef));
const description = Computed.create(null, use => use(vs.description));
return buildRenameWidget(vs, title, description, options, dom.autoDispose(title), ...args);
return buildRenamableTitle(vs, title, description, options, dom.autoDispose(title), ...args);
}
export function buildTableName(vs: ViewSectionRec, ...args: DomElementArg[]) {
interface TableNameOptions {
isEditing: Observable<boolean>,
disabled?: boolean,
}
export function buildTableName(vs: ViewSectionRec, options: TableNameOptions, ...args: DomElementArg[]) {
const title = Computed.create(null, use => use(use(vs.table).tableNameDef));
const description = Computed.create(null, use => use(vs.description));
return buildRenameWidget(vs, title, description, { widgetNameHidden: true }, dom.autoDispose(title), ...args);
return buildRenamableTitle(
vs,
title,
description,
{
openOnClick: false,
widgetNameHidden: true,
...options,
},
dom.autoDispose(title),
...args
);
}
export function buildRenameWidget(
interface RenamableTitleOptions {
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
/** Defaults to true. */
openOnClick?: boolean,
isEditing?: Observable<boolean>,
disabled?: boolean,
}
function buildRenamableTitle(
vs: ViewSectionRec,
title: Observable<string>,
description: Observable<string>,
options: WidgetTitleOptions,
...args: DomElementArg[]) {
options: RenamableTitleOptions,
...args: DomElementArg[]
) {
const {openOnClick = true, disabled = false, isEditing, ...renameTitleOptions} = options;
let popupControl: PopupControl | undefined;
return cssTitleContainer(
cssTitle(
testId('text'),
dom.text(title),
dom.on('click', () => {
// The popup doesn't close if `openOnClick` is false and the title is
// clicked. Make sure that it does.
if (!openOnClick) { popupControl?.close(); }
}),
// In case titleDef is all blank space, make it visible on hover.
cssTitle.cls("-empty", use => !use(title)?.trim()),
cssTitle.cls("-open-on-click", openOnClick),
cssTitle.cls("-disabled", disabled),
elem => {
setPopupToCreateDom(elem, ctl => buildWidgetRenamePopup(ctl, vs, options), {
if (disabled) { return; }
// The widget title popup can be configured to open in up to two ways:
// 1. When the title is clicked - done by setting `openOnClick` to `true`.
// 2. When `isEditing` is set to true - done by setting `isEditing` to `true`.
//
// Typically, the former should be set. The latter is useful for triggering the
// popup from a different part of the UI, like a menu item.
const trigger: IPopupOptions['trigger'] = [];
if (openOnClick) { trigger.push('click'); }
if (isEditing) {
trigger.push((_: Element, ctl: PopupControl) => {
popupControl = ctl;
ctl.autoDispose(isEditing.addListener((editing) => {
if (editing) {
ctl.open();
} else if (!ctl.isDisposed()) {
ctl.close();
}
}));
});
}
setPopupToCreateDom(elem, ctl => {
if (isEditing) {
ctl.onDispose(() => isEditing.set(false));
}
return buildRenameTitlePopup(ctl, vs, renameTitleOptions);
}, {
placement: 'bottom-start',
trigger: ['click'],
trigger,
attach: 'body',
boundaries: 'viewport',
});
},
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
openOnClick ? dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }) : null,
),
dom.maybe(description, () => [
descriptionInfoTooltip(description.get(), "widget")
@@ -61,7 +125,7 @@ export function buildRenameWidget(
);
}
function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, options: WidgetTitleOptions) {
function buildRenameTitlePopup(ctrl: IOpenController, vs: ViewSectionRec, options: RenamableTitleOptions) {
const tableRec = vs.table.peek();
// If the table is a summary table.
const isSummary = Boolean(tableRec.summarySourceTable.peek());
@@ -279,14 +343,16 @@ const cssTitleContainer = styled('div', `
`);
const cssTitle = styled('div', `
cursor: pointer;
overflow: hidden;
border-radius: 3px;
margin: -4px;
padding: 4px;
text-overflow: ellipsis;
align-self: start;
&:hover {
&-open-on-click:not(&-disabled) {
cursor: pointer;
}
&-open-on-click:not(&-disabled):hover {
background-color: ${theme.hover};
}
&-empty {

View File

@@ -9,7 +9,7 @@ import { IconName } from 'app/client/ui2018/IconList';
import { icon } from 'app/client/ui2018/icons';
import { cssSelectBtn } from 'app/client/ui2018/select';
import {
BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
BindableValue, Computed, dom, DomContents, DomElementArg, DomElementMethod, IDomArgs,
MaybeObsArray, MutableObsArray, Observable, styled
} from 'grainjs';
import debounce from 'lodash/debounce';
@@ -574,16 +574,27 @@ export const menuItemAsync: typeof weasel.menuItem = function(action, ...args) {
return menuItem(() => setTimeout(action, 0), ...args);
};
export function menuItemCmd(cmd: Command, label: string, ...args: DomElementArg[]) {
export function menuItemCmd(
cmd: Command,
label: string | (() => DomContents),
...args: DomElementArg[]
) {
return menuItem(
() => cmd.run(),
dom('span', label, testId('cmd-name')),
typeof label === 'string'
? dom('span', label, testId('cmd-name'))
: dom('div', label(), testId('cmd-name')),
cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null,
cssMenuItemCmd.cls(''), // overrides some menu item styles
...args
);
}
export const menuItemCmdLabel = styled('div', `
display: flex;
align-items: center;
`);
export function menuAnnotate(text: string, ...args: DomElementArg[]) {
return cssAnnotateMenuItem(text, ...args);
}
@@ -701,6 +712,15 @@ const cssInputButtonMenuElem = styled(cssMenuElem, `
const cssMenuItemCmd = styled('div', `
justify-content: space-between;
--icon-color: ${theme.menuItemFg};
.${weasel.cssMenuItem.className}-sel & {
--icon-color: ${theme.menuItemSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled & {
--icon-color: ${theme.menuItemDisabledFg};
}
`);
const cssCmdKey = styled('span', `
@@ -712,7 +732,7 @@ const cssCmdKey = styled('span', `
color: ${theme.menuItemIconSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled > & {
.${weasel.cssMenuItem.className}.disabled & {
color: ${theme.menuItemDisabledFg};
}
`);

View File

@@ -77,7 +77,11 @@ export class CellStyle extends Disposable {
}),
cssLine(
cssLabel(t('CELL STYLE')),
cssButton(t('Open row styles'), dom.on('click', allCommands.viewTabOpen.run)),
cssButton(
t('Open row styles'),
dom.on('click', allCommands.viewTabOpen.run),
dom.hide(!isTableWidget),
),
),
cssRow(
testId('cell-color-select'),

View File

@@ -1,12 +1,16 @@
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';
import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, select} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {isFullReferencingType, isVersions} from 'app/common/gristTypes';
import {UIRowId} from 'app/plugin/GristAPI';
import {Computed, dom, styled} from 'grainjs';
@@ -16,6 +20,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>;
private _visibleColRef: Computed<number>;
private _validCols: Computed<Array<IOptionFull<number>>>;
@@ -26,8 +31,10 @@ export class Reference extends NTextBox {
// Note that saveOnly is used here to prevent display value flickering on visible col change.
this._visibleColRef.onWrite((val) => this.field.visibleColRef.saveOnly(val));
this._refTable = Computed.create(this, (use) => use(use(this.field.column).refTable));
this._validCols = Computed.create(this, (use) => {
const refTable = use(use(this.field.column).refTable);
const refTable = use(this._refTable);
if (!refTable) { return []; }
return use(use(refTable.columns).getObservable())
.filter(col => !use(col.isHiddenCol))
@@ -75,16 +82,16 @@ export class Reference extends NTextBox {
return id && use(id);
});
const formattedValue = Computed.create(null, (use) => {
let [value, hasBlankReference] = ['', false];
let [value, hasBlankReference, hasRecordCard] = ['', false, false];
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 {value, hasBlankReference};
return {value, hasBlankReference, hasRecordCard};
}
const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)];
if (!displayValueObs) {
return {value, hasBlankReference};
return {value, hasBlankReference, hasRecordCard};
}
const displayValue = use(displayValueObs);
@@ -97,8 +104,12 @@ export class Reference extends NTextBox {
use(this.field.formatter).formatAny(displayValue);
hasBlankReference = referenceId.get() !== 0 && value.trim() === '';
const refTable = use(this._refTable);
if (refTable) {
hasRecordCard = !use(use(refTable.recordCardViewSection).disabled);
}
return {value, hasBlankReference};
return {value, hasBlankReference, hasRecordCard};
});
return cssRef(
@@ -107,12 +118,39 @@ export class Reference extends NTextBox {
cssRef.cls('-blank', use => use(formattedValue).hasBlankReference),
dom.style('text-align', this.alignment),
dom.cls('text_wrapping', this.wrapping),
cssRefIcon('FieldReference', testId('ref-link-icon'), hideInPrintView()),
dom.text(use => {
if (use(referenceId) === 0) { return ''; }
if (use(formattedValue).hasBlankReference) { return '[Blank]'; }
return use(formattedValue).value;
})
cssRefIcon('FieldReference',
cssRefIcon.cls('-view-as-card', use =>
RECORD_CARDS() && 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');
}
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();
}),
hideInPrintView(),
testId('ref-link-icon'),
),
dom('span',
dom.text(use => {
if (use(referenceId) === 0) { return ''; }
if (use(formattedValue).hasBlankReference) { return '[Blank]'; }
return use(formattedValue).value;
}),
testId('ref-text'),
),
);
}
}
@@ -121,6 +159,13 @@ const cssRefIcon = styled(icon, `
float: left;
--icon-color: ${theme.lightText};
margin: -1px 2px 2px 0;
&-view-as-card {
cursor: pointer;
}
&-view-as-card:hover {
--icon-color: ${theme.controlFg};
}
`);
const cssRef = styled('div.field_clip', `