mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
01cef034ed
commit
e180641c7d
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
@ -3,6 +3,10 @@
|
||||
flex: 1 1 0px;
|
||||
}
|
||||
|
||||
.viewsection_buttons {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.viewsection_title {
|
||||
flex-shrink: 0;
|
||||
align-items: baseline;
|
||||
|
@ -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.
|
||||
|
34
app/client/ui/MenuToggle.ts
Normal file
34
app/client/ui/MenuToggle.ts
Normal 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 */
|
||||
}
|
||||
`);
|
38
app/client/ui/RowContextMenu.ts
Normal file
38
app/client/ui/RowContextMenu.ts
Normal 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;
|
||||
}
|
@ -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')),
|
||||
|
Loading…
Reference in New Issue
Block a user