Summary: A new way for renaming tables. - There is a new popup to rename section (where you can also rename the table) - Renaming/Deleting page doesn't modify/delete the table. - Renaming table can rename a page if the names match (and the page contains a section with that table). - User can rename table in Raw Data UI in two ways - either on the listing or by using the section name popup - As before, there is no way to change tableId - it is derived from a table name. - When the section name is empty the table name is shown instead. - White space for section name is allowed (to discuss) - so the user can just paste ' '. - Empty name for a page is not allowed (but white space is). - Some bugs related to deleting tables with attached summary tables (and with undoing this operation) were fixed (but not all of them yet). Test Plan: Updated tests. Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: georgegevoian Differential Revision: https://phab.getgrist.com/D3360pull/203/head
parent
8a1cca629b
commit
6f00106d7c
@ -1,71 +0,0 @@
|
|||||||
var _ = require('underscore');
|
|
||||||
var ko = require('knockout');
|
|
||||||
var BackboneEvents = require('backbone').Events;
|
|
||||||
|
|
||||||
var dispose = require('../lib/dispose');
|
|
||||||
var dom = require('../lib/dom');
|
|
||||||
var kd = require('../lib/koDom');
|
|
||||||
|
|
||||||
// Rather than require the whole of highlight.js, require just the core with the one language we
|
|
||||||
// need, to keep our bundle smaller and the build faster.
|
|
||||||
var hljs = require('highlight.js/lib/highlight');
|
|
||||||
hljs.registerLanguage('python', require('highlight.js/lib/languages/python'));
|
|
||||||
|
|
||||||
function CodeEditorPanel(gristDoc) {
|
|
||||||
this._gristDoc = gristDoc;
|
|
||||||
this._schema = ko.observable('');
|
|
||||||
this._denied = ko.observable(false);
|
|
||||||
|
|
||||||
this.listenTo(this._gristDoc, 'schemaUpdateAction', this.onSchemaAction);
|
|
||||||
this.onSchemaAction(); // Fetch the schema to initialize
|
|
||||||
}
|
|
||||||
dispose.makeDisposable(CodeEditorPanel);
|
|
||||||
_.extend(CodeEditorPanel.prototype, BackboneEvents);
|
|
||||||
|
|
||||||
CodeEditorPanel.prototype.buildDom = function() {
|
|
||||||
// The tabIndex enables the element to gain focus, and the .clipboard class prevents the
|
|
||||||
// Clipboard module from re-grabbing it. This is a quick fix for the issue where clipboard
|
|
||||||
// interferes with text selection. TODO it should be possible for the Clipboard to never
|
|
||||||
// interfere with text selection even for un-focusable elements.
|
|
||||||
return dom('div.g-code-panel.clipboard',
|
|
||||||
{tabIndex: "-1"},
|
|
||||||
kd.maybe(this._denied, () => dom('div.g-code-panel-denied',
|
|
||||||
dom('h2', kd.text('Access denied')),
|
|
||||||
dom('div', kd.text('Code View is available only when you have full document access.')),
|
|
||||||
)),
|
|
||||||
kd.scope(this._schema, function(schema) {
|
|
||||||
// The reason to scope and rebuild instead of using `kd.text(schema)` is because
|
|
||||||
// hljs.highlightBlock(elem) replaces `elem` with a whole new dom tree.
|
|
||||||
if (!schema) { return null; }
|
|
||||||
return dom(
|
|
||||||
'code.g-code-viewer.python',
|
|
||||||
schema,
|
|
||||||
dom.hide,
|
|
||||||
dom.defer(function(elem) {
|
|
||||||
hljs.highlightBlock(elem);
|
|
||||||
dom.show(elem);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CodeEditorPanel.prototype.onSchemaAction = async function(actions) {
|
|
||||||
try {
|
|
||||||
const schema = await this._gristDoc.docComm.fetchTableSchema();
|
|
||||||
if (!this.isDisposed()) {
|
|
||||||
this._schema(schema);
|
|
||||||
this._denied(false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!String(err).match(/Cannot view code/)) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (!this.isDisposed()) {
|
|
||||||
this._schema('');
|
|
||||||
this._denied(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = CodeEditorPanel;
|
|
@ -0,0 +1,64 @@
|
|||||||
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
|
import {reportError} from 'app/client/models/errors';
|
||||||
|
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||||
|
import {dom, Observable} from 'grainjs';
|
||||||
|
|
||||||
|
// Rather than require the whole of highlight.js, require just the core with the one language we
|
||||||
|
// need, to keep our bundle smaller and the build faster.
|
||||||
|
const hljs = require('highlight.js/lib/highlight');
|
||||||
|
hljs.registerLanguage('python', require('highlight.js/lib/languages/python'));
|
||||||
|
|
||||||
|
export class CodeEditorPanel extends DisposableWithEvents {
|
||||||
|
private _schema = Observable.create(this, '');
|
||||||
|
private _denied = Observable.create(this, false);
|
||||||
|
constructor(private _gristDoc: GristDoc) {
|
||||||
|
super();
|
||||||
|
this.listenTo(_gristDoc, 'schemaUpdateAction', this._onSchemaAction.bind(this));
|
||||||
|
this._onSchemaAction().catch(reportError); // Fetch the schema to initialize
|
||||||
|
}
|
||||||
|
|
||||||
|
public buildDom() {
|
||||||
|
// The tabIndex enables the element to gain focus, and the .clipboard class prevents the
|
||||||
|
// Clipboard module from re-grabbing it. This is a quick fix for the issue where clipboard
|
||||||
|
// interferes with text selection. TODO it should be possible for the Clipboard to never
|
||||||
|
// interfere with text selection even for un-focusable elements.
|
||||||
|
return dom('div.g-code-panel.clipboard',
|
||||||
|
{tabIndex: "-1"},
|
||||||
|
dom.maybe(this._denied, () => dom('div.g-code-panel-denied',
|
||||||
|
dom('h2', dom.text('Access denied')),
|
||||||
|
dom('div', dom.text('Code View is available only when you have full document access.')),
|
||||||
|
)),
|
||||||
|
dom.maybe(this._schema, (schema) => {
|
||||||
|
// The reason to scope and rebuild instead of using `kd.text(schema)` is because
|
||||||
|
// hljs.highlightBlock(elem) replaces `elem` with a whole new dom tree.
|
||||||
|
const elem = dom('code.g-code-viewer',
|
||||||
|
dom.text(schema),
|
||||||
|
dom.hide(true)
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
hljs.highlightBlock(elem);
|
||||||
|
dom.showElem(elem, true);
|
||||||
|
});
|
||||||
|
return elem;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onSchemaAction() {
|
||||||
|
try {
|
||||||
|
const schema = await this._gristDoc.docComm.fetchTableSchema();
|
||||||
|
if (!this.isDisposed()) {
|
||||||
|
this._schema.set(schema);
|
||||||
|
this._denied.set(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!String(err).match(/Cannot view code/)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!this.isDisposed()) {
|
||||||
|
this._schema.set('');
|
||||||
|
this._denied.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,245 @@
|
|||||||
|
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||||
|
import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
||||||
|
import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||||
|
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||||
|
import {cssTextInput} from 'app/client/ui2018/editableLabel';
|
||||||
|
import {menuCssClass} from 'app/client/ui2018/menus';
|
||||||
|
import {ModalControl} from 'app/client/ui2018/modals';
|
||||||
|
import {Computed, dom, DomElementArg, IInputOptions, input, makeTestId, Observable, styled} from 'grainjs';
|
||||||
|
import {IOpenController, setPopupToCreateDom} from 'popweasel';
|
||||||
|
|
||||||
|
const testId = makeTestId('test-widget-title-');
|
||||||
|
|
||||||
|
interface WidgetTitleOptions {
|
||||||
|
tableNameHidden?: boolean,
|
||||||
|
widgetNameHidden?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) {
|
||||||
|
const title = Computed.create(null, use => use(vs.titleDef));
|
||||||
|
return buildRenameWidget(vs, title, options, dom.autoDispose(title), ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTableName(vs: ViewSectionRec, ...args: DomElementArg[]) {
|
||||||
|
const title = Computed.create(null, use => use(use(vs.table).tableNameDef));
|
||||||
|
return buildRenameWidget(vs, title, { widgetNameHidden: true }, dom.autoDispose(title), ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRenameWidget(
|
||||||
|
vs: ViewSectionRec,
|
||||||
|
title: Observable<string>,
|
||||||
|
options: WidgetTitleOptions,
|
||||||
|
...args: DomElementArg[]) {
|
||||||
|
return cssTitleContainer(
|
||||||
|
cssTitle(
|
||||||
|
testId('text'),
|
||||||
|
dom.text(title),
|
||||||
|
// In case titleDef is all blank space, make it visible on hover.
|
||||||
|
cssTitle.cls("-empty", use => !use(title)?.trim()),
|
||||||
|
elem => {
|
||||||
|
setPopupToCreateDom(elem, ctl => buildWidgetRenamePopup(ctl, vs, options), {
|
||||||
|
placement: 'bottom-start',
|
||||||
|
trigger: ['click'],
|
||||||
|
attach: 'body',
|
||||||
|
boundaries: 'viewport',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
...args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, options: WidgetTitleOptions) {
|
||||||
|
const tableRec = vs.table.peek();
|
||||||
|
// If the table is a summary table.
|
||||||
|
const isSummary = Boolean(tableRec.summarySourceTable.peek());
|
||||||
|
// Table name, for summary table it contains also a grouping description, but it is not editable.
|
||||||
|
// Example: Table1 or Table1 [by B, C]
|
||||||
|
const tableName = [tableRec.tableNameDef.peek(), tableRec.groupDesc.peek()]
|
||||||
|
.filter(p => Boolean(p?.trim())).join(' ');
|
||||||
|
// User input for table name.
|
||||||
|
const inputTableName = Observable.create(ctrl, tableName);
|
||||||
|
// User input for widget title.
|
||||||
|
const inputWidgetTitle = Observable.create(ctrl, vs.title.peek());
|
||||||
|
// Placeholder for widget title:
|
||||||
|
// - when widget title is empty shows a default widget title (what would be shown when title is empty)
|
||||||
|
// - when widget title is set, shows just a text to override it.
|
||||||
|
const inputWidgetPlaceholder = !vs.title.peek() ? 'Override widget title' : vs.defaultWidgetTitle.peek();
|
||||||
|
|
||||||
|
const disableSave = Computed.create(ctrl, (use) =>
|
||||||
|
(use(inputTableName) === tableName || use(inputTableName).trim() === '') &&
|
||||||
|
use(inputWidgetTitle) === vs.title.peek()
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalCtl = ModalControl.create(ctrl, () => ctrl.close());
|
||||||
|
|
||||||
|
const saveTableName = async () => {
|
||||||
|
// For summary table ignore - though we could rename primary table.
|
||||||
|
if (isSummary) { return; }
|
||||||
|
// Can't save an empty name - there are actually no good reasons why we can't have empty table name,
|
||||||
|
// unfortunately there are some use cases that really on the empty name:
|
||||||
|
// - For ACL we sometimes may check if tableId is empty (and sometimes if table name).
|
||||||
|
// - Pages with empty name are not visible by default (and pages are renamed with a table - if their name match).
|
||||||
|
if (!inputTableName.get().trim()) { return; }
|
||||||
|
// If value was changed.
|
||||||
|
if (inputTableName.get() !== tableRec.tableNameDef.peek()) {
|
||||||
|
await tableRec.tableNameDef.saveOnly(inputTableName.get());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveWidgetTitle = async () => {
|
||||||
|
// If value was changed.
|
||||||
|
if (inputWidgetTitle.get() !== vs.title.peek()) {
|
||||||
|
await vs.title.saveOnly(inputWidgetTitle.get());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const doSave = modalCtl.doWork(() => Promise.all([
|
||||||
|
saveTableName(),
|
||||||
|
saveWidgetTitle()
|
||||||
|
]), {close: true});
|
||||||
|
|
||||||
|
function initialFocus() {
|
||||||
|
// Set focus on a thing user is likely to change.
|
||||||
|
// Initial focus is set on tableName unless:
|
||||||
|
// - if this is a summary table - as it is not editable,
|
||||||
|
// - if widgetTitle is not empty - so user wants to change it further,
|
||||||
|
// - if widgetTitle is empty but the default widget name will have type suffix (like Table1 (Card)), so it won't
|
||||||
|
// be a table name - so user doesn't want the default value.
|
||||||
|
if (
|
||||||
|
!widgetInput ||
|
||||||
|
isSummary ||
|
||||||
|
vs.title.peek() ||
|
||||||
|
(
|
||||||
|
!vs.title.peek() &&
|
||||||
|
vs.defaultWidgetTitle.peek().toUpperCase() !== tableRec.tableName.peek().toUpperCase()
|
||||||
|
)) {
|
||||||
|
widgetInput?.focus();
|
||||||
|
} else if (!isSummary) {
|
||||||
|
tableInput?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build actual dom that looks like:
|
||||||
|
// DATA TABLE NAME
|
||||||
|
// [input]
|
||||||
|
// WIDGET TITLE
|
||||||
|
// [input]
|
||||||
|
// [Save] [Cancel]
|
||||||
|
let tableInput: HTMLInputElement|undefined;
|
||||||
|
let widgetInput: HTMLInputElement|undefined;
|
||||||
|
return cssRenamePopup(
|
||||||
|
// Create a FocusLayer to keep focus in this popup while it's active, and prevent keyboard
|
||||||
|
// shortcuts from being seen by the view underneath.
|
||||||
|
elem => { FocusLayer.create(ctrl, {defaultFocusElem: elem, pauseMousetrap: true}); },
|
||||||
|
testId('popup'),
|
||||||
|
dom.cls(menuCssClass),
|
||||||
|
dom.maybe(!options.tableNameHidden, () => [
|
||||||
|
cssLabel('DATA TABLE NAME'),
|
||||||
|
// Update tableName on key stroke - this will show the default widget name as we type.
|
||||||
|
// above this modal.
|
||||||
|
tableInput = cssInput(
|
||||||
|
inputTableName,
|
||||||
|
updateOnKey,
|
||||||
|
{disabled: isSummary, placeholder: 'Provide a table name'},
|
||||||
|
testId('table-name-input')
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
dom.maybe(!options.widgetNameHidden, () => [
|
||||||
|
cssLabel('WIDGET TITLE'),
|
||||||
|
widgetInput = cssInput(inputWidgetTitle, updateOnKey, {placeholder: inputWidgetPlaceholder},
|
||||||
|
testId('section-name-input')
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
cssButtons(
|
||||||
|
primaryButton('Save',
|
||||||
|
dom.on('click', doSave),
|
||||||
|
dom.boolAttr('disabled', use => use(disableSave) || use(modalCtl.workInProgress)),
|
||||||
|
testId('save'),
|
||||||
|
),
|
||||||
|
basicButton('Cancel',
|
||||||
|
testId('cancel'),
|
||||||
|
dom.on('click', () => modalCtl.close())
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dom.onKeyDown({
|
||||||
|
Escape: () => modalCtl.close(),
|
||||||
|
// On enter save or cancel - depending on the change.
|
||||||
|
Enter: () => disableSave.get() ? modalCtl.close() : doSave(),
|
||||||
|
}),
|
||||||
|
elem => { setTimeout(initialFocus, 0); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOnKey = {onInput: true};
|
||||||
|
|
||||||
|
// Leave class for tests.
|
||||||
|
const cssTitleContainer = styled('div', `
|
||||||
|
flex: 1 1 0px;
|
||||||
|
min-width: 0px;
|
||||||
|
display: flex;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssTitle = styled('div', `
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: -4px;
|
||||||
|
padding: 4px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
align-self: start;
|
||||||
|
&:hover {
|
||||||
|
background-color: ${colors.mediumGrey};
|
||||||
|
}
|
||||||
|
&-empty {
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 23px;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssRenamePopup = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 280px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssLabel = styled('label', `
|
||||||
|
font-size: ${vars.xsmallFontSize};
|
||||||
|
font-weight: ${vars.bigControlTextWeight};
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssButtons = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
margin-top: 16px;
|
||||||
|
& > .${cssButton.className}:not(:first-child) {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssInputWithIcon = styled('div', `
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cssInput = styled((
|
||||||
|
obs: Observable<string>,
|
||||||
|
opts: IInputOptions,
|
||||||
|
...args) => input(obs, opts, cssTextInput.cls(''), ...args), `
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
&:disabled {
|
||||||
|
color: ${colors.slate};
|
||||||
|
background-color: ${colors.lightGrey};
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.${cssInputWithIcon.className} > &:disabled {
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
`);
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 438 B |
Loading…
Reference in new issue