mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Raw renames
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/D3360
This commit is contained in:
parent
8a1cca629b
commit
6f00106d7c
@ -62,7 +62,7 @@ function BaseView(gristDoc, viewSectionModel, options) {
|
||||
if (this.viewSection.table().summarySourceTable()) {
|
||||
const groupGetter = this.tableModel.tableData.getRowPropFunc('group');
|
||||
this._mainRowSource = rowset.BaseFilteredRowSource.create(this,
|
||||
rowId => !gristTypes.isEmptyList(groupGetter(rowId)));
|
||||
rowId => !groupGetter || !gristTypes.isEmptyList(groupGetter(rowId)));
|
||||
this._mainRowSource.subscribeTo(this._queryRowSource);
|
||||
} else {
|
||||
this._mainRowSource = this._queryRowSource;
|
||||
|
@ -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;
|
64
app/client/components/CodeEditorPanel.ts
Normal file
64
app/client/components/CodeEditorPanel.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -48,6 +48,10 @@ export class CursorMonitor extends Disposable {
|
||||
this._whenCursorHasChangedStoreInMemory(doc);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this._store.clear(this._key);
|
||||
}
|
||||
|
||||
private _whenCursorHasChangedStoreInMemory(doc: GristDoc) {
|
||||
// whenever current position changes, store it in the memory
|
||||
this.autoDispose(doc.cursorPosition.addListener(pos => {
|
||||
@ -62,8 +66,7 @@ export class CursorMonitor extends Disposable {
|
||||
private _whenDocumentLoadsRestorePosition(doc: GristDoc) {
|
||||
// if doc was opened with a hash link, don't restore last position
|
||||
if (doc.hasCustomNav.get()) {
|
||||
this._restored = true;
|
||||
return;
|
||||
return this._abortRestore();
|
||||
}
|
||||
|
||||
// if we are on raw data view, we need to set the position manually
|
||||
@ -85,7 +88,9 @@ export class CursorMonitor extends Disposable {
|
||||
// set that we already restored the position, as some view is shown to the user
|
||||
this._restored = true;
|
||||
const viewId = doc.activeViewId.get();
|
||||
if (!isViewDocPage(viewId)) { return; }
|
||||
if (!isViewDocPage(viewId)) {
|
||||
return this._abortRestore();
|
||||
}
|
||||
const position = this._readPosition(viewId);
|
||||
if (position) {
|
||||
// Ignore error with finding desired cell.
|
||||
@ -93,6 +98,11 @@ export class CursorMonitor extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
private _abortRestore() {
|
||||
this.clear();
|
||||
this._restored = true;
|
||||
}
|
||||
|
||||
private _storePosition(pos: ViewCursorPos) {
|
||||
this._store.update(this._key, pos);
|
||||
}
|
||||
|
@ -5,27 +5,32 @@ import {setTestState} from 'app/client/lib/testState';
|
||||
import {TableRec} from 'app/client/models/DocModel';
|
||||
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
|
||||
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||
import {buildTableName} from 'app/client/ui/WidgetTitle';
|
||||
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import * as css from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
|
||||
import {confirmModal} from 'app/client/ui2018/modals';
|
||||
import {Disposable, dom, fromKo, makeTestId, MultiHolder, styled} from 'grainjs';
|
||||
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-raw-data-');
|
||||
|
||||
export class DataTables extends Disposable {
|
||||
private _tables: Observable<TableRec[]>;
|
||||
private _view: Observable<string | null>;
|
||||
constructor(private _gristDoc: GristDoc) {
|
||||
super();
|
||||
// Remove tables that we don't have access to. ACL will remove tableId from those tables.
|
||||
this._tables = Computed.create(this, use =>
|
||||
use(_gristDoc.docModel.rawTables.getObservable())
|
||||
.filter(t => Boolean(use(t.tableId))));
|
||||
// Get the user id, to remember selected layout on the next visit.
|
||||
const userId = this._gristDoc.app.topAppModel.appObs.get()?.currentUser?.id ?? 0;
|
||||
this._view = this.autoDispose(localStorageObs(`u=${userId}:raw:viewType`, "list"));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const holder = new MultiHolder();
|
||||
// Get the user id, to remember selected layout on the next visit.
|
||||
const userId = this._gristDoc.app.topAppModel.appObs.get()?.currentUser?.id ?? 0;
|
||||
const view = holder.autoDispose(localStorageObs(`u=${userId}:raw:viewType`, "list"));
|
||||
return container(
|
||||
dom.autoDispose(holder),
|
||||
cssTableList(
|
||||
/*************** List section **********/
|
||||
testId('list'),
|
||||
@ -33,7 +38,7 @@ export class DataTables extends Disposable {
|
||||
docListHeader('Raw data tables'),
|
||||
cssSwitch(
|
||||
buttonSelect<any>(
|
||||
view,
|
||||
this._view,
|
||||
[
|
||||
{value: 'card', icon: 'TypeTable'},
|
||||
{value: 'list', icon: 'TypeCardList'},
|
||||
@ -44,49 +49,65 @@ export class DataTables extends Disposable {
|
||||
)
|
||||
),
|
||||
cssList(
|
||||
cssList.cls(use => `-${use(view)}`),
|
||||
dom.forEach(fromKo(this._gristDoc.docModel.allTables.getObservable()), tableRec =>
|
||||
cssList.cls(use => `-${use(this._view)}`),
|
||||
dom.forEach(this._tables, tableRec =>
|
||||
cssItem(
|
||||
testId('table'),
|
||||
cssItemContent(
|
||||
cssIcon('TypeTable',
|
||||
// Element to click in tests.
|
||||
dom.domComputed(use => `table-id-${use(tableRec.tableId)}`)
|
||||
),
|
||||
cssLabels(
|
||||
cssTitleLine(
|
||||
cssLine(
|
||||
dom.text(use2 => use2(use2(tableRec.rawViewSection).title) || use2(tableRec.tableId)),
|
||||
testId('table-title'),
|
||||
)
|
||||
),
|
||||
cssIdLine(
|
||||
cssIdLineContent(
|
||||
cssUpperCase("Table id: "),
|
||||
cssTableId(
|
||||
testId('table-id'),
|
||||
dom.text(tableRec.tableId),
|
||||
),
|
||||
{ title : 'Click to copy' },
|
||||
dom.on('click', async (e, t) => {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
showTransientTooltip(t, 'Table id copied to clipboard', {
|
||||
key: 'copy-table-id'
|
||||
});
|
||||
await copyToClipboard(tableRec.tableId.peek());
|
||||
setTestState({clipboard: tableRec.tableId.peek()});
|
||||
})
|
||||
)
|
||||
),
|
||||
cssLeft(
|
||||
dom.domComputed(tableRec.tableId, (tableId) =>
|
||||
cssGreenIcon(
|
||||
'TypeTable',
|
||||
testId(`table-id-${tableId}`)
|
||||
)
|
||||
),
|
||||
),
|
||||
cssDots(docMenuTrigger(
|
||||
testId('table-dots'),
|
||||
icon('Dots'),
|
||||
menu(() => this._menuItems(tableRec), {placement: 'bottom-start'}),
|
||||
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
|
||||
)),
|
||||
cssMiddle(
|
||||
css60(
|
||||
testId('table-title'),
|
||||
dom.domComputed(fromKo(tableRec.rawViewSectionRef), vsRef => {
|
||||
if (!vsRef) {
|
||||
// Some very old documents might not have rawViewSection.
|
||||
return dom('span', dom.text(tableRec.tableNameDef));
|
||||
} else {
|
||||
return dom('div', // to disable flex grow in the widget
|
||||
dom.domComputed(fromKo(tableRec.rawViewSection), vs =>
|
||||
dom.update(
|
||||
buildTableName(vs, testId('widget-title')),
|
||||
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
css40(
|
||||
cssIdHoverWrapper(
|
||||
cssUpperCase("Table id: "),
|
||||
cssTableId(
|
||||
testId('table-id'),
|
||||
dom.text(tableRec.tableId),
|
||||
),
|
||||
{ title : 'Click to copy' },
|
||||
dom.on('click', async (e, t) => {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
showTransientTooltip(t, 'Table id copied to clipboard', {
|
||||
key: 'copy-table-id'
|
||||
});
|
||||
await copyToClipboard(tableRec.tableId.peek());
|
||||
setTestState({clipboard: tableRec.tableId.peek()});
|
||||
})
|
||||
)
|
||||
),
|
||||
),
|
||||
cssRight(
|
||||
docMenuTrigger(
|
||||
testId('table-menu'),
|
||||
icon('Dots'),
|
||||
menu(() => this._menuItems(tableRec), {placement: 'bottom-start'}),
|
||||
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
|
||||
)
|
||||
),
|
||||
dom.on('click', () => {
|
||||
const sectionId = tableRec.rawViewSection.peek().getRowId();
|
||||
if (!sectionId) {
|
||||
@ -101,17 +122,17 @@ export class DataTables extends Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
private _menuItems(t: TableRec) {
|
||||
private _menuItems(table: TableRec) {
|
||||
const {isReadonly, docModel} = this._gristDoc;
|
||||
return [
|
||||
// TODO: in the upcoming diff
|
||||
// menuItem(() => this._renameTable(t), "Rename", testId('rename'),
|
||||
// dom.cls('disabled', isReadonly)),
|
||||
menuItem(
|
||||
() => this._removeTable(t),
|
||||
() => this._removeTable(table),
|
||||
'Remove',
|
||||
testId('menu-remove'),
|
||||
dom.cls('disabled', use => use(isReadonly) || use(docModel.allTables.getObservable()).length <= 1 )
|
||||
dom.cls('disabled', use => use(isReadonly) || (
|
||||
// Can't delete last user table, unless it is a hidden table.
|
||||
use(docModel.allTables.getObservable()).length <= 1 && !use(table.isHidden)
|
||||
))
|
||||
),
|
||||
dom.maybe(isReadonly, () => menuText('You do not have edit access to this document')),
|
||||
];
|
||||
@ -124,10 +145,6 @@ export class DataTables extends Disposable {
|
||||
}
|
||||
confirmModal(`Delete ${t.tableId()} data, and remove it from all pages?`, 'Delete', doRemove);
|
||||
}
|
||||
|
||||
// private async _renameTable(t: TableRec) {
|
||||
// // TODO:
|
||||
// }
|
||||
}
|
||||
|
||||
const container = styled('div', `
|
||||
@ -169,23 +186,6 @@ const cssList = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssItemContent = styled('div', `
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
.${cssList.className}-list & {
|
||||
align-items: center;
|
||||
}
|
||||
.${cssList.className}-card & {
|
||||
align-items: flex-start;
|
||||
}
|
||||
@media ${css.mediaXSmall} {
|
||||
& {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssItem = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -197,7 +197,7 @@ const cssItem = styled('div', `
|
||||
border-color: ${css.colors.slate};
|
||||
}
|
||||
.${cssList.className}-list & {
|
||||
height: calc(1em * 40/13); /* 40px for 13px font */
|
||||
min-height: calc(1em * 40/13); /* 40px for 13px font */
|
||||
}
|
||||
.${cssList.className}-card & {
|
||||
width: 300px;
|
||||
@ -216,67 +216,69 @@ const cssItem = styled('div', `
|
||||
}
|
||||
`);
|
||||
|
||||
const cssIcon = styled(icon, `
|
||||
--icon-color: ${css.colors.lightGreen};
|
||||
margin-left: 12px;
|
||||
// Holds icon in top left corner
|
||||
const cssLeft = styled('div', `
|
||||
padding-top: 11px;
|
||||
padding-left: 12px;
|
||||
margin-right: 8px;
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
flex: none;
|
||||
.${cssList.className}-card & {
|
||||
margin-top: 1px;
|
||||
}
|
||||
@media ${css.mediaXSmall} {
|
||||
& {
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssOverflow = styled('div', `
|
||||
overflow: hidden;
|
||||
`);
|
||||
|
||||
const cssLabels = styled(cssOverflow, `
|
||||
overflow: hidden;
|
||||
const cssMiddle = styled('div', `
|
||||
flex-grow: 1;
|
||||
min-width: 0px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 4px;
|
||||
.${cssList.className}-card & {
|
||||
margin: 0px:
|
||||
}
|
||||
`);
|
||||
|
||||
const css60 = styled('div', `
|
||||
min-width: min(240px, 100%);
|
||||
display: flex;
|
||||
flex: 6;
|
||||
`);
|
||||
|
||||
const css40 = styled('div', `
|
||||
min-width: min(240px, 100%);
|
||||
flex: 4;
|
||||
display: flex;
|
||||
`);
|
||||
|
||||
|
||||
// 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;
|
||||
display: flex;
|
||||
flex: none;
|
||||
`);
|
||||
|
||||
const cssGreenIcon = styled(icon, `
|
||||
--icon-color: ${css.colors.lightGreen};
|
||||
`);
|
||||
|
||||
const cssLine = styled('span', `
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
`);
|
||||
|
||||
const cssTitleLine = styled(cssOverflow, `
|
||||
display: flex;
|
||||
min-width: 50%;
|
||||
.${cssList.className}-card & {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
@media ${css.mediaXSmall} {
|
||||
& {
|
||||
flex-basis: 100% !important;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssIdLine = styled(cssOverflow, `
|
||||
display: flex;
|
||||
min-width: 40%;
|
||||
.${cssList.className}-card & {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssIdLineContent = styled(cssOverflow, `
|
||||
const cssIdHoverWrapper = styled('div', `
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
align-items: baseline;
|
||||
color: ${css.colors.slate};
|
||||
transition: background 0.05s;
|
||||
padding: 1px 2px;
|
||||
line-height: 18px;
|
||||
&:hover {
|
||||
background: ${css.colors.lightGrey};
|
||||
}
|
||||
@ -301,11 +303,6 @@ const cssUpperCase = styled('span', `
|
||||
white-space: nowrap;
|
||||
`);
|
||||
|
||||
const cssDots = styled('div', `
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
const cssTableList = styled('div', `
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
@ -118,6 +118,7 @@
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
margin-top: -4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.grist-single-record__menu__count {
|
||||
|
@ -66,7 +66,10 @@ export class EditorMonitor extends Disposable {
|
||||
*/
|
||||
private async _listenToReload(doc: GristDoc) {
|
||||
// don't restore on readonly mode or when there is custom nav
|
||||
if (doc.isReadonly.get() || doc.hasCustomNav.get()) { return; }
|
||||
if (doc.isReadonly.get() || doc.hasCustomNav.get()) {
|
||||
this._store.clear();
|
||||
return;
|
||||
}
|
||||
// if we are on raw data view, we need to set the position manually
|
||||
// as currentView observable will not be changed.
|
||||
if (doc.activeViewId.get() === 'data') {
|
||||
@ -86,7 +89,10 @@ export class EditorMonitor extends Disposable {
|
||||
this._restored = true;
|
||||
const viewId = doc.activeViewId.get();
|
||||
// if view wasn't rendered (page is displaying history or code view) do nothing
|
||||
if (!isViewDocPage(viewId)) { return; }
|
||||
if (!isViewDocPage(viewId)) {
|
||||
this._store.clear();
|
||||
return;
|
||||
}
|
||||
const lastEdit = this._store.readValue();
|
||||
if (lastEdit) {
|
||||
// set the cursor at right cell
|
||||
|
@ -7,7 +7,7 @@ import {AccessRules} from 'app/client/aclui/AccessRules';
|
||||
import {ActionLog} from 'app/client/components/ActionLog';
|
||||
import * as BaseView from 'app/client/components/BaseView';
|
||||
import {isNumericLike, isNumericOnly} from 'app/client/components/ChartView';
|
||||
import * as CodeEditorPanel from 'app/client/components/CodeEditorPanel';
|
||||
import {CodeEditorPanel} from 'app/client/components/CodeEditorPanel';
|
||||
import * as commands from 'app/client/components/commands';
|
||||
import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {CursorMonitor, ViewCursorPos} from "app/client/components/CursorMonitor";
|
||||
@ -380,9 +380,9 @@ export class GristDoc extends DisposableWithEvents {
|
||||
return cssViewContentPane(testId('gristdoc'),
|
||||
cssViewContentPane.cls("-contents", use => use(this.activeViewId) === 'data'),
|
||||
dom.domComputed<IDocPage>(this.activeViewId, (viewId) => (
|
||||
viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) :
|
||||
viewId === 'acl' ? dom.create((owner) => owner.autoDispose(AccessRules.create(this, this))) :
|
||||
viewId === 'data' ? dom.create((owner) => owner.autoDispose(RawData.create(this, this))) :
|
||||
viewId === 'code' ? dom.create(CodeEditorPanel, this) :
|
||||
viewId === 'acl' ? dom.create(AccessRules, this) :
|
||||
viewId === 'data' ? dom.create(RawData, this) :
|
||||
viewId === 'GristDocTour' ? null :
|
||||
dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId)))
|
||||
)),
|
||||
@ -413,12 +413,14 @@ export class GristDoc extends DisposableWithEvents {
|
||||
* null, then moves to a position best suited for optActionGroup (not yet implemented).
|
||||
*/
|
||||
public async moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: MinimalActionGroup): Promise<void> {
|
||||
if (!cursorPos || cursorPos.sectionId == null) {
|
||||
if (!cursorPos || !cursorPos.sectionId) {
|
||||
// TODO We could come up with a suitable cursorPos here based on the action itself.
|
||||
// This should only come up if trying to undo/redo after reloading a page (since the cursorPos
|
||||
// associated with the action is only stored in memory of the current JS process).
|
||||
// A function like `getCursorPosForActionGroup(ag)` would also be useful to jump to the best
|
||||
// place from any action in the action log.
|
||||
// When user deletes table from Raw Data view, the section id will be 0 and undoing that
|
||||
// operation will move cursor to the empty section row (with id 0).
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@ -787,6 +789,9 @@ export class GristDoc extends DisposableWithEvents {
|
||||
if (!cursorPos.sectionId) { throw new Error('sectionId required'); }
|
||||
if (!cursorPos.rowId) { throw new Error('rowId required'); }
|
||||
const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
|
||||
if (!section.id.peek()) {
|
||||
throw new Error(`Section ${cursorPos.sectionId} does not exist`);
|
||||
}
|
||||
const srcSection = section.linkSrcSection.peek();
|
||||
if (srcSection.id.peek()) {
|
||||
// We're in a linked section, so we need to recurse to make sure the row we want
|
||||
@ -867,6 +872,17 @@ export class GristDoc extends DisposableWithEvents {
|
||||
view?.activateEditorAtCursor(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames table. Method exposed primarily for tests.
|
||||
*/
|
||||
public async renameTable(tableId: string, newTableName: string) {
|
||||
const tableRec = this.docModel.allTables.all().find(t => t.tableId.peek() === tableId);
|
||||
if (!tableRec) {
|
||||
throw new UserError(`No table with id ${tableId}`);
|
||||
}
|
||||
await tableRec.tableName.saveOnly(newTableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a view to be ready
|
||||
*/
|
||||
|
@ -114,6 +114,9 @@ export class LinkingState extends Disposable {
|
||||
_update();
|
||||
function _update() {
|
||||
const result: FilterColValues = {filters: {}, operations: {}};
|
||||
if (srcSection.isDisposed()) {
|
||||
return result;
|
||||
}
|
||||
const srcRowId = srcSection.activeRowId();
|
||||
for (const c of srcSection.table().groupByColumns()) {
|
||||
const col = c.summarySource();
|
||||
|
@ -22,8 +22,22 @@ export class RawData extends Disposable {
|
||||
this.autoDispose(commands.createGroup(commandGroup, this, true));
|
||||
this._lightboxVisible = Computed.create(this, use => {
|
||||
const section = use(this._gristDoc.viewModel.activeSection);
|
||||
return Boolean(section.getRowId());
|
||||
return Boolean(use(section.id)) && use(section.isRaw);
|
||||
});
|
||||
// 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.
|
||||
// But by the time we are gone (disposed), active view will be changed, so here we will save the reference.
|
||||
// TODO: empty view should rather have id = 0, not undefined. Should be fixed soon.
|
||||
const emptyView = this._gristDoc.docModel.views.rowModels.find(x => x.id.peek() === undefined);
|
||||
this.autoDispose(this._gristDoc.activeViewId.addListener(() => {
|
||||
emptyView?.activeSectionId(0);
|
||||
}));
|
||||
// Whenever we close lightbox, clear cursor monitor state.
|
||||
this.autoDispose(this._lightboxVisible.addListener(state => {
|
||||
if (!state) {
|
||||
this._gristDoc.cursorMonitor.clear();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
@ -39,7 +53,8 @@ export class RawData extends Disposable {
|
||||
),
|
||||
/*************** Lightbox section **********/
|
||||
dom.domComputedOwned(fromKo(this._gristDoc.viewModel.activeSection), (owner, viewSection) => {
|
||||
if (!viewSection.getRowId()) {
|
||||
const sectionId = viewSection.getRowId();
|
||||
if (!sectionId || !viewSection.isRaw.peek()) {
|
||||
return null;
|
||||
}
|
||||
ViewSectionHelper.create(owner, this._gristDoc, viewSection);
|
||||
@ -51,7 +66,7 @@ export class RawData extends Disposable {
|
||||
sectionRowId: viewSection.getRowId(),
|
||||
draggable: false,
|
||||
focusable: false,
|
||||
onRename: this._renameSection.bind(this)
|
||||
widgetNameHidden: true
|
||||
})
|
||||
),
|
||||
cssCloseButton('CrossBig',
|
||||
@ -68,12 +83,6 @@ export class RawData extends Disposable {
|
||||
private _close() {
|
||||
this._gristDoc.viewModel.activeSectionId(0);
|
||||
}
|
||||
|
||||
private async _renameSection(name: string) {
|
||||
// here we will rename primary page for active primary viewSection
|
||||
const primaryViewName = this._gristDoc.viewModel.activeSection.peek().table.peek().primaryView.peek().name;
|
||||
await primaryViewName.saveOnly(name);
|
||||
}
|
||||
}
|
||||
|
||||
const cssContainer = styled('div', `
|
||||
|
@ -15,32 +15,15 @@
|
||||
color: var(--grist-color-slate);
|
||||
font-size: var(--grist-small-font-size);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewsection_titletext {
|
||||
cursor: text;
|
||||
overflow: hidden;
|
||||
}
|
||||
.viewsection_titletext_container {
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
.viewsection_content {
|
||||
background-color: #ffffff;
|
||||
overflow: visible;
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.viewsection_title_colorbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
margin: auto .5rem auto 0;
|
||||
box-shadow: inset 0px 0px 5px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* TODO should be switched to use new icon */
|
||||
.viewsection_drag_indicator {
|
||||
visibility: hidden;
|
||||
|
@ -14,15 +14,15 @@ import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {filterBar} from 'app/client/ui/FilterBar';
|
||||
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
|
||||
import {buildWidgetTitle} from 'app/client/ui/WidgetTitle';
|
||||
import {colors, mediaSmall, testId} from 'app/client/ui2018/cssVars';
|
||||
import {editableLabel} from 'app/client/ui2018/editableLabel';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||
import {mod} from 'app/common/gutil';
|
||||
import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, styled, subscribe} from 'grainjs';
|
||||
import {Observable} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import * as _ from 'underscore';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, styled, subscribe} from 'grainjs';
|
||||
|
||||
// tslint:disable:no-console
|
||||
|
||||
@ -275,11 +275,11 @@ export function buildViewSectionDom(options: {
|
||||
isResizing?: Observable<boolean>
|
||||
viewModel?: ViewRec,
|
||||
// Should show drag anchor.
|
||||
draggable?: boolean /* defaults to true */
|
||||
draggable?: boolean, /* defaults to true */
|
||||
// Should show green bar on the left (but preserves active-section class).
|
||||
focusable?: boolean /* defaults to true */
|
||||
// Custom handler for renaming the section.
|
||||
onRename?: (name: string) => any
|
||||
focusable?: boolean, /* defaults to true */
|
||||
tableNameHidden?: boolean,
|
||||
widgetNameHidden?: boolean,
|
||||
}) {
|
||||
const isResizing = options.isResizing ?? Observable.create(null, false);
|
||||
const {gristDoc, sectionRowId, viewModel, draggable = true, focusable = true} = options;
|
||||
@ -301,13 +301,7 @@ export function buildViewSectionDom(options: {
|
||||
),
|
||||
dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () =>
|
||||
cssSigmaIcon('Pivot', testId('sigma'))),
|
||||
dom('div.viewsection_titletext_container.flexitem.flexhbox',
|
||||
dom('span.viewsection_titletext', editableLabel(
|
||||
fromKo(vs.titleDef),
|
||||
(val) => options.onRename ? options.onRename(val) : vs.titleDef.saveOnly(val),
|
||||
testId('viewsection-title'),
|
||||
)),
|
||||
),
|
||||
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
|
||||
viewInstance.buildTitleControls(),
|
||||
dom('span.viewsection_buttons',
|
||||
dom.create(viewSectionMenu, gristDoc.docModel, vs, gristDoc.isReadonly)
|
||||
@ -332,6 +326,11 @@ export function buildViewSectionDom(options: {
|
||||
);
|
||||
}
|
||||
|
||||
// With new widgetPopup it is hard to click on viewSection without a activating it, hence we
|
||||
// add a little blank space to use in test.
|
||||
const cssTestClick = styled(`div`, `
|
||||
min-width: 1px;
|
||||
`);
|
||||
|
||||
const cssSigmaIcon = styled(icon, `
|
||||
bottom: 1px;
|
||||
|
@ -7,6 +7,7 @@
|
||||
* FocusLayerManager will watch for this element to lose focus or to get disposed, and will
|
||||
* restore focus to the default element.
|
||||
*/
|
||||
import * as Mousetrap from 'app/client/lib/Mousetrap';
|
||||
import {arrayRemove} from 'app/common/gutil';
|
||||
import {RefCountMap} from 'app/common/RefCountMap';
|
||||
import {Disposable, dom} from 'grainjs';
|
||||
@ -21,7 +22,12 @@ export interface FocusLayerOptions {
|
||||
defaultFocusElem: HTMLElement;
|
||||
|
||||
// When true for an element, that element may hold focus even while this layer is active.
|
||||
allowFocus: (elem: Element) => boolean;
|
||||
// Defaults to any element except document.body.
|
||||
allowFocus?: (elem: Element) => boolean;
|
||||
|
||||
// If set, pause mousetrap keyboard shortcuts while this FocusLayer is active. Without it, arrow
|
||||
// keys will navigate in a grid underneath this layer, and Enter may open a cell there.
|
||||
pauseMousetrap?: boolean;
|
||||
|
||||
// Called when the defaultFocusElem gets focused.
|
||||
onDefaultFocus?: () => void;
|
||||
@ -139,10 +145,20 @@ export class FocusLayer extends Disposable implements FocusLayerOptions {
|
||||
constructor(options: FocusLayerOptions) {
|
||||
super();
|
||||
this.defaultFocusElem = options.defaultFocusElem;
|
||||
this.allowFocus = options.allowFocus;
|
||||
this.allowFocus = options.allowFocus || (elem => elem !== document.body);
|
||||
this._onDefaultFocus = options.onDefaultFocus;
|
||||
this._onDefaultBlur = options.onDefaultBlur;
|
||||
|
||||
// Make sure the element has a tabIndex attribute, to make it focusable.
|
||||
if (!this.defaultFocusElem.hasAttribute('tabindex')) {
|
||||
this.defaultFocusElem.setAttribute('tabindex', '-1');
|
||||
}
|
||||
|
||||
if (options.pauseMousetrap) {
|
||||
Mousetrap.setPaused(true);
|
||||
this.onDispose(() => Mousetrap.setPaused(false));
|
||||
}
|
||||
|
||||
const managerRefCount = this.autoDispose(_focusLayerManager.use(null));
|
||||
const manager = managerRefCount.get();
|
||||
manager.addLayer(this);
|
||||
|
@ -22,7 +22,7 @@ import {urlState} from 'app/client/models/gristUrlState';
|
||||
import * as MetaRowModel from 'app/client/models/MetaRowModel';
|
||||
import * as MetaTableModel from 'app/client/models/MetaTableModel';
|
||||
import * as rowset from 'app/client/models/rowset';
|
||||
import {isHiddenTable} from 'app/common/isHiddenTable';
|
||||
import {isHiddenTable, isRawTable} from 'app/common/isHiddenTable';
|
||||
import {schema, SchemaTypes} from 'app/common/schema';
|
||||
|
||||
import {ACLRuleRec, createACLRuleRec} from 'app/client/models/entities/ACLRuleRec';
|
||||
@ -135,6 +135,7 @@ export class DocModel {
|
||||
public docInfoRow: DocInfoRec;
|
||||
|
||||
public allTables: KoArray<TableRec>;
|
||||
public rawTables: KoArray<TableRec>;
|
||||
public allTableIds: KoArray<string>;
|
||||
|
||||
// A mapping from tableId to DataTableModel for user-defined tables.
|
||||
@ -169,6 +170,7 @@ export class DocModel {
|
||||
// An observable array of user-visible tables, sorted by tableId, excluding summary tables.
|
||||
// This is a publicly exposed member.
|
||||
this.allTables = createUserTablesArray(this.tables);
|
||||
this.rawTables = createRawTablesArray(this.tables);
|
||||
|
||||
// An observable array of user-visible tableIds. A shortcut mapped from allTables.
|
||||
const allTableIds = ko.computed(() => this.allTables.all().map(t => t.tableId()));
|
||||
@ -236,3 +238,13 @@ function createUserTablesArray(tablesModel: MetaTableModel<TableRec>): KoArray<T
|
||||
// Create an observable RowModel array based on this rowSource, sorted by tableId.
|
||||
return tablesModel._createRowSetModel(rowSource, 'tableId');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an observable array of tables, sorted by tableId, and excluding summary tables.
|
||||
*/
|
||||
function createRawTablesArray(tablesModel: MetaTableModel<TableRec>): KoArray<TableRec> {
|
||||
const rowSource = new rowset.FilteredRowSource(r => isRawTable(tablesModel.tableData, r));
|
||||
rowSource.subscribeTo(tablesModel);
|
||||
// Create an observable RowModel array based on this rowSource, sorted by tableId.
|
||||
return tablesModel._createRowSetModel(rowSource, 'tableId');
|
||||
}
|
||||
|
@ -68,9 +68,19 @@ MetaTableModel.prototype.loadData = function() {
|
||||
* when the row is deleted; in that case lacking such dependency may cause subtle rare bugs.
|
||||
*/
|
||||
MetaTableModel.prototype.getRowModel = function(rowId, optDependOnVersion) {
|
||||
let r = this.rowModels[rowId] || this.getEmptyRowModel();
|
||||
const rowIdModel = this.rowModels[rowId];
|
||||
const r = rowIdModel || this.getEmptyRowModel();
|
||||
if (optDependOnVersion) {
|
||||
this._rowModelVersions[rowId]();
|
||||
// Versions are never deleted, so even if the rowModel is deleted, we still have its version
|
||||
// in this list.
|
||||
const version = this._rowModelVersions[rowId];
|
||||
if (version) {
|
||||
// Subscribe to updates for rowModel at rowId.
|
||||
version();
|
||||
} else {
|
||||
// It shouldn't happen, but maybe it would be better to add an empty version observable at rowId.
|
||||
// If it happens, it means we tried to get non existing row (row that wasn't created previously).
|
||||
}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
@ -192,7 +192,7 @@ class FinderImpl implements IFinder {
|
||||
// Filter out those we don't have permissions to see (through ACL-tableId will be empty).
|
||||
.filter(t => Boolean(t.tableId.peek()))
|
||||
// sort in order that is the same as on the raw data list page,
|
||||
.sort((a, b) => nativeCompare(a.tableTitle.peek(), b.tableTitle.peek()))
|
||||
.sort((a, b) => nativeCompare(a.tableNameDef.peek(), b.tableNameDef.peek()))
|
||||
// get rawViewSection,
|
||||
.map(t => t.rawViewSection.peek())
|
||||
// and test if it isn't an empty record.
|
||||
|
@ -11,6 +11,18 @@ export function createPageRec(this: PageRec, docModel: DocModel): void {
|
||||
this.view = refRecord(docModel.views, this.viewRef);
|
||||
this.isHidden = ko.pureComputed(() => {
|
||||
const name = this.view().name();
|
||||
return !name || (name === 'GristDocTour' && !docModel.showDocTourTable);
|
||||
const isTableHidden = () => {
|
||||
const viewId = this.view().id();
|
||||
const tables = docModel.rawTables.all();
|
||||
const primaryTable = tables.find(t => t.primaryViewId() === viewId);
|
||||
return !!primaryTable && primaryTable.isHidden();
|
||||
};
|
||||
// Page is hidden when any of this is true:
|
||||
// - It has an empty name (or no name at all)
|
||||
// - It is GristDocTour (unless user wants to see it)
|
||||
// - It is a page generated for a hidden table TODO: Follow up - don't create
|
||||
// pages for hidden tables.
|
||||
// This is used currently only the left panel, to hide pages from the user.
|
||||
return !name || (name === 'GristDocTour' && !docModel.showDocTourTable) || isTableHidden();
|
||||
});
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import {KoArray} from 'app/client/lib/koArray';
|
||||
import {DocModel, IRowModel, recordSet, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {ColumnRec, ValidationRec, ViewRec} from 'app/client/models/DocModel';
|
||||
import * as modelUtil from 'app/client/models/modelUtil';
|
||||
import {MANUALSORT} from 'app/common/gristTypes';
|
||||
import * as ko from 'knockout';
|
||||
import toUpper = require('lodash/toUpper');
|
||||
import * as randomcolor from 'randomcolor';
|
||||
|
||||
// Represents a user-defined table.
|
||||
@ -23,10 +23,16 @@ export interface TableRec extends IRowModel<"_grist_Tables"> {
|
||||
|
||||
// The list of grouped by columns.
|
||||
groupByColumns: ko.Computed<ColumnRec[]>;
|
||||
|
||||
// The user-friendly name of the table, which is the same as tableId for non-summary tables,
|
||||
// and is 'tableId[groupByCols...]' for summary tables.
|
||||
tableTitle: ko.Computed<string>;
|
||||
// Grouping description.
|
||||
groupDesc: ko.PureComputed<string>;
|
||||
// Name of the data table - title of the rawViewSection
|
||||
// for summary table it is name of primary table.
|
||||
tableName: modelUtil.KoSaveableObservable<string>;
|
||||
// Table name with a default value (which is tableId).
|
||||
tableNameDef: modelUtil.KoSaveableObservable<string>;
|
||||
// If user can select this table in various places.
|
||||
// Note: Some hidden tables can still be visible on RawData view.
|
||||
isHidden: ko.Computed<boolean>;
|
||||
|
||||
tableColor: string;
|
||||
disableAddRemoveRows: ko.Computed<boolean>;
|
||||
@ -40,6 +46,10 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
|
||||
this.primaryView = refRecord(docModel.views, this.primaryViewId);
|
||||
this.rawViewSection = refRecord(docModel.viewSections, this.rawViewSectionRef);
|
||||
this.summarySource = refRecord(docModel.tables, this.summarySourceTable);
|
||||
this.isHidden = this.autoDispose(
|
||||
// This is repeated logic from isHiddenTable.
|
||||
ko.pureComputed(() => !!this.summarySourceTable() || this.tableId()?.startsWith("GristHidden"))
|
||||
);
|
||||
|
||||
// A Set object of colRefs for all summarySourceCols of this table.
|
||||
this.summarySourceColRefs = this.autoDispose(ko.pureComputed(() => new Set(
|
||||
@ -51,18 +61,12 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
|
||||
|
||||
this.groupByColumns = ko.pureComputed(() => this.columns().all().filter(c => c.summarySourceCol()));
|
||||
|
||||
const groupByDesc = ko.pureComputed(() => {
|
||||
const groupBy = this.groupByColumns();
|
||||
return groupBy.length ? 'by ' + groupBy.map(c => c.label()).join(", ") : "Totals";
|
||||
});
|
||||
|
||||
// The user-friendly name of the table, which is the same as tableId for non-summary tables,
|
||||
// and is 'tableId[groupByCols...]' for summary tables.
|
||||
this.tableTitle = ko.pureComputed(() => {
|
||||
if (this.summarySourceTable()) {
|
||||
return toUpper(this.summarySource().tableId()) + " [" + groupByDesc() + "]";
|
||||
this.groupDesc = ko.pureComputed(() => {
|
||||
if (!this.summarySourceTable()) {
|
||||
return '';
|
||||
}
|
||||
return toUpper(this.tableId());
|
||||
const groupBy = this.groupByColumns();
|
||||
return `[${groupBy.length ? 'by ' + groupBy.map(c => c.label()).join(", ") : "Totals"}]`;
|
||||
});
|
||||
|
||||
// TODO: We should save this value and let users change it.
|
||||
@ -74,4 +78,40 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
|
||||
this.disableAddRemoveRows = ko.pureComputed(() => Boolean(this.summarySourceTable()));
|
||||
|
||||
this.supportsManualSort = ko.pureComputed(() => this.columns().all().some(c => c.colId() === MANUALSORT));
|
||||
|
||||
this.tableName = modelUtil.savingComputed({
|
||||
read: () => {
|
||||
if (this.isDisposed()) {
|
||||
return '';
|
||||
}
|
||||
if (this.summarySourceTable()) {
|
||||
return this.summarySource().rawViewSection().title();
|
||||
} else {
|
||||
// Need to be extra careful here, rawViewSection might be disposed.
|
||||
if (this.rawViewSection().isDisposed()) {
|
||||
return '';
|
||||
}
|
||||
return this.rawViewSection().title();
|
||||
}
|
||||
},
|
||||
write: (setter, val) => {
|
||||
if (this.summarySourceTable()) {
|
||||
setter(this.summarySource().rawViewSection().title, val);
|
||||
} else {
|
||||
setter(this.rawViewSection().title, val);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.tableNameDef = modelUtil.fieldWithDefault(
|
||||
this.tableName,
|
||||
// TableId will be null/undefined when ACL will restrict access to it.
|
||||
ko.computed(() => {
|
||||
// During table removal, we could be disposed.
|
||||
if (this.isDisposed()) {
|
||||
return '';
|
||||
}
|
||||
const table = this.summarySourceTable() ? this.summarySource() : this;
|
||||
return table.tableId() || '';
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -44,8 +44,10 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
|
||||
|
||||
table: ko.Computed<TableRec>;
|
||||
|
||||
tableTitle: ko.Computed<string>;
|
||||
// Widget title with a default value
|
||||
titleDef: modelUtil.KoSaveableObservable<string>;
|
||||
// Default widget title (the one that is used in titleDef).
|
||||
defaultWidgetTitle: ko.PureComputed<string>;
|
||||
|
||||
// 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.
|
||||
@ -166,6 +168,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
|
||||
// List of selected rows
|
||||
selectedRows: Observable<number[]>;
|
||||
|
||||
|
||||
// Save all filters of fields/columns in the section.
|
||||
saveFilters(): Promise<void>;
|
||||
|
||||
@ -279,13 +282,25 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
|
||||
this.table = refRecord(docModel.tables, this.tableRef);
|
||||
|
||||
this.tableTitle = this.autoDispose(ko.pureComputed(() => this.table().tableTitle()));
|
||||
this.titleDef = modelUtil.fieldWithDefault(
|
||||
this.title,
|
||||
() => this.table().tableTitle() + (
|
||||
(this.parentKey() === 'record') ? '' : ` ${getWidgetTypes(this.parentKey.peek() as any).label}`
|
||||
)
|
||||
);
|
||||
|
||||
// The user-friendly name of the table, which is the same as tableId for non-summary tables,
|
||||
// and is 'tableId[groupByCols...]' for summary tables.
|
||||
// Consist of 3 parts
|
||||
// - TableId (or primary table id for summary tables) capitalized
|
||||
// - Grouping description (table record contains this for summary tables)
|
||||
// - Widget type description (if not grid)
|
||||
// All concatenated separated by space.
|
||||
this.defaultWidgetTitle = this.autoDispose(ko.pureComputed(() => {
|
||||
const widgetTypeDesc = this.parentKey() !== 'record' ? `${getWidgetTypes(this.parentKey.peek() as any).label}` : '';
|
||||
const table = this.table();
|
||||
return [
|
||||
table.tableNameDef()?.toUpperCase(), // Due to ACL this can be null.
|
||||
table.groupDesc(),
|
||||
widgetTypeDesc
|
||||
].filter(part => Boolean(part?.trim())).join(' ');
|
||||
}));
|
||||
// Widget title.
|
||||
this.titleDef = modelUtil.fieldWithDefault(this.title, this.defaultWidgetTitle);
|
||||
|
||||
// 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.
|
||||
|
@ -300,7 +300,8 @@ export class PageWidgetSelect extends Disposable {
|
||||
),
|
||||
dom.forEach(this._tables, (table) => dom('div',
|
||||
cssEntryWrapper(
|
||||
cssEntry(cssIcon('TypeTable'), cssLabel(dom.text(table.tableId)),
|
||||
cssEntry(cssIcon('TypeTable'),
|
||||
cssLabel(dom.text(use => use(table.tableNameDef) || use(table.tableId))),
|
||||
dom.on('click', () => this._selectTable(table.id())),
|
||||
cssEntry.cls('-selected', (use) => use(this._value.table) === table.id()),
|
||||
testId('table-label')
|
||||
|
@ -3,12 +3,10 @@ import { duplicatePage } from "app/client/components/duplicatePage";
|
||||
import { GristDoc } from "app/client/components/GristDoc";
|
||||
import { PageRec } from "app/client/models/DocModel";
|
||||
import { urlState } from "app/client/models/gristUrlState";
|
||||
import { isHiddenTable } from 'app/common/isHiddenTable';
|
||||
import * as MetaTableModel from "app/client/models/MetaTableModel";
|
||||
import { find as findInTree, fromTableData, TreeItemRecord, TreeRecord,
|
||||
TreeTableData} from "app/client/models/TreeModel";
|
||||
import { TreeViewComponent } from "app/client/ui/TreeViewComponent";
|
||||
import { confirmModal } from 'app/client/ui2018/modals';
|
||||
import { buildPageDom, PageActions } from "app/client/ui2018/pages";
|
||||
import { mod } from 'app/common/gutil';
|
||||
import { Computed, Disposable, dom, fromKo, observable, Observable } from "grainjs";
|
||||
@ -52,7 +50,7 @@ export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Ob
|
||||
}
|
||||
|
||||
function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: GristDoc, id: number) {
|
||||
const {docModel, isReadonly} = activeDoc;
|
||||
const {isReadonly} = activeDoc;
|
||||
const pageName = pagesTable.rowModels[id].view.peek().name;
|
||||
const viewId = pagesTable.rowModels[id].view.peek().id.peek();
|
||||
const docData = pagesTable.tableData.docData;
|
||||
@ -61,33 +59,11 @@ function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: Grist
|
||||
onRemove: () => docData.sendAction(['RemoveRecord', '_grist_Views', viewId]),
|
||||
// TODO: duplicate should prompt user for confirmation
|
||||
onDuplicate: () => duplicatePage(activeDoc, id),
|
||||
isRemoveDisabled: () => false,
|
||||
// Can't remove last visible page
|
||||
isRemoveDisabled: () => activeDoc.docModel.visibleDocPages.peek().length <= 1,
|
||||
isReadonly
|
||||
};
|
||||
|
||||
// find a table with a matching primary view
|
||||
const tableRef = docModel.tables.tableData.findRow('primaryViewId', viewId);
|
||||
|
||||
if (tableRef) {
|
||||
function doRemove() {
|
||||
const tableId = docModel.tables.tableData.getValue(tableRef, 'tableId');
|
||||
return docData.sendAction(['RemoveTable', tableId]);
|
||||
}
|
||||
|
||||
// if user removes a primary view, let's confirm first, because this will remove the
|
||||
// corresponding table and also all pages that are using this table.
|
||||
// TODO: once we have raw table view, removing page should remove just the view (not the
|
||||
// table), but for now this is the only way to remove a table in the newui.
|
||||
actions.onRemove = () => confirmModal(
|
||||
`Delete ${pageName()} data, and remove it from all pages?`, 'Delete', doRemove);
|
||||
|
||||
// Disable removing the last page. Sometimes hidden pages end up showing in the side panel
|
||||
// (e.g. GristHidden_import* for aborted imports); those aren't listed in allTables, and we
|
||||
// should allow removing them.
|
||||
actions.isRemoveDisabled = () => (docModel.allTables.all().length <= 1) &&
|
||||
!isHiddenTable(docModel.tables.tableData, tableRef);
|
||||
}
|
||||
|
||||
return buildPageDom(fromKo(pageName), actions, urlState().setLinkUrl({docPage: viewId}));
|
||||
}
|
||||
|
||||
|
@ -299,7 +299,7 @@ export class RightPanel extends Disposable {
|
||||
});
|
||||
return dom.maybe(viewConfigTab, (vct) => [
|
||||
this._disableIfReadonly(),
|
||||
cssLabel('WIDGET TITLE',
|
||||
cssLabel(dom.text(use => use(activeSection.isRaw) ? 'DATA TABLE NAME' : 'WIDGET TITLE'),
|
||||
dom.style('margin-bottom', '14px')),
|
||||
cssRow(cssTextInput(
|
||||
Computed.create(owner, (use) => use(activeSection.titleDef)),
|
||||
|
@ -27,8 +27,6 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
||||
gristDoc.docModel.rules.getNumRows() > 0);
|
||||
}
|
||||
owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules));
|
||||
// TODO: Create global observable to enable raw tools (TO REMOVE once raw data ui has landed)
|
||||
(window as any).enableRawTools = Observable.create(null, false);
|
||||
updateCanViewAccessRules();
|
||||
return cssTools(
|
||||
cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)),
|
||||
@ -48,17 +46,15 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse
|
||||
}),
|
||||
testId('access-rules'),
|
||||
),
|
||||
// Raw data - for now hidden.
|
||||
dom.maybe((window as any).enableRawTools, () =>
|
||||
cssPageEntry(
|
||||
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'data'),
|
||||
cssPageLink(
|
||||
cssPageIcon('Database'),
|
||||
cssLinkText('Raw data'),
|
||||
testId('raw'),
|
||||
urlState().setLinkUrl({docPage: 'data'})
|
||||
),
|
||||
)),
|
||||
cssPageEntry(
|
||||
cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'data'),
|
||||
cssPageLink(
|
||||
cssPageIcon('Database'),
|
||||
cssLinkText('Raw data'),
|
||||
testId('raw'),
|
||||
urlState().setLinkUrl({docPage: 'data'})
|
||||
)
|
||||
),
|
||||
cssPageEntry(
|
||||
cssPageLink(cssPageIcon('Log'), cssLinkText('Document History'), testId('log'),
|
||||
dom.on('click', () => gristDoc.showTool('docHistory')))
|
||||
|
245
app/client/ui/WidgetTitle.ts
Normal file
245
app/client/ui/WidgetTitle.ts
Normal file
@ -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;
|
||||
}
|
||||
`);
|
@ -1,5 +1,4 @@
|
||||
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
||||
import * as Mousetrap from 'app/client/lib/Mousetrap';
|
||||
import {reportError} from 'app/client/models/errors';
|
||||
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, mediaSmall, testId, vars} from 'app/client/ui2018/cssVars';
|
||||
@ -36,7 +35,7 @@ export interface IModalControl {
|
||||
): (...args: Args) => Promise<void>;
|
||||
}
|
||||
|
||||
class ModalControl extends Disposable implements IModalControl {
|
||||
export class ModalControl extends Disposable implements IModalControl {
|
||||
private _inProgress = Observable.create<number>(this, 0);
|
||||
private _workInProgress = Computed.create(this, this._inProgress, (use, n) => (n > 0));
|
||||
private _closePromise: Promise<boolean>|undefined;
|
||||
@ -44,13 +43,13 @@ class ModalControl extends Disposable implements IModalControl {
|
||||
|
||||
constructor(
|
||||
private _doClose: () => void,
|
||||
private _doFocus: () => void,
|
||||
private _doFocus?: () => void,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this._doFocus();
|
||||
this._doFocus?.();
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
@ -163,11 +162,6 @@ export function modal(
|
||||
|
||||
const modalDom = cssModalBacker(
|
||||
dom.create((owner) => {
|
||||
// Pause mousetrap keyboard shortcuts while the modal is shown. Without this, arrow keys
|
||||
// will navigate in a grid underneath the modal, and Enter may open a cell there.
|
||||
Mousetrap.setPaused(true);
|
||||
owner.onDispose(() => Mousetrap.setPaused(false));
|
||||
|
||||
const focus = () => dialog.focus();
|
||||
const ctl = ModalControl.create(owner, doClose, focus);
|
||||
close = () => ctl.close();
|
||||
@ -181,6 +175,9 @@ export function modal(
|
||||
FocusLayer.create(owner, {
|
||||
defaultFocusElem: dialog,
|
||||
allowFocus: (elem) => (elem !== document.body),
|
||||
// Pause mousetrap keyboard shortcuts while the modal is shown. Without this, arrow keys
|
||||
// will navigate in a grid underneath the modal, and Enter may open a cell there.
|
||||
pauseMousetrap: true
|
||||
});
|
||||
return dialog;
|
||||
}),
|
||||
|
@ -56,7 +56,7 @@ export class NumericTextBox extends NTextBox {
|
||||
const defaultMin = Computed.create(holder, resolved, (use, res) => res.minimumFractionDigits);
|
||||
const defaultMax = Computed.create(holder, resolved, (use, res) => res.maximumFractionDigits);
|
||||
const docCurrency = Computed.create(holder, docSettings, (use, settings) =>
|
||||
settings.currency ?? LocaleCurrency.getCurrency(settings.locale)
|
||||
settings.currency ?? LocaleCurrency.getCurrency(settings.locale ?? 'en-US')
|
||||
);
|
||||
|
||||
// Save a value as the given property in this.options() observable. Set it, save, and revert
|
||||
|
@ -49,7 +49,7 @@ LocaleCurrencyMap["SS"] = "SSP";
|
||||
LocaleCurrencyMap["XK"] = "EUR";
|
||||
const currenciesCodes = Object.values(LocaleCurrencyMap);
|
||||
export function getCurrency(code: string) {
|
||||
const currency = LocaleCurrency.getCurrency(code);
|
||||
const currency = LocaleCurrency.getCurrency(code ?? 'en-US');
|
||||
// Fallback to USD
|
||||
return currency ?? DEFAULT_CURRENCY;
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ export interface NumberFormatOptions extends FormatOptions {
|
||||
}
|
||||
|
||||
export function getCurrency(options: NumberFormatOptions, docSettings: DocumentSettings): string {
|
||||
return options.currency || docSettings.currency || LocaleCurrency.getCurrency(docSettings.locale);
|
||||
return options.currency || docSettings.currency || LocaleCurrency.getCurrency(docSettings.locale ?? 'en-US');
|
||||
}
|
||||
|
||||
export function buildNumberFormat(options: NumberFormatOptions, docSettings: DocumentSettings): Intl.NumberFormat {
|
||||
|
@ -167,12 +167,14 @@ export class TableData extends ActionDispatcher implements SkippableRows {
|
||||
* Given a column name, returns a function that takes a rowId and returns the value for that
|
||||
* column of that row. The returned function is faster than getValue() calls.
|
||||
*/
|
||||
public getRowPropFunc(colId: string): undefined | UIRowFunc<CellValue|undefined> {
|
||||
const colData = this._columns.get(colId);
|
||||
if (!colData) { return undefined; }
|
||||
const values = colData.values;
|
||||
public getRowPropFunc(colId: string): UIRowFunc<CellValue|undefined> {
|
||||
const rowMap = this._rowMap;
|
||||
return function(rowId: UIRowId) { return values[rowMap.get(rowId as number)!]; };
|
||||
return (rowId: UIRowId) => {
|
||||
const colData = this._columns.get(colId);
|
||||
if (!colData) { return undefined; }
|
||||
const values = colData.values;
|
||||
return values[rowMap.get(rowId as number)!];
|
||||
};
|
||||
}
|
||||
|
||||
// By default, no rows are skippable, all are kept.
|
||||
|
@ -7,6 +7,12 @@ import {TableData} from "./TableData";
|
||||
*/
|
||||
export function isHiddenTable(tablesData: TableData, tableRef: UIRowId): boolean {
|
||||
const tableId = tablesData.getValue(tableRef, 'tableId') as string|undefined;
|
||||
return tablesData.getValue(tableRef, 'summarySourceTable') !== 0 ||
|
||||
Boolean(tableId?.startsWith('GristHidden'));
|
||||
return !isRawTable(tablesData, tableRef) || Boolean(tableId?.startsWith('GristHidden'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether a table identified by the rowId of its metadata record should be visible on Raw Data page.
|
||||
*/
|
||||
export function isRawTable(tablesData: TableData, tableRef: UIRowId): boolean {
|
||||
return tablesData.getValue(tableRef, 'summarySourceTable') === 0;
|
||||
}
|
||||
|
@ -131,13 +131,15 @@ export class TimeLayout {
|
||||
public fields: TimeQuery;
|
||||
public columns: TimeQuery;
|
||||
public views: TimeQuery;
|
||||
public sections: TimeQuery;
|
||||
|
||||
constructor(public tc: TimeCursor) {
|
||||
this.tables = new TimeQuery(tc, '_grist_Tables', ['tableId', 'primaryViewId']);
|
||||
this.tables = new TimeQuery(tc, '_grist_Tables', ['tableId', 'primaryViewId', 'rawViewSectionRef']);
|
||||
this.fields = new TimeQuery(tc, '_grist_Views_section_field',
|
||||
['parentId', 'parentPos', 'colRef']);
|
||||
this.columns = new TimeQuery(tc, '_grist_Tables_column', ['parentId', 'colId']);
|
||||
this.views = new TimeQuery(tc, '_grist_Views', ['id', 'name']);
|
||||
this.sections = new TimeQuery(tc, '_grist_Views_section', ['id', 'title']);
|
||||
}
|
||||
|
||||
/** update from TimeCursor */
|
||||
@ -146,6 +148,7 @@ export class TimeLayout {
|
||||
await this.columns.update();
|
||||
await this.fields.update();
|
||||
await this.views.update();
|
||||
await this.sections.update();
|
||||
}
|
||||
|
||||
public getColumnOrder(tableId: string): string[] {
|
||||
@ -158,7 +161,7 @@ export class TimeLayout {
|
||||
}
|
||||
|
||||
public getTableName(tableId: string): string {
|
||||
const primaryViewId = this.tables.one({tableId}).primaryViewId;
|
||||
return this.views.one({id: primaryViewId}).name;
|
||||
const rawViewSectionRef = this.tables.one({tableId}).rawViewSectionRef;
|
||||
return this.sections.one({id: rawViewSectionRef}).title;
|
||||
}
|
||||
}
|
||||
|
@ -454,14 +454,16 @@ class TestUserActions(test_engine.EngineTestCase):
|
||||
[ 4, 'Table1', 3],
|
||||
])
|
||||
|
||||
# Update the names in a few views, and ensure that primary ones cause tables to get renamed.
|
||||
# Update the names in a few views, and ensure that primary ones won't cause tables to
|
||||
# get renamed.
|
||||
self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2,3,4],
|
||||
{'name': ['A', 'B', 'C']}])
|
||||
|
||||
self.assertTableData('_grist_Tables', cols="subset", data=[
|
||||
[ 'id', 'tableId', 'primaryViewId' ],
|
||||
[ 1, 'Schools', 1],
|
||||
[ 2, 'GristSummary_7_Schools', 0],
|
||||
[ 3, 'C', 4],
|
||||
[ 3, 'Table1', 4],
|
||||
])
|
||||
self.assertTableData('_grist_Views', cols="subset", data=[
|
||||
[ 'id', 'name', 'primaryViewTable' ],
|
||||
@ -471,6 +473,97 @@ class TestUserActions(test_engine.EngineTestCase):
|
||||
[ 4, 'C', 3]
|
||||
])
|
||||
|
||||
# Now rename a table (by raw view section) and make sure that a view with the same name
|
||||
# was renamed
|
||||
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,
|
||||
{'title': 'Bars'}])
|
||||
|
||||
self.assertTableData('_grist_Tables', cols="subset", data=[
|
||||
['id', 'tableId'],
|
||||
[1, 'Bars', 1],
|
||||
[2, 'GristSummary_4_Bars', 0],
|
||||
[3, 'Table1', 4],
|
||||
])
|
||||
self.assertTableData('_grist_Views', cols="subset", data=[
|
||||
['id', 'name'],
|
||||
[1, 'Bars'],
|
||||
[2, 'A'],
|
||||
[3, 'B'],
|
||||
[4, 'C']
|
||||
])
|
||||
|
||||
# Now rename tables so that two tables will have same names, to test if only the view
|
||||
# with a page will be renamed.
|
||||
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,
|
||||
{'title': 'A'}])
|
||||
|
||||
self.assertTableData('_grist_Tables', cols="subset", data=[
|
||||
['id', 'tableId'],
|
||||
[1, 'A', 1],
|
||||
[2, 'GristSummary_1_A', 0],
|
||||
[3, 'Table1', 4],
|
||||
])
|
||||
self.assertTableData('_grist_Views', cols="subset", data=[
|
||||
['id', 'name'],
|
||||
[1, 'A'],
|
||||
[2, 'A'],
|
||||
[3, 'B'],
|
||||
[4, 'C']
|
||||
])
|
||||
|
||||
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,
|
||||
{'title': 'Z'}])
|
||||
|
||||
self.assertTableData('_grist_Tables', cols="subset", data=[
|
||||
['id', 'tableId'],
|
||||
[1, 'Z', 1],
|
||||
[2, 'GristSummary_1_Z', 0],
|
||||
[3, 'Table1', 4],
|
||||
])
|
||||
self.assertTableData('_grist_Views', cols="subset", data=[
|
||||
['id', 'name'],
|
||||
[1, 'Z'],
|
||||
[2, 'Z'],
|
||||
[3, 'B'],
|
||||
[4, 'C']
|
||||
])
|
||||
|
||||
# Add new table, with a view with the same name (Z) and make sure it won't be renamed
|
||||
self.apply_user_action(['AddTable', 'Stations', [
|
||||
{'id': 'city', 'type': 'Text'},
|
||||
]])
|
||||
|
||||
# Replacing only a page name (though primary)
|
||||
self.apply_user_action(['UpdateRecord', '_grist_Views', 5, {'name': 'Z'}])
|
||||
self.assertTableData('_grist_Views', cols="subset", data=[
|
||||
['id', 'name'],
|
||||
[1, 'Z'],
|
||||
[2, 'Z'],
|
||||
[3, 'B'],
|
||||
[4, 'C'],
|
||||
[5, 'Z']
|
||||
])
|
||||
|
||||
# Rename table Z to Schools. Primary view for Stations (Z) should not be renamed.
|
||||
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,
|
||||
{'title': 'Schools'}])
|
||||
|
||||
self.assertTableData('_grist_Tables', cols="subset", data=[
|
||||
['id', 'tableId'],
|
||||
[1, 'Schools'],
|
||||
[2, 'GristSummary_7_Schools'],
|
||||
[3, 'Table1'],
|
||||
[4, 'Stations'],
|
||||
])
|
||||
self.assertTableData('_grist_Views', cols="subset", data=[
|
||||
['id', 'name'],
|
||||
[1, 'Schools'],
|
||||
[2, 'Schools'],
|
||||
[3, 'B'],
|
||||
[4, 'C'],
|
||||
[5, 'Z']
|
||||
])
|
||||
|
||||
#----------------------------------------------------------------------
|
||||
|
||||
def test_section_removes(self):
|
||||
@ -531,7 +624,8 @@ class TestUserActions(test_engine.EngineTestCase):
|
||||
self.assertEqual(count_calls[0], 0)
|
||||
|
||||
# Do a schema action to ensure it gets called: this causes a table rename.
|
||||
self.apply_user_action(['UpdateRecord', '_grist_Views', 4, {'name': 'C'}])
|
||||
# 7 is id of raw view section for the Tabl1 table
|
||||
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 7, {'title': 'C'}])
|
||||
self.assertEqual(count_calls[0], 1)
|
||||
|
||||
self.assertTableData('_grist_Tables', cols="subset", data=[
|
||||
|
@ -681,22 +681,33 @@ class UserActions(object):
|
||||
make_acl_updates()
|
||||
|
||||
|
||||
@override_action('BulkUpdateRecord', '_grist_Views')
|
||||
def _updateViewRecords(self, table_id, row_ids, col_values):
|
||||
# If we change a view's name, and that view is a primary view, change
|
||||
# its table's tableId as well.
|
||||
if 'name' in col_values:
|
||||
@override_action('BulkUpdateRecord', '_grist_Views_section')
|
||||
def _updateViewSections(self, table_id, row_ids, col_values):
|
||||
# If we change a raw section name, rename also the table. Table name is a title of the RAW
|
||||
# section. TableId is derived from the tableName (or is autogenerated if the tableName is blank)
|
||||
if 'title' in col_values:
|
||||
rename_table_recs = []
|
||||
rename_names = []
|
||||
rename_section_recs = []
|
||||
for i, rec, values in self._bulk_action_iter(table_id, row_ids, col_values):
|
||||
table = rec.primaryViewTable
|
||||
if table:
|
||||
rename_table_recs.append(table)
|
||||
rename_section_recs.append(table.rawViewSectionRef)
|
||||
rename_names.append(values['name'])
|
||||
if rec.isRaw:
|
||||
rename_table_recs.append(rec.tableRef)
|
||||
rename_names.append(values['title'])
|
||||
|
||||
# Renaming a table may sometimes rename pages: For any pages whose name matches
|
||||
# the table name, rename those page to match (provided it contains a section with this
|
||||
# table).
|
||||
|
||||
# Get all sections with this table
|
||||
sections = self._docmodel.view_sections.lookupRecords(tableRef=rec.tableRef)
|
||||
# Get the views of those sections
|
||||
views = {s.parentId for s in sections if s.parentId is not None and s.parentId.id != 0}
|
||||
# Filter them by the old table name (which may be empty - than by tableId)
|
||||
related_views = [v for v in views if v.name == (rec.title or rec.tableRef.tableId)]
|
||||
# Update the views immediately
|
||||
if related_views:
|
||||
self._docmodel.update(related_views, name=[values['title']] * len(related_views))
|
||||
|
||||
self._docmodel.update(rename_table_recs, tableId=rename_names)
|
||||
self._docmodel.update(rename_section_recs, title=rename_names)
|
||||
|
||||
self.doBulkUpdateRecord(table_id, row_ids, col_values)
|
||||
|
||||
@ -972,7 +983,7 @@ class UserActions(object):
|
||||
remove_table_recs.extend(st for t in remove_table_recs for st in t.summaryTables)
|
||||
|
||||
# If other tables have columns referring to this table, remove them.
|
||||
self._docmodel.remove(self._collect_back_references(remove_table_recs))
|
||||
self.doRemoveColumns(self._collect_back_references(remove_table_recs))
|
||||
|
||||
# Remove all view sections and fields for all tables being removed.
|
||||
# Bypass the check for raw data view sections.
|
||||
@ -1014,6 +1025,9 @@ class UserActions(object):
|
||||
if any(c.summarySourceCol for c in col_recs):
|
||||
raise ValueError("RemoveColumn: cannot remove a group-by column from a summary table")
|
||||
|
||||
self.doRemoveColumns(col_recs)
|
||||
|
||||
def doRemoveColumns(self, col_recs):
|
||||
# We need to remove group-by columns based on the columns being removed. To ensure we don't end
|
||||
# up with multiple summary tables with the same breakdown, we'll implement this by using
|
||||
# UpdateSummaryViewSection() on all the affected sections.
|
||||
@ -1030,7 +1044,7 @@ class UserActions(object):
|
||||
|
||||
# Remove this column from any sort specs to which it belongs.
|
||||
parent_sections = {section for c in col_recs for section in c.parentId.viewSections}
|
||||
removed_col_refs = set(row_ids)
|
||||
removed_col_refs = set((c.id for c in col_recs))
|
||||
re_sort_sections = []
|
||||
re_sort_specs = []
|
||||
for section in parent_sections:
|
||||
@ -1081,7 +1095,7 @@ class UserActions(object):
|
||||
|
||||
# Remove metadata records, but prepare schema actions before the metadata is cleared.
|
||||
removals = [actions.RemoveColumn(c.parentId.tableId, c.colId) for c in all_removals]
|
||||
self.doBulkRemoveRecord(table_id, [int(c) for c in all_removals])
|
||||
self.doBulkRemoveRecord('_grist_Tables_column', [int(c) for c in all_removals])
|
||||
|
||||
# Finally do the schema actions to remove the columns.
|
||||
for action in removals:
|
||||
|
@ -46,7 +46,7 @@
|
||||
--icon-Copy: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLDMgTDExLDE1IEwyLDE1IEwyLDMgTDExLDMgWiBNMTAsMy45OTkgTDMsMy45OTkgTDMsMTMuOTk5IEwxMCwxMy45OTkgTDEwLDMuOTk5IFogTTE0LDAgTDE0LDEyIEwxMS41LDEyIEwxMS41LDExIEwxMywxMSBMMTMsMSBMNiwxIEw2LDIuNSBMNSwyLjUgTDUsMCBMMTQsMCBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
||||
--icon-CrossBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjQxNDIxMzYsMTIgTDE5LjcwNzEwNjgsMTguMjkyODkzMiBDMjAuMDk3NjMxMSwxOC42ODM0MTc1IDIwLjA5NzYzMTEsMTkuMzE2NTgyNSAxOS43MDcxMDY4LDE5LjcwNzEwNjggQzE5LjMxNjU4MjUsMjAuMDk3NjMxMSAxOC42ODM0MTc1LDIwLjA5NzYzMTEgMTguMjkyODkzMiwxOS43MDcxMDY4IEwxMiwxMy40MTQyMTM2IEw1LjcwNzEwNjc4LDE5LjcwNzEwNjggQzUuMzE2NTgyNDksMjAuMDk3NjMxMSA0LjY4MzQxNzUxLDIwLjA5NzYzMTEgNC4yOTI4OTMyMiwxOS43MDcxMDY4IEMzLjkwMjM2ODkzLDE5LjMxNjU4MjUgMy45MDIzNjg5MywxOC42ODM0MTc1IDQuMjkyODkzMjIsMTguMjkyODkzMiBMMTAuNTg1Nzg2NCwxMiBMNC4yOTI4OTMyMiw1LjcwNzEwNjc4IEMzLjkwMjM2ODkzLDUuMzE2NTgyNDkgMy45MDIzNjg5Myw0LjY4MzQxNzUxIDQuMjkyODkzMjIsNC4yOTI4OTMyMiBDNC42ODM0MTc1MSwzLjkwMjM2ODkzIDUuMzE2NTgyNDksMy45MDIzNjg5MyA1LjcwNzEwNjc4LDQuMjkyODkzMjIgTDEyLDEwLjU4NTc4NjQgTDE4LjI5Mjg5MzIsNC4yOTI4OTMyMiBDMTguNjgzNDE3NSwzLjkwMjM2ODkzIDE5LjMxNjU4MjUsMy45MDIzNjg5MyAxOS43MDcxMDY4LDQuMjkyODkzMjIgQzIwLjA5NzYzMTEsNC42ODM0MTc1MSAyMC4wOTc2MzExLDUuMzE2NTgyNDkgMTkuNzA3MTA2OCw1LjcwNzEwNjc4IEwxMy40MTQyMTM2LDEyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||
--icon-CrossSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEyLjUsNy41IEMxMi43NzYxNDI0LDcuNSAxMyw3LjcyMzg1NzYzIDEzLDggQzEzLDguMjc2MTQyMzcgMTIuNzc2MTQyNCw4LjUgMTIuNSw4LjUgTDguNSw4LjUgTDguNSwxMi41IEM4LjUsMTIuNzc2MTQyNCA4LjI3NjE0MjM3LDEzIDgsMTMgQzcuNzIzODU3NjMsMTMgNy41LDEyLjc3NjE0MjQgNy41LDEyLjUgTDcuNSw4LjUgTDMuNSw4LjUgQzMuMjIzODU3NjMsOC41IDMsOC4yNzYxNDIzNyAzLDggQzMsNy43MjM4NTc2MyAzLjIyMzg1NzYzLDcuNSAzLjUsNy41IEw3LjUsNy41IEw3LjUsMy41IEM3LjUsMy4yMjM4NTc2MyA3LjcyMzg1NzYzLDMgOCwzIEM4LjI3NjE0MjM3LDMgOC41LDMuMjIzODU3NjMgOC41LDMuNSBMOC41LDcuNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0icm90YXRlKC00NSA4IDgpIi8+PC9zdmc+');
|
||||
--icon-Database: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48ZWxsaXBzZSBjeD0iMTIiIGN5PSI1IiByeD0iOSIgcnk9IjMiIHN0cm9rZS13aWR0aD0iMS41Ii8+PHBhdGggZD0iTTMgNXYxNGMwIDEuNjYgNCAzIDkgM3M5LTEuMzQgOS0zVjUiIHN0cm9rZS13aWR0aD0iMS41Ii8+PC9zdmc+');
|
||||
--icon-Database: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTYiIHdpZHRoPSIxNiI+PGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTIxMjEiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY2xhc3M9Im5jLWljb24td3JhcHBlciIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGVsbGlwc2UgY3g9IjgiIGN5PSIzIiByeD0iNi41IiByeT0iMi41IiBkYXRhLWNhcD0iYnV0dCIvPjxwYXRoIGQ9Ik0xLjUsNi41VjEzIGMwLDEuMzgxLDIuOTEsMi41LDYuNSwyLjVzNi41LTEuMTE5LDYuNS0yLjVWNi41IiBkYXRhLWNhcD0iYnV0dCIvPjwvZz48L3N2Zz4=');
|
||||
--icon-Dots: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS41IEM3LjE3MTU3Mjg4LDkuNSA2LjUsOC44Mjg0MjcxMiA2LjUsOCBDNi41LDcuMTcxNTcyODggNy4xNzE1NzI4OCw2LjUgOCw2LjUgQzguODI4NDI3MTIsNi41IDkuNSw3LjE3MTU3Mjg4IDkuNSw4IEM5LjUsOC44Mjg0MjcxMiA4LjgyODQyNzEyLDkuNSA4LDkuNSBaIE0xMi41LDkuNSBDMTEuNjcxNTcyOSw5LjUgMTEsOC44Mjg0MjcxMiAxMSw4IEMxMSw3LjE3MTU3Mjg4IDExLjY3MTU3MjksNi41IDEyLjUsNi41IEMxMy4zMjg0MjcxLDYuNSAxNCw3LjE3MTU3Mjg4IDE0LDggQzE0LDguODI4NDI3MTIgMTMuMzI4NDI3MSw5LjUgMTIuNSw5LjUgWiBNMy41LDkuNSBDMi42NzE1NzI4OCw5LjUgMiw4LjgyODQyNzEyIDIsOCBDMiw3LjE3MTU3Mjg4IDIuNjcxNTcyODgsNi41IDMuNSw2LjUgQzQuMzI4NDI3MTIsNi41IDUsNy4xNzE1NzI4OCA1LDggQzUsOC44Mjg0MjcxMiA0LjMyODQyNzEyLDkuNSAzLjUsOS41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||
--icon-Download: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEyLDEzLjI5Mjg5MzIgTDE0LjE0NjQ0NjYsMTEuMTQ2NDQ2NiBDMTQuMzQxNzA4OCwxMC45NTExODQ1IDE0LjY1ODI5MTIsMTAuOTUxMTg0NSAxNC44NTM1NTM0LDExLjE0NjQ0NjYgQzE1LjA0ODgxNTUsMTEuMzQxNzA4OCAxNS4wNDg4MTU1LDExLjY1ODI5MTIgMTQuODUzNTUzNCwxMS44NTM1NTM0IEwxMS44NTM1NTM0LDE0Ljg1MzU1MzQgQzExLjY1ODI5MTIsMTUuMDQ4ODE1NSAxMS4zNDE3MDg4LDE1LjA0ODgxNTUgMTEuMTQ2NDQ2NiwxNC44NTM1NTM0IEw4LjE0NjQ0NjYxLDExLjg1MzU1MzQgQzcuOTUxMTg0NDYsMTEuNjU4MjkxMiA3Ljk1MTE4NDQ2LDExLjM0MTcwODggOC4xNDY0NDY2MSwxMS4xNDY0NDY2IEM4LjM0MTcwODc2LDEwLjk1MTE4NDUgOC42NTgyOTEyNCwxMC45NTExODQ1IDguODUzNTUzMzksMTEuMTQ2NDQ2NiBMMTEsMTMuMjkyODkzMiBMMTEsNy41IEMxMSw3LjIyMzg1NzYzIDExLjIyMzg1NzYsNyAxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw3LjIyMzg1NzYzIDEyLDcuNSBMMTIsMTMuMjkyODkzMiBaIE0xLjA4NTM1Mjg1LDExIEMxLjI5MTI3MTA2LDExLjU4MjU5NjIgMS44NDY4OTA1OSwxMiAyLjUsMTIgTDYuNSwxMiBDNi43NzYxNDIzNywxMiA3LDEyLjIyMzg1NzYgNywxMi41IEM3LDEyLjc3NjE0MjQgNi43NzYxNDIzNywxMyA2LjUsMTMgTDIuNSwxMyBDMS4xMTkyODgxMywxMyAxLjM4Nzc3ODc4ZS0xNiwxMS44ODA3MTE5IDAsMTAuNSBDMCwxMC4yMjM4NTc2IDAuMjIzODU3NjI1LDEwIDAuNSwxMCBMNi41LDEwIEM2Ljc3NjE0MjM3LDEwIDcsMTAuMjIzODU3NiA3LDEwLjUgQzcsMTAuNzc2MTQyNCA2Ljc3NjE0MjM3LDExIDYuNSwxMSBMMS4wODUzNTI4NSwxMSBaIE0yLDguNSBDMiw4Ljc3NjE0MjM3IDEuNzc2MTQyMzcsOSAxLjUsOSBDMS4yMjM4NTc2Myw5IDEsOC43NzYxNDIzNyAxLDguNSBMMSwyLjUgQzEsMS42NzE1NzI4OCAxLjY3MTU3Mjg4LDEgMi41LDEgTDEzLjUsMSBDMTQuMzI4NDI3MSwxIDE1LDEuNjcxNTcyODggMTUsMi41IEwxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBDMTQuMjIzODU3Niw5IDE0LDguNzc2MTQyMzcgMTQsOC41IEwxNCwyLjUgQzE0LDIuMjIzODU3NjMgMTMuNzc2MTQyNCwyIDEzLjUsMiBMMi41LDIgQzIuMjIzODU3NjMsMiAyLDIuMjIzODU3NjMgMiwyLjUgTDIsOC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||
--icon-DragDrop: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNSwzIEM2Ljc3NjE0MjM3LDMgNywzLjIyMzg1NzYzIDcsMy41IEw3LDEyLjUgQzcsMTIuNzc2MTQyNCA2Ljc3NjE0MjM3LDEzIDYuNSwxMyBDNi4yMjM4NTc2MywxMyA2LDEyLjc3NjE0MjQgNiwxMi41IEw2LDMuNSBDNiwzLjIyMzg1NzYzIDYuMjIzODU3NjMsMyA2LjUsMyBaIE05LjUsMyBDOS43NzYxNDIzNywzIDEwLDMuMjIzODU3NjMgMTAsMy41IEwxMCwxMi41IEMxMCwxMi43NzYxNDI0IDkuNzc2MTQyMzcsMTMgOS41LDEzIEM5LjIyMzg1NzYzLDEzIDksMTIuNzc2MTQyNCA5LDEyLjUgTDksMy41IEM5LDMuMjIzODU3NjMgOS4yMjM4NTc2MywzIDkuNSwzIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||
|
@ -1,67 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="feather feather-database"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
sodipodi:docname="Database.svg"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
|
||||
<metadata
|
||||
id="metadata14">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs12" />
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1011"
|
||||
id="namedview10"
|
||||
showgrid="false"
|
||||
inkscape:zoom="27.812867"
|
||||
inkscape:cx="11.447057"
|
||||
inkscape:cy="12.185654"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg8" />
|
||||
<ellipse
|
||||
cx="12"
|
||||
cy="5"
|
||||
rx="9"
|
||||
ry="3"
|
||||
id="ellipse2"
|
||||
style="stroke-width:1.5;stroke-miterlimit:4;stroke-dasharray:none" />
|
||||
<path
|
||||
d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"
|
||||
id="path6"
|
||||
style="stroke-width:1.5;stroke-miterlimit:4;stroke-dasharray:none" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 16 16"><title>database</title><g stroke-width="1" stroke-linecap="round" fill="none" stroke="#212121" stroke-miterlimit="10" class="nc-icon-wrapper" stroke-linejoin="round"><ellipse cx="8" cy="3" rx="6.5" ry="2.5" data-cap="butt"></ellipse> <path d="M1.5,6.5V13 c0,1.381,2.91,2.5,6.5,2.5s6.5-1.119,6.5-2.5V6.5" data-cap="butt" stroke="#212121"></path> </g></svg>
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 438 B |
@ -125,11 +125,8 @@ describe('ActionLog', function() {
|
||||
await item2.find('table td:nth-child(2)').click();
|
||||
assert.equal(await gu.getActiveCell().getText(), 'f');
|
||||
|
||||
// Delete the page and table for Table1Renamed.
|
||||
await gu.openPageMenu('Table1Renamed');
|
||||
await driver.find('.grist-floating-menu .test-docpage-remove').click();
|
||||
await driver.findWait('.test-modal-confirm', 500).click();
|
||||
await gu.waitForServer();
|
||||
// Delete Table1Renamed.
|
||||
await gu.removeTable('Table1Renamed');
|
||||
await driver.findContent('.action_log label', /All tables/).find('input').click();
|
||||
|
||||
const item4 = await getActionLogItem(4);
|
||||
@ -143,6 +140,9 @@ describe('ActionLog', function() {
|
||||
|
||||
it("should filter cell changes and renames by table", async function() {
|
||||
// Have Table2, now add some more
|
||||
// We are at Raw Data view now (since we deleted a table).
|
||||
assert.match(await driver.getCurrentUrl(), /p\/data$/);
|
||||
await gu.getPageItem('Table2').click();
|
||||
await gu.enterGridRows({rowNum: 1, col: 0}, [['2']]);
|
||||
await gu.addNewTable(); // Table1
|
||||
await gu.enterGridRows({rowNum: 1, col: 0}, [['1']]);
|
||||
|
@ -186,11 +186,11 @@ export async function selectAll() {
|
||||
|
||||
/**
|
||||
* Returns a WebElementPromise for the .viewsection_content element for the section which contains
|
||||
* the given RegExp content.
|
||||
* the given text (case insensitive) content.
|
||||
*/
|
||||
export function getSection(sectionOrTitle: string|WebElement): WebElement|WebElementPromise {
|
||||
if (typeof sectionOrTitle !== 'string') { return sectionOrTitle; }
|
||||
return driver.find(`.test-viewsection-title[value="${sectionOrTitle}" i]`)
|
||||
return driver.findContent(`.test-viewsection-title`, new RegExp("^" + escapeRegExp(sectionOrTitle) + "$", 'i'))
|
||||
.findClosest('.viewsection_content');
|
||||
}
|
||||
|
||||
@ -198,8 +198,8 @@ export function getSection(sectionOrTitle: string|WebElement): WebElement|WebEle
|
||||
* Click into a section without disrupting cursor positions.
|
||||
*/
|
||||
export async function selectSectionByTitle(title: string) {
|
||||
await driver.find(`.test-viewsection-title[value="${title}" i]`)
|
||||
.findClosest('.viewsection_titletext_container').click();
|
||||
// .test-viewsection is a special 1px width element added for tests only.
|
||||
await driver.findContent(`.test-viewsection-title`, title).find(".test-viewsection-blank").click();
|
||||
}
|
||||
|
||||
|
||||
@ -845,12 +845,15 @@ export async function addNewTable() {
|
||||
}
|
||||
|
||||
export interface PageWidgetPickerOptions {
|
||||
summarize?: RegExp[]; // Optional list of patterns to match Group By columns.
|
||||
selectBy?: RegExp|string; // Optional pattern of SELECT BY option to pick.
|
||||
summarize?: (RegExp|string)[]; // Optional list of patterns to match Group By columns.
|
||||
}
|
||||
|
||||
// Add a new page using the 'Add New' menu and wait for the new page to be shown.
|
||||
export async function addNewPage(typeRe: RegExp, tableRe: RegExp, options?: PageWidgetPickerOptions) {
|
||||
export async function addNewPage(
|
||||
typeRe: RegExp|'Table'|'Card'|'Card List'|'Chart'|'Custom',
|
||||
tableRe: RegExp|string,
|
||||
options?: PageWidgetPickerOptions) {
|
||||
const url = await driver.getCurrentUrl();
|
||||
|
||||
// Click the 'Page' entry in the 'Add New' menu
|
||||
@ -874,9 +877,12 @@ export async function addNewSection(typeRe: RegExp, tableRe: RegExp, options?: P
|
||||
await selectWidget(typeRe, tableRe, options);
|
||||
}
|
||||
|
||||
// Select type and table that matches respectivelly typeRe and tableRe and save. The widget picker
|
||||
// Select type and table that matches respectively typeRe and tableRe and save. The widget picker
|
||||
// must be already opened when calling this function.
|
||||
export async function selectWidget(typeRe: RegExp, tableRe: RegExp, options: PageWidgetPickerOptions = {}) {
|
||||
export async function selectWidget(
|
||||
typeRe: RegExp|string,
|
||||
tableRe: RegExp|string,
|
||||
options: PageWidgetPickerOptions = {}) {
|
||||
|
||||
const tableEl = driver.findContent('.test-wselect-table', tableRe);
|
||||
|
||||
@ -937,11 +943,33 @@ export async function renamePage(oldName: string|RegExp, newName: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a table. TODO at the moment it's done by renaming the "primary" page for this table.
|
||||
* Once "raw data views" are supported, they will be used to rename tables.
|
||||
* Removes a page from the page menu, checks if the page is actually removable.
|
||||
*/
|
||||
export async function renameTable(oldName: RegExp|string, newName: string) {
|
||||
return renamePage(oldName, newName);
|
||||
export async function removePage(name: string|RegExp) {
|
||||
await openPageMenu(name);
|
||||
assert.equal(await driver.find('.test-docpage-remove').matches('.disabled'), false);
|
||||
await driver.find('.test-docpage-remove').click();
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a page can be removed.
|
||||
*/
|
||||
export async function canRemovePage(name: string|RegExp) {
|
||||
await openPageMenu(name);
|
||||
const isDisabled = await driver.find('.test-docpage-remove').matches('.disabled');
|
||||
await driver.sendKeys(Key.ESCAPE);
|
||||
return !isDisabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a table using exposed method from gristDoc. Use renameActiveTable to use the UI.
|
||||
*/
|
||||
export async function renameTable(tableId: string, newName: string) {
|
||||
await driver.executeScript(`
|
||||
return window.gristDocPageModel.gristDoc.get().renameTable(arguments[0], arguments[1]);
|
||||
`, tableId, newName);
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -955,6 +983,23 @@ export async function renameColumn(col: IColHeader, newName: string) {
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a table using RAW data view. Return back a current url.
|
||||
*/
|
||||
export async function removeTable(tableId: string) {
|
||||
const back = await driver.getCurrentUrl();
|
||||
await driver.find(".test-tools-raw").click();
|
||||
const tableIdList = await driver.findAll('.test-raw-data-table-id', e => e.getText());
|
||||
const tableIndex = tableIdList.indexOf(tableId);
|
||||
assert.isTrue(tableIndex >= 0, `No raw table with id ${tableId}`);
|
||||
const menus = await driver.findAll(".test-raw-data-table .test-raw-data-table-menu");
|
||||
assert.equal(menus.length, tableIdList.length);
|
||||
await menus[tableIndex].click();
|
||||
await driver.find(".test-raw-data-menu-remove").click();
|
||||
await driver.find(".test-modal-confirm").click();
|
||||
await waitForServer();
|
||||
return back;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the Undo button and wait for server. If optCount is given, click Undo that many times.
|
||||
@ -978,7 +1023,12 @@ export async function begin(invariant: () => any = () => true) {
|
||||
const start = await undoStackPointer();
|
||||
const previous = await invariant();
|
||||
return async () => {
|
||||
await undo(await undoStackPointer() - start);
|
||||
// We will be careful here and await every time for the server and check js errors.
|
||||
const count = await undoStackPointer() - start;
|
||||
for (let i = 0; i < count; ++i) {
|
||||
await undo();
|
||||
await checkForErrors();
|
||||
}
|
||||
assert.deepEqual(await invariant(), previous);
|
||||
};
|
||||
}
|
||||
@ -1127,17 +1177,12 @@ export async function searchPrev() {
|
||||
await driver.find('.test-tb-search-prev').click();
|
||||
}
|
||||
|
||||
export function getCurrentSectionName() {
|
||||
return driver.find('.active_section .test-viewsection-title').value();
|
||||
}
|
||||
|
||||
export function getCurrentPageName() {
|
||||
return driver.find('.test-treeview-itemHeader.selected').find('.test-docpage-label').getText();
|
||||
}
|
||||
|
||||
export async function getActiveRawTableName() {
|
||||
const title = await driver.findWait('.test-raw-data-overlay .test-viewsection-title', 100).value();
|
||||
return title;
|
||||
return await driver.findWait('.test-raw-data-overlay .test-viewsection-title', 100).getText();
|
||||
}
|
||||
|
||||
export function getSearchInput() {
|
||||
@ -1172,6 +1217,18 @@ export async function openRawTable(tableId: string) {
|
||||
await driver.find(`.test-raw-data-table .test-raw-data-table-id-${tableId}`).click();
|
||||
}
|
||||
|
||||
export async function renameRawTable(tableId: string, newName: string) {
|
||||
await driver.find(`.test-raw-data-table .test-raw-data-table-id-${tableId}`)
|
||||
.findClosest('.test-raw-data-table')
|
||||
.find('.test-raw-data-widget-title')
|
||||
.click();
|
||||
const input = await driver.find(".test-widget-title-table-name-input");
|
||||
await input.doClear();
|
||||
await input.click();
|
||||
await driver.sendKeys(newName, Key.ENTER);
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
export async function isRawTableOpened() {
|
||||
return await driver.find('.test-raw-data-close-button').isPresent();
|
||||
}
|
||||
@ -1428,10 +1485,6 @@ export async function getCurrentUrlId() {
|
||||
return decodeUrl({}, new URL(await driver.getCurrentUrl())).doc;
|
||||
}
|
||||
|
||||
export async function getActiveSectionTitle() {
|
||||
return driver.find('.active_section .test-viewsection-title').value();
|
||||
}
|
||||
|
||||
export function getToasts(): Promise<string[]> {
|
||||
return driver.findAll('.test-notifier-toast-wrapper', (el) => el.getText());
|
||||
}
|
||||
@ -2257,6 +2310,57 @@ export async function waitForAnchor() {
|
||||
await driver.wait(async () => (await getTestState()).anchorApplied, 2000);
|
||||
}
|
||||
|
||||
export async function getActiveSectionTitle(timeout?: number) {
|
||||
return await driver.findWait('.active_section .test-viewsection-title', timeout ?? 0).getText();
|
||||
}
|
||||
|
||||
export async function getSectionTitle(timeout?: number) {
|
||||
return await driver.findWait('.test-viewsection-title', timeout ?? 0).getText();
|
||||
}
|
||||
|
||||
export async function getSectionTitles() {
|
||||
return await driver.findAll('.test-viewsection-title', el => el.getText());
|
||||
}
|
||||
|
||||
export async function renameSection(sectionTitle: string, name: string) {
|
||||
const renameWidget = driver.findContent(`.test-viewsection-title`, sectionTitle);
|
||||
await renameWidget.find(".test-widget-title-text").click();
|
||||
await driver.find(".test-widget-title-section-name-input").click();
|
||||
await selectAll();
|
||||
await driver.sendKeys(name || Key.DELETE, Key.ENTER);
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
export async function renameActiveSection(name: string) {
|
||||
await driver.find(".active_section .test-viewsection-title .test-widget-title-text").click();
|
||||
await driver.find(".test-widget-title-section-name-input").click();
|
||||
await selectAll();
|
||||
await driver.sendKeys(name || Key.DELETE, Key.ENTER);
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames active data table using widget title popup (from active section).
|
||||
*/
|
||||
export async function renameActiveTable(name: string) {
|
||||
await driver.find(".active_section .test-viewsection-title .test-widget-title-text").click();
|
||||
await driver.find(".test-widget-title-table-name-input").click();
|
||||
await selectAll();
|
||||
await driver.sendKeys(name, Key.ENTER);
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
export async function setWidgetUrl(url: string) {
|
||||
await driver.find('.test-config-widget-url').click();
|
||||
// First clear textbox.
|
||||
await clearInput();
|
||||
if (url) {
|
||||
await sendKeys(url);
|
||||
}
|
||||
await sendKeys(Key.ENTER);
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
} // end of namespace gristUtils
|
||||
|
||||
stackWrapOwnMethods(gristUtils);
|
||||
|
Loading…
Reference in New Issue
Block a user