Summary: New shortcuts for removing and adding rows. For adding a row we now have Mod+(Shift)+Enter For removing rows we now have Mod+Delete/Mod+Backspace Before removing rows, the user is prompted to confirm, this prompt can be dismissed and this setting can be remembered. User needs to confirm only when using shortcut. Old shortcuts are still active and shows information about this change. This information is shown only once, after this shortcuts have default behavior (zooming). New users don't see this explanation. Test Plan: Updated Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3655pull/323/head
parent
0a8ce2178a
commit
6460c22a89
@ -0,0 +1,118 @@
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {Command} from 'app/client/components/commands';
|
||||
import {markAsSeen} from 'app/client/models/UserPrefs';
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {reportMessage} from 'app/client/models/errors';
|
||||
import {DeprecationWarning} from 'app/common/Prefs';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {showDeprecatedWarning} from 'app/client/components/modals';
|
||||
import {Disposable, dom, Holder, styled} from 'grainjs';
|
||||
import intersection from "lodash/intersection";
|
||||
|
||||
const G = getBrowserGlobals('document', 'window');
|
||||
|
||||
/**
|
||||
* Manages deprecated commands and keyboard shortcuts. It subscribes itself to all commands and
|
||||
* keyboard shortcuts, and shows a warning when a deprecated command is used.
|
||||
*/
|
||||
export class DeprecatedCommands extends Disposable {
|
||||
// Holds the commands created by this class, so they can be disposed,
|
||||
// when this class is disposed or reattached.
|
||||
private _holder = Holder.create(this);
|
||||
|
||||
constructor(private _gristDoc: GristDoc) {
|
||||
super();
|
||||
G.window.resetSeenWarnings = () => {};
|
||||
}
|
||||
|
||||
public attach() {
|
||||
// We can be attached multiple times, so first clear previous commands.
|
||||
this._holder.clear();
|
||||
|
||||
// Get all the warnings from the app model and expose reset function (used in tests only).
|
||||
// When we reset the warnings, we also need to reattach ourselves.
|
||||
const seenWarnings = this._gristDoc.docPageModel.appModel.deprecatedWarnings;
|
||||
G.window.resetSeenWarnings = () => {
|
||||
if (!this._gristDoc.isDisposed()) {
|
||||
seenWarnings.set([]);
|
||||
this.attach();
|
||||
}
|
||||
};
|
||||
|
||||
// We wan't do anything for anonymous users.
|
||||
if (!this._gristDoc.docPageModel.appModel.currentValidUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If user has seen all keyboard warnings, don't need to do anything.
|
||||
const commandList = Object.values(commands.allCommands as Record<string, Command>);
|
||||
const deprecatedCommands = commandList.filter((command) => command.deprecated);
|
||||
const deprecatedNames = deprecatedCommands.map((command) => command.name);
|
||||
if (intersection(seenWarnings.get(), deprecatedNames).length === deprecatedNames.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Now subscribe to all the commands and handle them.
|
||||
const group: any = {};
|
||||
for (const c of deprecatedCommands) {
|
||||
group[c.name] = this._handleCommand.bind(this, c);
|
||||
}
|
||||
if (Object.keys(group).length) {
|
||||
this._holder.autoDispose(commands.createGroup(group, this, true));
|
||||
}
|
||||
}
|
||||
|
||||
private _handleCommand(c: Command) {
|
||||
const seenWarnings = this._gristDoc.docPageModel.appModel.deprecatedWarnings;
|
||||
if (!this._hasSeenWarning(c.name)) {
|
||||
markAsSeen(seenWarnings, c.name);
|
||||
this._showWarning(c.desc);
|
||||
return false; // Stop processing.
|
||||
} else {
|
||||
return true; // Continue processing.
|
||||
}
|
||||
}
|
||||
|
||||
private _showWarning(desc: string) {
|
||||
// Try to figure out where to show the message. If we have active view, we can try
|
||||
// to find the selected cell and show the message there. Otherwise, we show it in the
|
||||
// bottom right corner as a warning.
|
||||
const selectedCell = this._gristDoc.currentView.get()?.viewPane.querySelector(".selected_cursor");
|
||||
if (!selectedCell) {
|
||||
reportMessage(() => dom('div', this._createMessage(desc)), {
|
||||
level: 'info',
|
||||
key: 'deprecated-command',
|
||||
});
|
||||
} else {
|
||||
showDeprecatedWarning(selectedCell, this._createMessage(desc));
|
||||
}
|
||||
}
|
||||
|
||||
private _hasSeenWarning(name: string) {
|
||||
const seenWarnings = this._gristDoc.docPageModel.appModel.deprecatedWarnings;
|
||||
const preference = seenWarnings.get() ?? [];
|
||||
return preference.includes(DeprecationWarning.check(name));
|
||||
}
|
||||
|
||||
private _createMessage(description: string) {
|
||||
const elements: Node[] = [];
|
||||
// Description can have embedded commands in the form of {commandName}.
|
||||
// To construct message we need to replace all {name} to key strokes dom.
|
||||
for (const part of description.split(/({\w+})/g)) {
|
||||
// If it starts with {
|
||||
if (part[0] === '{') {
|
||||
const otherCommand = commands.allCommands[part.slice(1, -1)];
|
||||
if (otherCommand) {
|
||||
elements.push(otherCommand.getKeysDom());
|
||||
}
|
||||
} else {
|
||||
elements.push(cssTallerText(part));
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
}
|
||||
|
||||
const cssTallerText = styled('span', `
|
||||
line-height: 24px;
|
||||
`);
|
@ -0,0 +1,141 @@
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import {reportSuccess} from 'app/client/models/errors';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {testId, theme} from 'app/client/ui2018/cssVars';
|
||||
import {modalTooltip} from 'app/client/ui2018/modals';
|
||||
import {dom, DomContents, observable, styled} from 'grainjs';
|
||||
|
||||
/**
|
||||
* This is a file for all custom and pre-configured popups, modals, toasts and tooltips, used
|
||||
* in more then one component.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tooltip or popup to confirm row deletion.
|
||||
*/
|
||||
export function buildConfirmDelete(
|
||||
refElement: Element,
|
||||
onSave: (remember: boolean) => void,
|
||||
single = true,
|
||||
) {
|
||||
const remember = observable(false);
|
||||
const tooltip = modalTooltip(refElement, (ctl) =>
|
||||
cssContainer(
|
||||
dom.autoDispose(remember),
|
||||
testId('confirm-deleteRows'),
|
||||
testId('confirm-popup'),
|
||||
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
|
||||
dom.onKeyDown({
|
||||
Escape: () => ctl.close(),
|
||||
Enter: () => { onSave(remember.get()); ctl.close(); },
|
||||
}),
|
||||
dom('div', `Are you sure you want to delete ${single ? 'this' : 'these'} record${single ? '' : 's'}?`,
|
||||
dom.style('margin-bottom', '10px'),
|
||||
),
|
||||
cssButtons(
|
||||
dom.style('margin-bottom', '12px'),
|
||||
primaryButton('Delete', testId('confirm-save'), dom.on('click', () => {
|
||||
onSave(remember.get());
|
||||
ctl.close();
|
||||
})),
|
||||
basicButton('Cancel', testId('confirm-cancel'), dom.on('click', () => ctl.close()))
|
||||
),
|
||||
dom('div',
|
||||
labeledSquareCheckbox(remember, "Don't ask again.", testId('confirm-remember')),
|
||||
)
|
||||
), {}
|
||||
);
|
||||
// Attach this tooltip to a cell so that it is automatically closed when the cell is disposed.
|
||||
// or scrolled out of view (and then disposed).
|
||||
dom.onDisposeElem(refElement, () => {
|
||||
if (!tooltip.isDisposed()) {
|
||||
tooltip.close();
|
||||
}
|
||||
});
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
export function showDeprecatedWarning(
|
||||
refElement: Element,
|
||||
content: DomContents
|
||||
) {
|
||||
const tooltip = modalTooltip(refElement, (ctl) =>
|
||||
cssWideContainer(
|
||||
testId('popup-warning-deprecated'),
|
||||
elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); },
|
||||
dom.onKeyDown({
|
||||
Escape: () => ctl.close(),
|
||||
Enter: () => ctl.close(),
|
||||
}),
|
||||
content,
|
||||
cssButtons(
|
||||
dom.style('margin-top', '12px'),
|
||||
dom.style('justify-content', 'right'),
|
||||
basicButton('Close', testId('confirm-cancel'), dom.on('click', () => ctl.close()))
|
||||
),
|
||||
)
|
||||
);
|
||||
// Attach this warning to a cell so that it is automatically closed when the cell is disposed.
|
||||
// or scrolled out of view (and then disposed).
|
||||
dom.onDisposeElem(refElement, () => {
|
||||
if (!tooltip.isDisposed()) {
|
||||
tooltip.close();
|
||||
}
|
||||
});
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows notification with a single button 'Undo' delete.
|
||||
*/
|
||||
export function reportUndo(
|
||||
doc: GristDoc,
|
||||
messageLabel: string,
|
||||
buttonLabel = 'Undo to restore'
|
||||
) {
|
||||
// First create a notification with a button to undo the delete.
|
||||
let notification = reportSuccess(messageLabel, {
|
||||
key: 'undo',
|
||||
actions: [{
|
||||
label: buttonLabel,
|
||||
action: () => {
|
||||
// When user clicks on the button, undo the last action.
|
||||
commands.allCommands.undo.run();
|
||||
// And remove this notification.
|
||||
close();
|
||||
},
|
||||
}]
|
||||
});
|
||||
|
||||
// When we received some actions from the server, cancel this popup,
|
||||
// as the undo might do something else.
|
||||
doc.on('onDocUserAction', close);
|
||||
notification?.onDispose(() => doc.off('onDocUserAction', close));
|
||||
|
||||
function close() {
|
||||
if (notification && !notification?.isDisposed()) {
|
||||
notification.dispose();
|
||||
notification = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cssTheme = styled('div', `
|
||||
color: ${theme.text};
|
||||
`);
|
||||
|
||||
const cssButtons = styled('div', `
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
`);
|
||||
|
||||
const cssContainer = styled(cssTheme, `
|
||||
max-width: 210px;
|
||||
`);
|
||||
|
||||
const cssWideContainer = styled(cssTheme, `
|
||||
max-width: 340px;
|
||||
`);
|
Loading…
Reference in new issue