(core) Context menu for cards.

Summary:
User was not able to delete cards. This patch introduces a context menu for cards, analogous to the one available for rows on a GridView.

Changes:
- Row numbers on a GridView have the same icon as on columns to make context menu more discoverable.
- Context menu for rows and columns, when activated, didn't switch section in rare conditions (i.e. when the section had 2 or more columns selected, one of which had the same rowId as a column in the section that the user switched from).
- Card list layout and a single card layout has the same context menu as in a GridView, available by pressing the context menu button.

Test Plan: Browser tests

Reviewers: dsagal, paulfitz

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2870
This commit is contained in:
Jarosław Sadziński 2021-06-28 20:02:45 +02:00
parent 01cef034ed
commit e180641c7d
11 changed files with 204 additions and 83 deletions

View File

@ -6,7 +6,8 @@
/* Make visible if open or in column header hover */
.g-column-menu-btn.open,
.g-column-menu-btn.active,
.column_name:hover .g-column-menu-btn {
.column_name:hover .g-column-menu-btn,
.column_name .g-column-menu-btn.weasel-popup-open {
visibility: visible;
}
@ -14,35 +15,6 @@
visibility: hidden;
}
.g-column-menu-btn > span.glyphicon {
padding: 1px;
margin-left: 2px;
margin-right: 2px;
background-color: #fff;
color: #999;
border: 1px solid #999;
border-radius: 3px;
font-size: 1rem;
}
.g-column-menu-btn.left-btn > span.glyphicon {
margin: 0 0 0 2px;
}
.g-column-menu-btn.right-btn > span.glyphicon {
margin: 0 2px 0 0;
}
.g-column-menu-btn:hover > span.glyphicon {
color: #333;
border: 1px solid #333;
}
.g-column-menu-btn.active > span.glyphicon {
color: #33f;
border-color: #33f;
}
.g-column-menu {
position: absolute;
min-width: 180px;

View File

@ -52,15 +52,32 @@
}
.detail_row_num {
text-align: right;
font-size: var(--grist-x-small-font-size);
font-weight: normal;
color: var(--grist-color-slate);
padding: 8px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.detail_row_num .menu_toggle {
margin-left: 0.5rem;
}
.detail_row_num:hover .menu_toggle,
.detail_row_num .menu_toggle.weasel-popup-open {
color: var(--color-link-default);
}
/* hide menu on layout editor */
.detailview_layout_editor .menu_toggle {
visibility: hidden !important;
}
.detail_row_num::before {
content: "ROW ";
margin-right: 2px;
}
.detail-left.disabled, .detail-right.disabled, .detail-add-btn.disabled {
@ -200,6 +217,10 @@
margin-right: -1px; /* allow labels to overflow into the padding */
}
.detail_theme_record_compact .menu_toggle {
transform: translateY(-1px);
}
/*** form theme ***/
.detail_theme_field_form {

View File

@ -13,6 +13,7 @@ var BaseView = require('./BaseView');
var CopySelection = require('./CopySelection');
var RecordLayout = require('./RecordLayout');
var commands = require('./commands');
const {RowContextMenu} = require('../ui/RowContextMenu');
/**
* DetailView component implements a list of record layouts.
@ -28,6 +29,7 @@ function DetailView(gristDoc, viewSectionModel) {
this.recordLayout = this.autoDispose(RecordLayout.create({
viewSection: this.viewSection,
buildFieldDom: this.buildFieldDom.bind(this),
buildContextMenu : this.buildContextMenu.bind(this),
resizeCallback: () => {
if (!this._isSingle) {
this.scrolly().updateSize();
@ -205,6 +207,15 @@ DetailView.prototype.getSelection = function() {
);
};
DetailView.prototype.buildContextMenu = function(row, options) {
const defaults = {
disableInsert: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand()),
disableDelete: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || row._isAddRow()),
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
};
return RowContextMenu(options ? Object.assign(defaults, options) : defaults);
}
/**
* Builds the DOM for the given field of the given row.
* @param {MetaRowModel|String} field: Model for the field to render. For a new field being added,
@ -262,6 +273,8 @@ DetailView.prototype.buildDom = function() {
// Add .detailview_single when showing a single card or while editing layout.
kd.toggleClass('detailview_single',
() => this._isSingle || this.recordLayout.isEditingLayout()),
// Add a marker class that editor is active - used for hiding context menu toggle.
kd.toggleClass('detailview_layout_editor', this.recordLayout.isEditingLayout),
kd.maybe(this.recordLayout.isEditingLayout, () => {
const rowId = this.viewData.getRowId(this.recordLayout.editIndex.peek());
const record = this.getRenderedRowModel(rowId);

View File

@ -83,6 +83,21 @@
cursor: pointer;
}
/* Menu toggle on a row */
.gridview_data_row_num .menu_toggle {
visibility: hidden;
position: absolute;
top: 2px;
right: 0px;
}
/* Show on hover or when menu is opened */
.gridview_data_row_num:hover .menu_toggle,
.gridview_data_row_num .menu_toggle.weasel-popup-open {
visibility: visible;
}
@media print {
/* For printing, !important tag is needed for background colors to be respected; but normally,
* do not want !important, as it interferes with row selection.
@ -339,8 +354,8 @@
.g-column-main-menu {
position: absolute;
top: 0;
right: 0;
top: 3px;
right: 2px;
}

View File

@ -29,9 +29,13 @@ const {onDblClickMatchElem} = require('app/client/lib/dblclick');
const {Holder} = require('grainjs');
const {menu} = require('../ui2018/menus');
const {calcFieldsCondition} = require('../ui/GridViewMenus');
const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, RowContextMenu, freezeAction} = require('../ui/GridViewMenus');
const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, freezeAction} = require('../ui/GridViewMenus');
const {RowContextMenu} = require('../ui/RowContextMenu');
const {setPopupToCreateDom} = require('popweasel');
const {testId} = require('app/client/ui2018/cssVars');
const {menuToggle} = require('app/client/ui/MenuToggle');
// A threshold for interpreting a motionless click as a click rather than a drag.
// Anything longer than this time (in milliseconds) should be interpreted as a drag
@ -901,8 +905,9 @@ GridView.prototype.buildDom = function() {
kf.editableLabel(field.displayLabel, isEditingLabel, renameCommands),
dom.on('mousedown', ev => isEditingLabel() ? ev.stopPropagation() : true)
),
this.isPreview ? null : dom('div.g-column-main-menu.g-column-menu-btn.right-btn',
dom('span.glyphicon.glyphicon-triangle-bottom'),
this.isPreview ? null : menuToggle(null,
kd.cssClass('g-column-main-menu'),
kd.cssClass('g-column-menu-btn'),
// Prevent mousedown on the dropdown triangle from initiating column drag.
dom.on('mousedown', () => false),
// Select the column if it's not part of a multiselect.
@ -993,17 +998,26 @@ GridView.prototype.buildDom = function() {
);
}
}),
kd.toggleClass('selected', () =>
!row._isAddRow() && self.cellSelector.isRowSelected(row._index())),
dom.on('contextmenu', ev => self.maybeSelectRow(ev.currentTarget, row.getRowId())),
menu(ctl => RowContextMenu({
dom.on('contextmenu', ev => {
// This is a little hack to position the menu the same way as with a click,
// the same hack as on a column menu.
ev.preventDefault();
ev.currentTarget.querySelector('.menu_toggle').click();
}),
menuToggle(null,
dom.on('click', ev => self.maybeSelectRow(ev.currentTarget.parentNode, row.getRowId())),
menu(() => RowContextMenu({
disableInsert: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.tableModel.tableMetaRow.onDemand()),
disableDelete: Boolean(self.gristDoc.isReadonly.get() || self.viewSection.disableAddRemoveRows() || self.getSelection().onlyAddRowSelected()),
isViewSorted: self.viewSection.activeSortSpec.peek().length > 0,
}), { trigger: ['contextmenu'] }),
}), { trigger: ['click'] }),
// Prevent mousedown on the dropdown triangle from initiating row drag.
dom.on('mousedown', () => false),
testId('row-menu-trigger'),
),
kd.toggleClass('selected', () =>
!row._isAddRow() && self.cellSelector.isRowSelected(row._index())),
),
dom('div.record',
kd.toggleClass('record-add', row._isAddRow),
kd.style('borderLeftWidth', v.borderWidthPx),
@ -1379,6 +1393,8 @@ GridView.prototype._columnFilterMenu = function(ctl, field) {
};
GridView.prototype.maybeSelectColumn = function (elem, field) {
// Change focus before running command so that the correct viewsection's cursor is moved.
this.viewSection.hasFocus(true);
const selectedColIds = this.getSelection().colIds;
if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) {
return; // No need to select the column because it's included in the multi-selection
@ -1387,6 +1403,8 @@ GridView.prototype.maybeSelectColumn = function (elem, field) {
};
GridView.prototype.maybeSelectRow = function(elem, rowId) {
// Change focus before running command so that the correct viewsection's cursor is moved.
this.viewSection.hasFocus(true);
// If the clicked row was not already in the selection, move the selection to the row.
if (!this.getSelection().rowIds.includes(rowId)) {
this.assignCursor(elem, selector.ROW);

View File

@ -32,9 +32,12 @@ var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var {Delay} = require('../lib/Delay');
var kd = require('../lib/koDom');
var Layout = require('./Layout');
var RecordLayoutEditor = require('./RecordLayoutEditor');
var commands = require('./commands');
var {menuToggle} = require('app/client/ui/MenuToggle');
var {menu} = require('../ui2018/menus');
var {testId} = require('app/client/ui2018/cssVars');
/**
* Construct a RecordLayout.
@ -47,6 +50,7 @@ var RecordLayoutEditor = require('./RecordLayoutEditor');
function RecordLayout(options) {
this.viewSection = options.viewSection;
this.buildFieldDom = options.buildFieldDom;
this.buildContextMenu = options.buildContextMenu;
this.isEditingLayout = ko.observable(false);
this.editIndex = ko.observable(0);
this.layoutEditor = ko.observable(null); // RecordLayoutEditor when one is active.
@ -328,7 +332,23 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.layoutEditor.peek().dispose();
this.layoutEditor(null);
}) : null,
dom('div.detail_row_num', kd.text(() => (row._index() + 1))),
dom('div.detail_row_num',
kd.text(() => (row._index() + 1)),
dom.on('contextmenu', ev => {
// This is a little hack to position the menu the same way as with a click,
// the same hack as on a column menu.
ev.preventDefault();
ev.currentTarget.querySelector('.menu_toggle').click();
}),
menuToggle(null,
dom.on('click', () => {
this.viewSection.hasFocus(true);
commands.allCommands.setCursor.run(row);
}),
menu(() => this.buildContextMenu(row)),
testId('card-menu-trigger')
)
),
dom('div.g_record_detail_inner', layout.rootElem)
);
};

View File

@ -3,6 +3,10 @@
flex: 1 1 0px;
}
.viewsection_buttons {
margin-left: 4px;
}
.viewsection_title {
flex-shrink: 0;
align-items: baseline;

View File

@ -31,42 +31,6 @@ export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) {
}, `Show column ${col.label()}`))
];
}
interface IRowContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
}
export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: IRowContextMenu) {
const result: Element[] = [];
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
// below the active row. Thus in this case we show a single `insert row` command.
result.push(
menuItemCmd(allCommands.insertRecordAfter, 'Insert row',
dom.cls('disabled', disableInsert)),
);
} else {
result.push(
menuItemCmd(allCommands.insertRecordBefore, 'Insert row above',
dom.cls('disabled', disableInsert)),
menuItemCmd(allCommands.insertRecordAfter, 'Insert row below',
dom.cls('disabled', disableInsert)),
);
}
result.push(
menuDivider(),
menuItemCmd(allCommands.deleteRecords, 'Delete',
dom.cls('disabled', disableDelete)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.copyLink, 'Copy anchor link'));
return result;
}
interface IMultiColumnContextMenu {
// For multiple selection, true/false means the value applies to all columns, 'mixed' means it's
// true for some columns, but not all.

View File

@ -0,0 +1,34 @@
import { dom, DomArg, IDisposableOwner, styled } from "grainjs";
import { icon } from "app/client/ui2018/icons";
import { colors } from "app/client/ui2018/cssVars";
/**
* Creates a toggle button - little square button with a dropdown icon inside, used
* by a context menu for a row inside a grid, a card inside a cardlist and column name.
*/
export function menuToggle(obs: IDisposableOwner, ...args: DomArg[]) {
const contextMenu = cssMenuToggle(
icon('Dropdown', dom.cls('menu_toggle_icon')),
...args
);
return contextMenu;
}
const cssMenuToggle = styled('div.menu_toggle', `
background: white;
cursor: pointer;
--icon-color: ${colors.slate};
border: 1px solid ${colors.slate};
border-radius: 4px;
&:hover {
--icon-color: ${colors.darkGreen};
border-color: ${colors.darkGreen};
}
&:active {
--icon-color: ${colors.darkerGreen};
border-color: ${colors.darkerGreen};
}
& > .menu_toggle_icon {
display: block; /* don't create a line */
}
`);

View File

@ -0,0 +1,38 @@
import { allCommands } from 'app/client/components/commands';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { dom } from 'grainjs';
interface IRowContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
}
export function RowContextMenu({ disableInsert, disableDelete, isViewSorted }: IRowContextMenu) {
const result: Element[] = [];
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
// below the active row. Thus in this case we show a single `insert row` command.
result.push(
menuItemCmd(allCommands.insertRecordAfter, 'Insert row',
dom.cls('disabled', disableInsert)),
);
} else {
result.push(
menuItemCmd(allCommands.insertRecordBefore, 'Insert row above',
dom.cls('disabled', disableInsert)),
menuItemCmd(allCommands.insertRecordAfter, 'Insert row below',
dom.cls('disabled', disableInsert)),
);
}
result.push(
menuDivider(),
menuItemCmd(allCommands.deleteRecords, 'Delete',
dom.cls('disabled', disableDelete)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.copyLink, 'Copy anchor link'));
return result;
}

View File

@ -8,8 +8,30 @@ import {dom} from 'grainjs';
* Returns a list of menu items for a view section.
*/
export function makeViewLayoutMenu(viewModel: ViewRec, viewSection: ViewSectionRec, isReadonly: boolean) {
const gristDoc = viewSection.viewInstance.peek()!.gristDoc;
const viewInstance = viewSection.viewInstance.peek()!;
const gristDoc = viewInstance.gristDoc;
// get current row index from cursor
const cursorRow = viewInstance.cursor.rowIndex.peek();
// get row id from current data
// rowId can be string - it is wrongly typed in cursor and in viewData
const rowId = (cursorRow !== null ? viewInstance.viewData.getRowId(cursorRow) : null) as string|null|number;
const isAddRow = rowId === 'new';
const contextMenu = [
menuItemCmd(allCommands.deleteRecords,
'Delete record',
testId('section-delete-card'),
dom.cls('disabled', isReadonly || isAddRow)),
menuItemCmd(allCommands.copyLink,
'Copy anchor link',
testId('section-card-link'),
),
menuDivider(),
];
return [
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
menuItemCmd(allCommands.printSection, 'Print widget', testId('print-section')),
menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
'Download as CSV', testId('download-section')),