(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:
Jarosław Sadziński
2022-04-27 19:46:24 +02:00
parent 8a1cca629b
commit 6f00106d7c
37 changed files with 946 additions and 452 deletions

View File

@@ -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;

View File

@@ -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;

View 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);
}
}
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -118,6 +118,7 @@
flex-shrink: 0;
padding: 0;
margin-top: -4px;
text-transform: uppercase;
}
.grist-single-record__menu__count {

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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();

View File

@@ -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', `

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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');
}

View File

@@ -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;
};

View File

@@ -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.

View File

@@ -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();
});
}

View File

@@ -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() || '';
})
);
}

View File

@@ -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.

View File

@@ -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')

View File

@@ -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}));
}

View File

@@ -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)),

View File

@@ -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')))

View 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;
}
`);

View File

@@ -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;
}),

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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;
}
}