(core) Record Cards

Summary:
Adds a new Record Card view section to each non-summary table, which can be from opened from various parts of the Grist UI to view and edit records in a popup card view.

Work is still ongoing, so the feature is locked away behind a flag; follow-up work is planned to finish up the implementation and add end-to-end tests.

Test Plan: Python and server tests. Browser tests will be included in a follow-up.

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4114
This commit is contained in:
George Gevoian
2023-11-19 19:46:32 -05:00
parent 2eec48b685
commit caf830db08
53 changed files with 1261 additions and 456 deletions

View File

@@ -1,23 +1,28 @@
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {setTestState} from 'app/client/lib/testState';
import {TableRec} from 'app/client/models/DocModel';
import {RECORD_CARDS} from 'app/client/models/features';
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable';
import {showTransientTooltip} from 'app/client/ui/tooltips';
import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
import {buildTableName} from 'app/client/ui/WidgetTitle';
import * as css from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {loadingDots} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {menu, menuDivider, menuIcon, menuItem, menuItemAsync, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
import {Computed, Disposable, dom, fromKo, makeTestId, observable, Observable, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
import * as weasel from 'popweasel';
const testId = makeTestId('test-raw-data-');
const t = makeT('DataTables');
const DATA_TABLES_TOOLTIP_KEY = 'dataTablesTooltip';
export class DataTables extends Disposable {
private _tables: Observable<TableRec[]>;
@@ -47,17 +52,19 @@ export class DataTables extends Disposable {
testId('list'),
cssHeader(t("Raw Data Tables")),
cssList(
dom.forEach(this._tables, tableRec =>
cssItem(
dom.forEach(this._tables, tableRec => {
const isEditingName = observable(false);
return cssTable(
dom.autoDispose(isEditingName),
testId('table'),
cssLeft(
cssTableIcon(
dom.domComputed((use) => cssTableTypeIcon(
use(tableRec.summarySourceTable) !== 0 ? 'PivotLight' : 'TypeTable',
testId(`table-id-${use(tableRec.tableId)}`)
)),
),
cssMiddle(
cssTitleRow(cssTableTitle(this._tableTitle(tableRec), testId('table-title'))),
cssTableNameAndId(
cssTitleRow(cssTableTitle(this._tableTitle(tableRec, isEditingName), testId('table-title'))),
cssDetailsRow(
cssTableIdWrapper(cssHoverWrapper(
cssUpperCase("Table ID: "),
@@ -76,14 +83,34 @@ export class DataTables extends Disposable {
setTestState({clipboard: tableRec.tableId.peek()});
})
)),
this._tableRows(tableRec),
),
),
cssRight(
docMenuTrigger(
this._tableRows(tableRec),
cssTableButtons(
cssRecordCardButton(
icon('TypeCard'),
dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
if (!tableRec.recordCardViewSection().disabled()) {
this._editRecordCard(tableRec);
}
}),
hoverTooltip(
dom.domComputed(use => use(use(tableRec.recordCardViewSection).disabled)
? t('Record Card Disabled')
: t('Record Card')),
{key: DATA_TABLES_TOOLTIP_KEY, closeOnClick: false}
),
dom.hide(!RECORD_CARDS()),
// Make the button invisible to maintain consistent alignment with non-summary tables.
dom.style('visibility', u => u(tableRec.summarySourceTable) === 0 ? 'visible' : 'hidden'),
cssRecordCardButton.cls('-disabled', use => use(use(tableRec.recordCardViewSection).disabled)),
),
cssDotsButton(
testId('table-menu'),
icon('Dots'),
menu(() => this._menuItems(tableRec), {placement: 'bottom-start'}),
menu(() => this._menuItems(tableRec, isEditingName), {placement: 'bottom-start'}),
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
)
),
@@ -94,14 +121,14 @@ export class DataTables extends Disposable {
}
this._gristDoc.viewModel.activeSectionId(sectionId);
})
)
)
);
})
),
),
);
}
private _tableTitle(table: TableRec) {
private _tableTitle(table: TableRec, isEditing: Observable<boolean>) {
return dom.domComputed((use) => {
const rawViewSectionRef = use(fromKo(table.rawViewSectionRef));
const isSummaryTable = use(table.summarySourceTable) !== 0;
@@ -113,37 +140,75 @@ export class DataTables extends Disposable {
].filter(p => Boolean(p?.trim())).join(' ');
return cssTableName(tableName);
} else {
return dom('div', // to disable flex grow in the widget
return cssFlexRow(
dom.domComputed(fromKo(table.rawViewSection), vs =>
buildTableName(vs, testId('widget-title'))
)
buildTableName(vs, {isEditing}, cssRenamableTableName.cls(''), testId('widget-title'))
),
cssRenameTableButton(icon('Pencil'),
dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
isEditing.set(true);
}),
cssRenameTableButton.cls('-active', isEditing),
),
);
}
});
}
private _menuItems(table: TableRec) {
private _menuItems(table: TableRec, isEditingName: Observable<boolean>) {
const {isReadonly, docModel} = this._gristDoc;
return [
menuItem(
() => { isEditingName.set(true); },
t("Rename Table"),
dom.cls('disabled', use => use(isReadonly) || use(table.summarySourceTable) !== 0),
testId('menu-rename-table'),
),
menuItem(
() => this._duplicateTable(table),
t("Duplicate Table"),
testId('menu-duplicate-table'),
dom.cls('disabled', use =>
use(isReadonly) ||
use(table.isHidden) ||
use(table.summarySourceTable) !== 0
),
testId('menu-duplicate-table'),
),
menuItem(
() => this._removeTable(table),
'Remove',
testId('menu-remove'),
t("Remove Table"),
dom.cls('disabled', use => use(isReadonly) || (
// Can't delete last visible table, unless it is a hidden table.
use(docModel.visibleTables.getObservable()).length <= 1 && !use(table.isHidden)
))
)),
testId('menu-remove-table'),
),
dom.maybe(use => RECORD_CARDS() && use(table.summarySourceTable) === 0, () => [
menuDivider(),
menuItem(
() => this._editRecordCard(table),
cssMenuItemIcon('TypeCard'),
t("Edit Record Card"),
dom.cls('disabled', use => use(isReadonly)),
testId('menu-edit-record-card'),
),
dom.domComputed(use => use(use(table.recordCardViewSection).disabled), (isDisabled) => {
return menuItemAsync(
async () => {
if (isDisabled) {
await this._enableRecordCard(table);
} else {
await this._disableRecordCard(table);
}
},
t('{{action}} Record Card', {action: isDisabled ? 'Enable' : 'Disable'}),
dom.cls('disabled', use => use(isReadonly)),
testId(`menu-${isDisabled ? 'enable' : 'disable'}-record-card`),
);
}),
]),
dom.maybe(isReadonly, () => menuText(t("You do not have edit access to this document"))),
];
}
@@ -166,6 +231,24 @@ export class DataTables extends Disposable {
), 'Delete', doRemove);
}
private _editRecordCard(r: TableRec) {
const sectionId = r.recordCardViewSection.peek().getRowId();
if (!sectionId) {
throw new Error(`Table ${r.tableId.peek()} doesn't have a record card view section.`);
}
this._gristDoc.viewModel.activeSectionId(sectionId);
commands.allCommands.editLayout.run();
}
private async _enableRecordCard(r: TableRec) {
await r.recordCardViewSection().disabled.setAndSave(false);
}
private async _disableRecordCard(r: TableRec) {
await r.recordCardViewSection().disabled.setAndSave(true);
}
private _tableRows(table: TableRec) {
return dom.maybe(this._rowCount, (rowCounts) => {
if (rowCounts === 'hidden') { return null; }
@@ -183,6 +266,18 @@ export class DataTables extends Disposable {
}
}
const cssMenuItemIcon = styled(menuIcon, `
--icon-color: ${css.theme.menuItemFg};
.${weasel.cssMenuItem.className}-sel & {
--icon-color: ${css.theme.menuItemSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled & {
--icon-color: ${css.theme.menuItemDisabledFg};
}
`);
const container = styled('div', `
overflow-y: auto;
position: relative;
@@ -198,42 +293,37 @@ const cssList = styled('div', `
gap: 12px;
`);
const cssItem = styled('div', `
display: flex;
align-items: center;
const cssTable = styled('div', `
display: grid;
grid-template-columns: 16px auto 100px 56px;
grid-template-rows: 1fr;
grid-column-gap: 8px;
cursor: pointer;
border-radius: 3px;
width: 100%;
height: calc(1em * 56/13); /* 56px for 13px font */
max-width: 750px;
padding: 0px 12px 0px 12px;
border: 1px solid ${css.theme.rawDataTableBorder};
&:hover {
border-color: ${css.theme.rawDataTableBorderHover};
}
`);
// Holds icon in top left corner
const cssLeft = styled('div', `
const cssTableIcon = styled('div', `
padding-top: 11px;
padding-left: 12px;
margin-right: 8px;
align-self: flex-start;
display: flex;
flex: none;
`);
const cssMiddle = styled('div', `
flex-grow: 1;
const cssTableNameAndId = styled('div', `
min-width: 0px;
display: flex;
flex-wrap: wrap;
margin-top: 6px;
margin-bottom: 4px;
flex-direction: column;
margin-top: 8px;
`);
const cssTitleRow = styled('div', `
min-width: 100%;
margin-right: 4px;
`);
const cssDetailsRow = styled('div', `
@@ -243,13 +333,12 @@ const cssDetailsRow = styled('div', `
`);
// 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;
// Holds dots menu (which is 24px x 24px)
const cssTableButtons = styled('div', `
display: flex;
flex: none;
align-items: center;
justify-content: flex-end;
column-gap: 8px;
`);
const cssTableTypeIcon = styled(icon, `
@@ -270,13 +359,10 @@ const cssTableIdWrapper = styled('div', `
const cssTableRowsWrapper = styled('div', `
display: flex;
flex-shrink: 0;
min-width: 100px;
overflow: hidden;
align-items: baseline;
align-items: center;
color: ${css.theme.lightText};
line-height: 18px;
padding: 0px 2px;
`);
const cssHoverWrapper = styled('div', `
@@ -301,6 +387,8 @@ const cssTableRows = cssTableId;
const cssTableTitle = styled('div', `
color: ${css.theme.text};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`);
@@ -327,3 +415,66 @@ const cssLoadingDots = styled(loadingDots, `
const cssTableName = styled('span', `
color: ${css.theme.text};
`);
const cssRecordCardButton = styled('div', `
display: flex;
align-items: center;
justify-content: center;
height: 24px;
width: 24px;
cursor: default;
padding: 4px;
border-radius: 3px;
--icon-color: ${css.theme.lightText};
&:hover {
background-color: ${css.theme.hover};
--icon-color: ${css.theme.controlFg};
}
&-disabled {
--icon-color: ${css.theme.lightText};
padding: 0px;
opacity: 0.4;
}
&-disabled:hover {
background: none;
--icon-color: ${css.theme.lightText};
}
`);
const cssDotsButton = styled(docMenuTrigger, `
margin: 0px;
&:hover, &.weasel-popup-open {
background-color: ${css.theme.hover};
}
`);
const cssRenameTableButton = styled('div', `
flex-shrink: 0;
width: 16px;
visibility: hidden;
cursor: default;
--icon-color: ${css.theme.lightText};
&:hover {
--icon-color: ${css.theme.controlFg};
}
&-active {
visibility: hidden;
}
.${cssTableTitle.className}:hover & {
visibility: visible;
}
`);
const cssFlexRow = styled('div', `
display: flex;
align-items: center;
column-gap: 8px;
`);
const cssRenamableTableName = styled('div', `
flex: initial;
`);

View File

@@ -17,8 +17,8 @@ const {CopySelection} = require('./CopySelection');
const RecordLayout = require('./RecordLayout');
const commands = require('./commands');
const tableUtil = require('../lib/tableUtil');
const {CardContextMenu} = require('../ui/CardContextMenu');
const {FieldContextMenu} = require('../ui/FieldContextMenu');
const {RowContextMenu} = require('../ui/RowContextMenu');
const {parsePasteForView} = require("./BaseView2");
const {descriptionInfoTooltip} = require("../ui/tooltips");
@@ -39,7 +39,7 @@ function DetailView(gristDoc, viewSectionModel) {
this.recordLayout = this.autoDispose(RecordLayout.create({
viewSection: this.viewSection,
buildFieldDom: this.buildFieldDom.bind(this),
buildRowContextMenu : this.buildRowContextMenu.bind(this),
buildCardContextMenu : this.buildCardContextMenu.bind(this),
buildFieldContextMenu : this.buildFieldContextMenu.bind(this),
resizeCallback: () => {
if (!this._isSingle) {
@@ -246,15 +246,14 @@ DetailView.prototype.getSelection = function() {
);
};
DetailView.prototype.buildRowContextMenu = function(row) {
const rowOptions = this._getRowContextMenuOptions(row);
return RowContextMenu(rowOptions);
DetailView.prototype.buildCardContextMenu = function(row) {
const cardOptions = this._getCardContextMenuOptions(row);
return CardContextMenu(cardOptions);
}
DetailView.prototype.buildFieldContextMenu = function(row) {
const rowOptions = this._getRowContextMenuOptions(row);
DetailView.prototype.buildFieldContextMenu = function() {
const fieldOptions = this._getFieldContextMenuOptions();
return FieldContextMenu(rowOptions, fieldOptions);
return FieldContextMenu(fieldOptions);
}
/**
@@ -490,8 +489,9 @@ DetailView.prototype._canSingleClick = function(field) {
};
DetailView.prototype._clearCardFields = function() {
const {isFormula} = this._getFieldContextMenuOptions();
if (isFormula === true) {
const selection = this.getSelection();
const isFormula = Boolean(selection.fields[0]?.column.peek().isRealFormula.peek());
if (isFormula) {
this.activateEditorAtCursor({init: ''});
} else {
const clearAction = tableUtil.makeDeleteAction(this.getSelection());
@@ -520,7 +520,7 @@ DetailView.prototype._clearCopySelection = function() {
this.copySelection(null);
};
DetailView.prototype._getRowContextMenuOptions = function(row) {
DetailView.prototype._getCardContextMenuOptions = function(row) {
return {
disableInsert: Boolean(
this.gristDoc.isReadonly.get() ||
@@ -542,7 +542,6 @@ DetailView.prototype._getFieldContextMenuOptions = function() {
return {
disableModify: Boolean(selection.fields[0]?.disableModify.peek()),
isReadonly: this.gristDoc.isReadonly.get() || this.isPreview,
isFormula: Boolean(selection.fields[0]?.column.peek().isRealFormula.peek()),
};
}

View File

@@ -55,6 +55,9 @@ const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles");
const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
const {makeT} = require('app/client/lib/localization');
const {reportError} = require('app/client/models/AppModel');
const {RECORD_CARDS} = require('app/client/models/features');
const {urlState} = require('app/client/models/gristUrlState');
const t = makeT('GridView');
@@ -370,7 +373,17 @@ GridView.gridCommands = {
return;
}
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
}
},
viewAsCard() {
if (!RECORD_CARDS()) { return; }
if (this._isRecordCardDisabled()) { return; }
const selectedRows = this.selectedRows();
const rowId = selectedRows[0];
const sectionId = this.viewSection.tableRecordCard().id();
const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}};
urlState().pushUrl(anchorUrlState, {replace: true}).catch(reportError);
},
};
GridView.prototype.onTableLoaded = function() {
@@ -1909,20 +1922,41 @@ GridView.prototype.rowContextMenu = function() {
GridView.prototype._getRowContextMenuOptions = function() {
return {
disableInsert: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand()),
disableDelete: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.getSelection().onlyAddRowSelected()),
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
numRows: this.getSelection().rowIds.length,
...this._getCellContextMenuOptions(),
disableShowRecordCard: this._isRecordCardDisabled(),
};
};
GridView.prototype._isRecordCardDisabled = function() {
return this.getSelection().onlyAddRowSelected() ||
this.viewSection.isTableRecordCardDisabled() ||
this.viewSection.table().summarySourceTable() !== 0;
}
GridView.prototype.cellContextMenu = function() {
return CellContextMenu(
this._getRowContextMenuOptions(),
this._getCellContextMenuOptions(),
this._getColumnMenuOptions(this.getSelection())
);
};
GridView.prototype._getCellContextMenuOptions = function() {
return {
disableInsert: Boolean(
this.gristDoc.isReadonly.get() ||
this.viewSection.disableAddRemoveRows() ||
this.tableModel.tableMetaRow.onDemand()
),
disableDelete: Boolean(
this.gristDoc.isReadonly.get() ||
this.viewSection.disableAddRemoveRows() ||
this.getSelection().onlyAddRowSelected()
),
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
numRows: this.getSelection().rowIds.length,
};
};
// End Context Menus
GridView.prototype.scrollToCursor = function(sync = true) {

View File

@@ -17,6 +17,7 @@ import {EditorMonitor} from "app/client/components/EditorMonitor";
import GridView from 'app/client/components/GridView';
import {importFromFile, selectAndImport} from 'app/client/components/Importer';
import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
import {RecordCardPopup} from 'app/client/components/RecordCardPopup';
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
@@ -125,7 +126,7 @@ export interface IExtraTool {
content: TabContent[] | IDomComponent;
}
interface RawSectionOptions {
interface PopupSectionOptions {
viewSection: ViewSectionRec;
hash: HashLink;
close: () => void;
@@ -179,7 +180,7 @@ export class GristDoc extends DisposableWithEvents {
// the space.
public maximizedSectionId: Observable<number | null> = Observable.create(this, null);
// This is id of the section that is currently shown in the popup. Probably this is an external
// section, like raw data view, or a section from another view..
// section, like raw data view, or a section from another view.
public externalSectionId: Computed<number | null>;
public viewLayout: ViewLayout | null = null;
@@ -201,15 +202,14 @@ export class GristDoc extends DisposableWithEvents {
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
private _rawSectionOptions: Observable<RawSectionOptions | null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage | RawSectionOptions>;
private _popupSectionOptions: Observable<PopupSectionOptions | null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage | PopupSectionOptions>;
private _docTutorialHolder = Holder.create<DocTutorial>(this);
private _isRickRowing: Observable<boolean> = Observable.create(this, false);
private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false);
private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this);
private _disableAutoStartingTours: boolean = false;
constructor(
public readonly app: App,
public readonly appModel: AppModel,
@@ -256,9 +256,9 @@ export class GristDoc extends DisposableWithEvents {
const viewId = this.docModel.views.tableData.findRow(docPage === 'GristDocTour' ? 'name' : 'id', docPage);
return viewId || use(defaultViewId);
});
this._activeContent = Computed.create(this, use => use(this._rawSectionOptions) ?? use(this.activeViewId));
this._activeContent = Computed.create(this, use => use(this._popupSectionOptions) ?? use(this.activeViewId));
this.externalSectionId = Computed.create(this, use => {
const externalContent = use(this._rawSectionOptions);
const externalContent = use(this._popupSectionOptions);
return externalContent ? use(externalContent.viewSection.id) : null;
});
// This viewModel reflects the currently active view, relying on the fact that
@@ -302,7 +302,7 @@ export class GristDoc extends DisposableWithEvents {
try {
if (state.hash.popup) {
if (state.hash.popup || state.hash.recordCard) {
await this.openPopup(state.hash);
} else {
// Navigate to an anchor if one is present in the url hash.
@@ -615,7 +615,17 @@ export class GristDoc extends DisposableWithEvents {
owner.autoDispose(this.activeViewId.addListener(content.close));
// In case the section is removed, close the popup.
content.viewSection.autoDispose({dispose: content.close});
return dom.create(RawDataPopup, this, content.viewSection, content.close);
const {recordCard} = content.hash;
if (recordCard) {
return dom.create(RecordCardPopup, {
gristDoc: this,
viewSection: content.viewSection,
onClose: content.close,
});
} else {
return dom.create(RawDataPopup, this, content.viewSection, content.close);
}
}) :
dom.create((owner) => {
this.viewLayout = ViewLayout.create(owner, this, content);
@@ -671,7 +681,11 @@ export class GristDoc extends DisposableWithEvents {
return;
}
// If this is completely unknown section (without a parent), it is probably an import preview.
if (!desiredSection.parentId.peek() && !desiredSection.isRaw.peek()) {
if (
!desiredSection.parentId.peek() &&
!desiredSection.isRaw.peek() &&
!desiredSection.isRecordCard.peek()
) {
const view = desiredSection.viewInstance.peek();
// Make sure we have a view instance here - it will prove our assumption that this is
// an import preview. Section might also be disconnected during undo/redo.
@@ -1215,7 +1229,8 @@ export class GristDoc extends DisposableWithEvents {
}, false, silent, visitedSections.concat([section.id.peek()]));
}
const view: ViewRec = section.view.peek();
const docPage: ViewDocPage = section.isRaw.peek() ? "data" : view.getRowId();
const isRawOrRecordCardView = section.isRaw.peek() || section.isRecordCard.peek();
const docPage: ViewDocPage = isRawOrRecordCardView ? 'data' : view.getRowId();
if (docPage != this.activeViewId.get()) {
await this.openDocPage(docPage);
}
@@ -1303,20 +1318,21 @@ export class GristDoc extends DisposableWithEvents {
// We need to make it active, so that cursor on this section will be the
// active one. This will change activeViewSectionId on a parent view of this section,
// which might be a diffrent view from what we currently have. If the section is
// a raw data section it will use `EmptyRowModel` as raw sections don't have parents.
// a raw data or record card section, it will use `EmptyRowModel` as these sections
// don't currently have parent views.
popupSection.hasFocus(true);
this._rawSectionOptions.set({
this._popupSectionOptions.set({
hash,
viewSection: popupSection,
close: () => {
// In case we are already close, do nothing.
if (!this._rawSectionOptions.get()) {
// In case we are already closed, do nothing.
if (!this._popupSectionOptions.get()) {
return;
}
if (popupSection !== prevSection) {
// We need to blur raw view section. Otherwise it will automatically be opened
// on raw data view. Note: raw data section doesn't have its own view, it uses
// empty row model as a parent (which feels like a hack).
// We need to blur the popup section. Otherwise it will automatically be opened
// on raw data view. Note: raw data and record card sections don't have parent views;
// they use the empty row model as a parent (which feels like a hack).
if (!popupSection.isDisposed()) {
popupSection.hasFocus(false);
}
@@ -1328,17 +1344,21 @@ export class GristDoc extends DisposableWithEvents {
prevSection.hasFocus(true);
}
}
// Clearing popup data will close this popup.
this._rawSectionOptions.set(null);
// Clearing popup section data will close this popup.
this._popupSectionOptions.set(null);
}
});
// If the anchor link is valid, set the cursor.
if (hash.colRef && hash.rowId) {
const fieldIndex = popupSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef);
if (fieldIndex >= 0) {
const view = await this._waitForView(popupSection);
view?.setCursorPos({rowId: hash.rowId, fieldIndex});
if (hash.rowId || hash.colRef) {
const {rowId} = hash;
let fieldIndex;
if (hash.colRef) {
const maybeFieldIndex = popupSection.viewFields.peek().all()
.findIndex(f => f.colRef.peek() === hash.colRef);
if (maybeFieldIndex !== -1) { fieldIndex = maybeFieldIndex; }
}
const view = await this._waitForView(popupSection);
view?.setCursorPos({rowId, fieldIndex});
}
}
@@ -1586,8 +1606,8 @@ export class GristDoc extends DisposableWithEvents {
*/
private async _switchToSectionId(sectionId: number) {
const section: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionId);
if (section.isRaw.peek()) {
// This is raw data view
if (section.isRaw.peek() || section.isRecordCard.peek()) {
// This is a raw data or record card view.
await urlState().pushUrl({docPage: 'data'});
this.viewModel.activeSectionId(sectionId);
} else if (section.isVirtual.peek()) {

View File

@@ -23,7 +23,7 @@ export class RawDataPage extends Disposable {
this.autoDispose(commands.createGroup(commandGroup, this, true));
this._lightboxVisible = Computed.create(this, use => {
const section = use(this._gristDoc.viewModel.activeSection);
return Boolean(use(section.id)) && use(section.isRaw);
return Boolean(use(section.id)) && (use(section.isRaw) || use(section.isRecordCard));
});
// 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.
@@ -55,7 +55,7 @@ export class RawDataPage extends Disposable {
/*************** Lightbox section **********/
dom.domComputed(fromKo(this._gristDoc.viewModel.activeSection), (viewSection) => {
const sectionId = viewSection.getRowId();
if (!sectionId || !viewSection.isRaw.peek()) {
if (!sectionId || (!viewSection.isRaw.peek() && !viewSection.isRecordCard.peek())) {
return null;
}
return dom.create(RawDataPopup, this._gristDoc, viewSection, () => this._close());
@@ -97,7 +97,9 @@ export class RawDataPopup extends Disposable {
sectionRowId: this._viewSection.getRowId(),
draggable: false,
focusable: false,
widgetNameHidden: this._viewSection.isRaw.peek(), // We are sometimes used for non raw sections.
// Expanded, non-raw widgets are also rendered in RawDataPopup.
widgetNameHidden: this._viewSection.isRaw.peek(),
renamable: !this._viewSection.isRecordCard.peek(),
})
),
cssCloseButton('CrossBig',
@@ -127,7 +129,7 @@ const cssPage = styled('div', `
}
`);
const cssOverlay = styled('div', `
export const cssOverlay = styled('div', `
background-color: ${theme.modalBackdrop};
inset: 0px;
height: 100%;
@@ -162,7 +164,7 @@ const cssSectionWrapper = styled('div', `
}
`);
const cssCloseButton = styled(icon, `
export const cssCloseButton = styled(icon, `
position: absolute;
top: 16px;
right: 16px;

View File

@@ -0,0 +1,69 @@
import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {cssCloseButton, cssOverlay} from 'app/client/components/RawDataPage';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {ViewSectionHelper} from 'app/client/components/ViewLayout';
import {theme} from 'app/client/ui2018/cssVars';
import {Disposable, dom, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-record-card-popup-');
interface RecordCardPopupOptions {
gristDoc: GristDoc;
viewSection: ViewSectionRec;
onClose(): void;
}
export class RecordCardPopup extends Disposable {
private _gristDoc = this._options.gristDoc;
private _viewSection = this._options.viewSection;
private _handleClose = this._options.onClose;
constructor(private _options: RecordCardPopupOptions) {
super();
const commandGroup = {
cancel: () => { this._handleClose(); },
};
this.autoDispose(commands.createGroup(commandGroup, this, true));
}
public buildDom() {
ViewSectionHelper.create(this, this._gristDoc, this._viewSection);
return cssOverlay(
testId('overlay'),
cssSectionWrapper(
buildViewSectionDom({
gristDoc: this._gristDoc,
sectionRowId: this._viewSection.getRowId(),
draggable: false,
focusable: false,
renamable: false,
hideTitleControls: true,
}),
),
cssCloseButton('CrossBig',
dom.on('click', () => this._handleClose()),
testId('close'),
),
dom.on('click', (ev, elem) => void (ev.target === elem ? this._handleClose() : null)),
);
}
}
const cssSectionWrapper = styled('div', `
background: ${theme.mainPanelBg};
height: 100%;
display: flex;
flex-direction: column;
border-radius: 5px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
& .viewsection_content {
margin: 0px;
margin-top: 8px;
}
& .viewsection_title {
padding: 0px 12px;
}
`);

View File

@@ -54,7 +54,7 @@ const t = makeT('RecordLayout');
function RecordLayout(options) {
this.viewSection = options.viewSection;
this.buildFieldDom = options.buildFieldDom;
this.buildRowContextMenu = options.buildRowContextMenu;
this.buildCardContextMenu = options.buildCardContextMenu;
this.buildFieldContextMenu = options.buildFieldContextMenu;
this.isEditingLayout = ko.observable(false);
this.editIndex = ko.observable(0);
@@ -342,7 +342,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.layoutEditor(null);
}) : null,
// enables field context menu anywhere on the card
contextMenu(() => this.buildFieldContextMenu(row)),
contextMenu(() => this.buildFieldContextMenu()),
dom('div.detail_row_num',
kd.text(() => (row._index() + 1)),
dom.on('contextmenu', ev => {
@@ -358,7 +358,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.viewSection.hasFocus(true);
commands.allCommands.setCursor.run(row);
}),
menu(() => this.buildRowContextMenu(row)),
menu(() => this.buildCardContextMenu(row)),
testId('card-menu-trigger')
)
),

View File

@@ -168,7 +168,7 @@ export class RefSelect extends Disposable {
this._getReferrerFields(item.value).forEach(refField => {
const sectionId = this._fieldObs()!.viewSection().getRowId();
if (refField.column().viewFields().all()
.filter(field => !field.viewSection().isRaw())
.filter(field => !field.viewSection().isRaw() && !field.viewSection().isRecordCard())
.some(field => field.parentId() !== sectionId)) {
// The col has fields in other sections, remove only the fields in this section.
return this._docModel.viewFields.sendTableAction(['RemoveRecord', refField.getRowId()]);

View File

@@ -8,7 +8,7 @@ var koArray = require('../lib/koArray');
var commands = require('./commands');
var {CustomSectionElement} = require('../lib/CustomSectionElement');
const {ChartConfig} = require('./ChartView');
const {Computed, dom: grainjsDom, makeTestId} = require('grainjs');
const {Computed, dom: grainjsDom, makeTestId, Holder} = require('grainjs');
const {cssRow} = require('app/client/ui/RightPanelStyles');
const {SortFilterConfig} = require('app/client/ui/SortFilterConfig');
@@ -37,6 +37,7 @@ function ViewConfigTab(options) {
var self = this;
this.gristDoc = options.gristDoc;
this.viewModel = options.viewModel;
this._viewSectionDataHolder = Holder.create(this);
// viewModel may point to different views, but viewSectionData is a single koArray reflecting
// the sections of the current view.
@@ -58,18 +59,21 @@ function ViewConfigTab(options) {
return this.viewModel.activeSection().parentKey() === 'custom';}, this));
this.isRaw = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().isRaw();}, this));
this.isRecordCard = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().isRecordCard();}, this));
this.activeRawSectionData = this.autoDispose(ko.computed(function() {
return self.isRaw() ? ViewSectionData.create(self.viewModel.activeSection()) : null;
this.activeRawOrRecordCardSectionData = this.autoDispose(ko.computed(function() {
return self.isRaw() || self.isRecordCard()
? self._viewSectionDataHolder.autoDispose(ViewSectionData.create(self.viewModel.activeSection()))
: null;
}));
this.activeSectionData = this.autoDispose(ko.computed(function() {
return (
_.find(self.viewSectionData.all(), function(sectionData) {
return sectionData.section &&
sectionData.section.getRowId() === self.viewModel.activeSectionId();
})
|| self.activeRawSectionData()
|| self.activeRawOrRecordCardSectionData()
|| self.viewSectionData.at(0)
);
}));

View File

@@ -205,14 +205,14 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
this._onResize();
// Reset active section to the first one if the section is popup is collapsed.
if (prev
&& this.viewModel.activeCollapsedSections.peek().includes(prev)
&& this.previousSectionId) {
// Make sure that previous section exists still.
if (this.viewModel.viewSections.peek().all()
.some(s => !s.isDisposed() && s.id.peek() === this.previousSectionId)) {
this.viewModel.activeSectionId(this.previousSectionId);
}
&& this.viewModel.activeCollapsedSections.peek().includes(prev)
&& this.previousSectionId) {
// Make sure that previous section exists still.
if (this.viewModel.viewSections.peek().all()
.some(s => !s.isDisposed() && s.id.peek() === this.previousSectionId)) {
this.viewModel.activeSectionId(this.previousSectionId);
}
}
} else {
// Otherwise resize only active one (the one in popup).
const section = this.viewModel.activeSection.peek();

View File

@@ -65,9 +65,21 @@ export function buildViewSectionDom(options: {
focusable?: boolean, /* defaults to true */
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
renamable?: boolean,
hideTitleControls?: boolean,
}) {
const isResizing = options.isResizing ?? Observable.create(null, false);
const {gristDoc, sectionRowId, viewModel, draggable = true, focusable = true} = options;
const {
gristDoc,
sectionRowId,
viewModel,
draggable = true,
focusable = true,
tableNameHidden,
widgetNameHidden,
renamable = true,
hideTitleControls = false,
} = options;
// Creating normal section dom
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
@@ -92,8 +104,13 @@ export function buildViewSectionDom(options: {
),
dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () =>
cssSigmaIcon('Pivot', testId('sigma'))),
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
viewInstance.buildTitleControls(),
buildWidgetTitle(
vs,
{tableNameHidden, widgetNameHidden, disabled: !renamable},
testId('viewsection-title'),
cssTestClick(testId("viewsection-blank")),
),
hideTitleControls ? null : viewInstance.buildTitleControls(),
dom('div.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc, vs)
)

View File

@@ -114,6 +114,7 @@ export type CommandName =
| 'clearCopySelection'
| 'detachEditor'
| 'activateAssistant'
| 'viewAsCard'
;
@@ -270,6 +271,11 @@ export const groups: CommendGroupDef[] = [{
keys: [],
desc: 'Activate assistant',
},
{
name: 'viewAsCard',
keys: [],
desc: 'Show the record card widget of the selected record',
},
]
}, {
group: 'Navigation',

View File

@@ -15,6 +15,7 @@ export interface TableRec extends IRowModel<"_grist_Tables"> {
primaryView: ko.Computed<ViewRec>;
rawViewSection: ko.Computed<ViewSectionRec>;
recordCardViewSection: ko.Computed<ViewSectionRec>;
summarySource: ko.Computed<TableRec>;
// A Set object of colRefs for all summarySourceCols of table.
@@ -52,6 +53,7 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
this.primaryView = refRecord(docModel.views, this.primaryViewId);
this.rawViewSection = refRecord(docModel.viewSections, this.rawViewSectionRef);
this.recordCardViewSection = refRecord(docModel.viewSections, this.recordCardViewSectionRef);
this.summarySource = refRecord(docModel.tables, this.summarySourceTable);
this.isHidden = this.autoDispose(
// This is repeated logic from isHiddenTable.

View File

@@ -85,6 +85,19 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// 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.
isRaw: ko.Computed<boolean>;
tableRecordCard: ko.Computed<ViewSectionRec>
isRecordCard: ko.Computed<boolean>;
/** True if this section is disabled. Currently only used by Record Card sections. */
disabled: modelUtil.KoSaveableObservable<boolean>;
/**
* True if the Record Card section of this section's table is disabled. Shortcut for
* `this.tableRecordCard().disabled()`.
*/
isTableRecordCardDisabled: ko.Computed<boolean>;
isVirtual: ko.Computed<boolean>;
isCollapsed: ko.Computed<boolean>;
@@ -443,7 +456,13 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
// 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.
this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.getRowId()));
this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.id()));
this.tableRecordCard = this.autoDispose(ko.pureComputed(() => this.table().recordCardViewSection()));
this.isRecordCard = this.autoDispose(ko.pureComputed(() =>
this.table().recordCardViewSectionRef() === this.id()));
this.disabled = modelUtil.fieldWithDefault(this.optionsObj.prop('disabled'), false);
this.isTableRecordCardDisabled = ko.pureComputed(() => this.tableRecordCard().disabled());
this.isVirtual = this.autoDispose(ko.pureComputed(() => typeof this.id() === 'string'));
@@ -818,7 +837,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
let newColInfo: NewColInfo;
await docModel.docData.bundleActions('Insert column', async () => {
newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action);
if (!this.isRaw.peek()) {
if (!this.isRaw.peek() && !this.isRecordCard.peek()) {
const fieldInfo = {
colRef: newColInfo.colRef,
parentId: this.id.peek(),

View File

@@ -33,3 +33,7 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
}
return G.window.PERMITTED_CUSTOM_WIDGETS;
}
export function RECORD_CARDS() {
return Boolean(getGristConfig().experimentalPlugins);
}

View File

@@ -0,0 +1,49 @@
import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { dom } from 'grainjs';
const t = makeT('CardContextMenu');
export interface ICardContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
numRows: number;
}
export function CardContextMenu({
disableInsert,
disableDelete,
isViewSorted,
numRows
}: ICardContextMenu) {
const result: Element[] = [];
if (isViewSorted) {
result.push(
menuItemCmd(allCommands.insertRecordAfter, t("Insert card"),
dom.cls('disabled', disableInsert)),
);
} else {
result.push(
menuItemCmd(allCommands.insertRecordBefore, t("Insert card above"),
dom.cls('disabled', disableInsert)),
menuItemCmd(allCommands.insertRecordAfter, t("Insert card below"),
dom.cls('disabled', disableInsert)),
);
}
result.push(
menuItemCmd(allCommands.duplicateRows, t("Duplicate card"),
dom.cls('disabled', disableInsert || numRows === 0)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.deleteRecords, t("Delete card"),
dom.cls('disabled', disableDelete)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.copyLink, t("Copy anchor link"))
);
return result;
}

View File

@@ -2,32 +2,36 @@ import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus';
import { IRowContextMenu } from 'app/client/ui/RowContextMenu';
import { COMMENTS } from 'app/client/models/features';
import { dom } from 'grainjs';
const t = makeT('CellContextMenu');
export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) {
export interface ICellContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
numRows: number;
}
const { disableInsert, disableDelete, isViewSorted } = rowOptions;
const { disableModify, isReadonly } = colOptions;
export function CellContextMenu(cellOptions: ICellContextMenu, colOptions: IMultiColumnContextMenu) {
const { disableInsert, disableDelete, isViewSorted, numRows } = cellOptions;
const { numColumns, disableModify, isReadonly, isFiltered } = colOptions;
// disableModify is true if the column is a summary column or is being transformed.
// isReadonly is true for readonly mode.
const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
const disableForReadonlyView = dom.cls('disabled', isReadonly);
const numCols: number = colOptions.numColumns;
const nameClearColumns = colOptions.isFiltered ?
t("Reset {{count}} entire columns", {count: numCols}) :
t("Reset {{count}} columns", {count: numCols});
const nameDeleteColumns = t("Delete {{count}} columns", {count: numCols});
const nameClearColumns = isFiltered ?
t("Reset {{count}} entire columns", {count: numColumns}) :
t("Reset {{count}} columns", {count: numColumns});
const nameDeleteColumns = t("Delete {{count}} columns", {count: numColumns});
const numRows: number = rowOptions.numRows;
const nameDeleteRows = t("Delete {{count}} rows", {count: numRows});
const nameClearCells = (numRows > 1 || numCols > 1) ? t("Clear values") : t("Clear cell");
const nameClearCells = (numRows > 1 || numColumns > 1) ? t("Clear values") : t("Clear cell");
const result: Array<Element|null> = [];
@@ -42,13 +46,13 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
...(
(numCols > 1 || numRows > 1) ? [] : [
(numColumns > 1 || numRows > 1) ? [] : [
menuDivider(),
menuItemCmd(allCommands.copyLink, t("Copy anchor link")),
menuDivider(),
menuItemCmd(allCommands.filterByThisCellValue, t("Filter by this value")),
menuItemCmd(allCommands.openDiscussion, t('Comment'), dom.cls('disabled', (
isReadonly || numRows === 0 || numCols === 0
isReadonly || numRows === 0 || numColumns === 0
)), dom.hide(use => !use(COMMENTS()))) //TODO: i18next
]
),

View File

@@ -1,6 +1,5 @@
import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {IRowContextMenu} from 'app/client/ui/RowContextMenu';
import {menuDivider, menuItemCmd} from 'app/client/ui2018/menus';
import {dom} from 'grainjs';
@@ -11,7 +10,7 @@ export interface IFieldContextMenu {
isReadonly: boolean;
}
export function FieldContextMenu(_rowOptions: IRowContextMenu, fieldOptions: IFieldContextMenu) {
export function FieldContextMenu(fieldOptions: IFieldContextMenu) {
const {disableModify, isReadonly} = fieldOptions;
const disableForReadonlyColumn = dom.cls('disabled', disableModify || isReadonly);
return [

View File

@@ -86,7 +86,7 @@ function removeView(activeDoc: GristDoc, viewId: number, pageName: string) {
const docData = activeDoc.docData;
// Create a set with tables on other pages (but not on this one).
const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels
.filter(vs => !vs.isRaw.peek() && vs.parentId.peek() !== viewId)
.filter(vs => !vs.isRaw.peek() && !vs.isRecordCard.peek() && vs.parentId.peek() !== viewId)
.map(vs => vs.tableRef.peek()));
// Check if this page is a last page for some tables.

View File

@@ -356,7 +356,10 @@ export class RightPanel extends Disposable {
dom.maybe(this._validSection, (activeSection) => (
buildConfigContainer(
subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) :
subTab === 'sortAndFilter' ? dom.create(this._buildPageSortFilterConfig.bind(this)) :
subTab === 'sortAndFilter' ? [
dom.create(this._buildPageSortFilterConfig.bind(this)),
cssConfigContainer.cls('-disabled', activeSection.isRecordCard),
] :
subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) :
null
)
@@ -397,33 +400,35 @@ export class RightPanel extends Disposable {
return dom.maybe(viewConfigTab, (vct) => [
this._disableIfReadonly(),
cssLabel(dom.text(use => use(activeSection.isRaw) ? t("DATA TABLE NAME") : t("WIDGET TITLE")),
dom.style('margin-bottom', '14px'),
),
cssRow(cssTextInput(
Computed.create(owner, (use) => use(activeSection.titleDef)),
val => activeSection.titleDef.saveOnly(val),
dom.boolAttr('disabled', use => {
const isRawTable = use(activeSection.isRaw);
const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0;
return isRawTable && isSummaryTable;
}),
testId('right-widget-title')
)),
dom.maybe(use => !use(activeSection.isRecordCard), () => [
cssLabel(dom.text(use => use(activeSection.isRaw) ? t("DATA TABLE NAME") : t("WIDGET TITLE")),
dom.style('margin-bottom', '14px'),
),
cssRow(cssTextInput(
Computed.create(owner, (use) => use(activeSection.titleDef)),
val => activeSection.titleDef.saveOnly(val),
dom.boolAttr('disabled', use => {
const isRawTable = use(activeSection.isRaw);
const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0;
return isRawTable && isSummaryTable;
}),
testId('right-widget-title')
)),
cssSection(
dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }),
),
cssSection(
dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }),
),
]),
dom.maybe(
(use) => !use(activeSection.isRaw),
(use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard),
() => cssRow(
primaryButton(t("Change Widget"), this._createPageWidgetPicker()),
cssRow.cls('-top-space')
),
),
cssSeparator(),
cssSeparator(dom.hide(activeSection.isRecordCard)),
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
cssLabel(t("Theme")),
@@ -744,7 +749,7 @@ export class RightPanel extends Disposable {
dom.hide((use) => !use(use(table).summarySourceTable)),
),
dom.maybe((use) => !use(activeSection.isRaw), () =>
dom.maybe((use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () =>
cssButtonRow(primaryButton(t("Edit Data Selection"), this._createPageWidgetPicker(),
testId('pwc-editDataSelection')),
dom.maybe(
@@ -764,9 +769,9 @@ export class RightPanel extends Disposable {
dom.maybe(viewConfigTab, (vct) => cssRow(
dom('div', vct._buildAdvancedSettingsDom()),
)),
cssSeparator(),
dom.maybe((use) => !use(activeSection.isRaw), () => [
dom.maybe((use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () => [
cssSeparator(),
cssLabel(t("SELECT BY")),
cssRow(
dom.update(
@@ -1033,6 +1038,10 @@ const cssConfigContainer = styled('div.test-config-container', `
& .fieldbuilder_settings {
margin: 16px 0 0 0;
}
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssDataLabel = styled('div', `

View File

@@ -1,6 +1,7 @@
import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { RECORD_CARDS } from 'app/client/models/features';
import { menuDivider, menuIcon, menuItemCmd, menuItemCmdLabel } from 'app/client/ui2018/menus';
import { dom } from 'grainjs';
const t = makeT('RowContextMenu');
@@ -8,12 +9,29 @@ const t = makeT('RowContextMenu');
export interface IRowContextMenu {
disableInsert: boolean;
disableDelete: boolean;
disableShowRecordCard: boolean;
isViewSorted: boolean;
numRows: number;
}
export function RowContextMenu({ disableInsert, disableDelete, isViewSorted, numRows }: IRowContextMenu) {
export function RowContextMenu({
disableInsert,
disableDelete,
disableShowRecordCard,
isViewSorted,
numRows
}: IRowContextMenu) {
const result: Element[] = [];
if (RECORD_CARDS() && numRows === 1) {
result.push(
menuItemCmd(
allCommands.viewAsCard,
() => menuItemCmdLabel(menuIcon('TypeCard'), t("View as card")),
dom.cls('disabled', disableShowRecordCard),
),
menuDivider(),
);
}
if (isViewSorted) {
// When the view is sorted, any newly added records get shifts instantly at the top or
// bottom. It could be very confusing for users who might expect the record to stay above or

View File

@@ -16,7 +16,7 @@ import {buildUrlId, isFeatureEnabled, parseUrlId} from 'app/common/gristUrls';
import * as roles from 'app/common/roles';
import {Document} from 'app/common/UserAPI';
import {dom, DomContents, styled} from 'grainjs';
import {MenuCreateFunc} from 'popweasel';
import {cssMenuItem, MenuCreateFunc} from 'popweasel';
import {makeT} from 'app/client/lib/localization';
const t = makeT('ShareMenu');
@@ -378,9 +378,12 @@ const cssMenuIconLink = styled('a', `
padding: 8px 24px;
--icon-color: ${theme.controlFg};
&:hover {
background-color: ${theme.hover};
--icon-color: ${theme.controlHoverFg};
.${cssMenuItem.className}-sel > & {
--icon-color: ${theme.menuItemIconSelectedFg};
}
.${cssMenuItem.className}.disabled & {
--icon-color: ${theme.menuItemDisabledFg};
}
`);

View File

@@ -58,6 +58,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
const showRawData = (use: UseCB) => {
return !use(viewSection.isRaw)// Don't show raw data if we're already in raw data.
&& !use(viewSection.isRecordCard)
&& !isSinglePage // Don't show raw data in single page mode.
;
};
@@ -88,20 +89,22 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
dom.maybe(!isSinglePage, () => [
menuDivider(),
menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter")),
menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection")),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)),
menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)),
]),
menuDivider(),
menuDivider(dom.hide(viewSection.isRecordCard)),
dom.maybe((use) => use(viewSection.parentKey) === 'custom' && use(viewSection.hasCustomOptions), () =>
menuItemCmd(allCommands.openWidgetConfiguration, t("Open configuration"),
testId('section-open-configuration')),
),
menuItemCmd(allCommands.collapseSection, t("Collapse widget"),
dom.cls('disabled', dontCollapseSection()),
dom.hide(viewSection.isRecordCard),
testId('section-collapse')),
menuItemCmd(allCommands.deleteSection, t("Delete widget"),
dom.cls('disabled', dontRemoveSection()),
dom.hide(viewSection.isRecordCard),
testId('section-delete')),
];
}

View File

@@ -69,6 +69,7 @@ export function viewSectionMenu(
&& use(gristDoc.maximizedSectionId) !== use(viewSection.id) // not in when we are maximized
&& use(gristDoc.externalSectionId) !== use(viewSection.id) // not in when we are external
&& !use(viewSection.isRaw) // not in raw mode
&& !use(viewSection.isRecordCard)
&& !use(singleVisible) // not in single section
;
});
@@ -145,6 +146,7 @@ export function viewSectionMenu(
ctl.close();
}),
]}),
dom.hide(viewSection.isRecordCard),
),
cssMenu(
testId('viewLayout'),

View File

@@ -7,7 +7,7 @@ import { theme } from 'app/client/ui2018/cssVars';
import {menuCssClass} from 'app/client/ui2018/menus';
import {ModalControl} from 'app/client/ui2018/modals';
import { Computed, dom, DomElementArg, makeTestId, Observable, styled } from 'grainjs';
import {IOpenController, setPopupToCreateDom} from 'popweasel';
import {IOpenController, IPopupOptions, PopupControl, setPopupToCreateDom} from 'popweasel';
import { descriptionInfoTooltip } from './tooltips';
import { autoGrow } from './forms';
import { cssInput, cssLabel, cssRenamePopup, cssTextArea } from 'app/client/ui/RenamePopupStyles';
@@ -18,41 +18,105 @@ const t = makeT('WidgetTitle');
interface WidgetTitleOptions {
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
disabled?: boolean,
}
export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) {
const title = Computed.create(null, use => use(vs.titleDef));
const description = Computed.create(null, use => use(vs.description));
return buildRenameWidget(vs, title, description, options, dom.autoDispose(title), ...args);
return buildRenamableTitle(vs, title, description, options, dom.autoDispose(title), ...args);
}
export function buildTableName(vs: ViewSectionRec, ...args: DomElementArg[]) {
interface TableNameOptions {
isEditing: Observable<boolean>,
disabled?: boolean,
}
export function buildTableName(vs: ViewSectionRec, options: TableNameOptions, ...args: DomElementArg[]) {
const title = Computed.create(null, use => use(use(vs.table).tableNameDef));
const description = Computed.create(null, use => use(vs.description));
return buildRenameWidget(vs, title, description, { widgetNameHidden: true }, dom.autoDispose(title), ...args);
return buildRenamableTitle(
vs,
title,
description,
{
openOnClick: false,
widgetNameHidden: true,
...options,
},
dom.autoDispose(title),
...args
);
}
export function buildRenameWidget(
interface RenamableTitleOptions {
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
/** Defaults to true. */
openOnClick?: boolean,
isEditing?: Observable<boolean>,
disabled?: boolean,
}
function buildRenamableTitle(
vs: ViewSectionRec,
title: Observable<string>,
description: Observable<string>,
options: WidgetTitleOptions,
...args: DomElementArg[]) {
options: RenamableTitleOptions,
...args: DomElementArg[]
) {
const {openOnClick = true, disabled = false, isEditing, ...renameTitleOptions} = options;
let popupControl: PopupControl | undefined;
return cssTitleContainer(
cssTitle(
testId('text'),
dom.text(title),
dom.on('click', () => {
// The popup doesn't close if `openOnClick` is false and the title is
// clicked. Make sure that it does.
if (!openOnClick) { popupControl?.close(); }
}),
// In case titleDef is all blank space, make it visible on hover.
cssTitle.cls("-empty", use => !use(title)?.trim()),
cssTitle.cls("-open-on-click", openOnClick),
cssTitle.cls("-disabled", disabled),
elem => {
setPopupToCreateDom(elem, ctl => buildWidgetRenamePopup(ctl, vs, options), {
if (disabled) { return; }
// The widget title popup can be configured to open in up to two ways:
// 1. When the title is clicked - done by setting `openOnClick` to `true`.
// 2. When `isEditing` is set to true - done by setting `isEditing` to `true`.
//
// Typically, the former should be set. The latter is useful for triggering the
// popup from a different part of the UI, like a menu item.
const trigger: IPopupOptions['trigger'] = [];
if (openOnClick) { trigger.push('click'); }
if (isEditing) {
trigger.push((_: Element, ctl: PopupControl) => {
popupControl = ctl;
ctl.autoDispose(isEditing.addListener((editing) => {
if (editing) {
ctl.open();
} else if (!ctl.isDisposed()) {
ctl.close();
}
}));
});
}
setPopupToCreateDom(elem, ctl => {
if (isEditing) {
ctl.onDispose(() => isEditing.set(false));
}
return buildRenameTitlePopup(ctl, vs, renameTitleOptions);
}, {
placement: 'bottom-start',
trigger: ['click'],
trigger,
attach: 'body',
boundaries: 'viewport',
});
},
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
openOnClick ? dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }) : null,
),
dom.maybe(description, () => [
descriptionInfoTooltip(description.get(), "widget")
@@ -61,7 +125,7 @@ export function buildRenameWidget(
);
}
function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, options: WidgetTitleOptions) {
function buildRenameTitlePopup(ctrl: IOpenController, vs: ViewSectionRec, options: RenamableTitleOptions) {
const tableRec = vs.table.peek();
// If the table is a summary table.
const isSummary = Boolean(tableRec.summarySourceTable.peek());
@@ -279,14 +343,16 @@ const cssTitleContainer = styled('div', `
`);
const cssTitle = styled('div', `
cursor: pointer;
overflow: hidden;
border-radius: 3px;
margin: -4px;
padding: 4px;
text-overflow: ellipsis;
align-self: start;
&:hover {
&-open-on-click:not(&-disabled) {
cursor: pointer;
}
&-open-on-click:not(&-disabled):hover {
background-color: ${theme.hover};
}
&-empty {

View File

@@ -9,7 +9,7 @@ import { IconName } from 'app/client/ui2018/IconList';
import { icon } from 'app/client/ui2018/icons';
import { cssSelectBtn } from 'app/client/ui2018/select';
import {
BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
BindableValue, Computed, dom, DomContents, DomElementArg, DomElementMethod, IDomArgs,
MaybeObsArray, MutableObsArray, Observable, styled
} from 'grainjs';
import debounce from 'lodash/debounce';
@@ -574,16 +574,27 @@ export const menuItemAsync: typeof weasel.menuItem = function(action, ...args) {
return menuItem(() => setTimeout(action, 0), ...args);
};
export function menuItemCmd(cmd: Command, label: string, ...args: DomElementArg[]) {
export function menuItemCmd(
cmd: Command,
label: string | (() => DomContents),
...args: DomElementArg[]
) {
return menuItem(
() => cmd.run(),
dom('span', label, testId('cmd-name')),
typeof label === 'string'
? dom('span', label, testId('cmd-name'))
: dom('div', label(), testId('cmd-name')),
cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null,
cssMenuItemCmd.cls(''), // overrides some menu item styles
...args
);
}
export const menuItemCmdLabel = styled('div', `
display: flex;
align-items: center;
`);
export function menuAnnotate(text: string, ...args: DomElementArg[]) {
return cssAnnotateMenuItem(text, ...args);
}
@@ -701,6 +712,15 @@ const cssInputButtonMenuElem = styled(cssMenuElem, `
const cssMenuItemCmd = styled('div', `
justify-content: space-between;
--icon-color: ${theme.menuItemFg};
.${weasel.cssMenuItem.className}-sel & {
--icon-color: ${theme.menuItemSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled & {
--icon-color: ${theme.menuItemDisabledFg};
}
`);
const cssCmdKey = styled('span', `
@@ -712,7 +732,7 @@ const cssCmdKey = styled('span', `
color: ${theme.menuItemIconSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled > & {
.${weasel.cssMenuItem.className}.disabled & {
color: ${theme.menuItemDisabledFg};
}
`);

View File

@@ -77,7 +77,11 @@ export class CellStyle extends Disposable {
}),
cssLine(
cssLabel(t('CELL STYLE')),
cssButton(t('Open row styles'), dom.on('click', allCommands.viewTabOpen.run)),
cssButton(
t('Open row styles'),
dom.on('click', allCommands.viewTabOpen.run),
dom.hide(!isTableWidget),
),
),
cssRow(
testId('cell-color-select'),

View File

@@ -1,12 +1,16 @@
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {TableRec} from 'app/client/models/DocModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {RECORD_CARDS} from 'app/client/models/features';
import {urlState} from 'app/client/models/gristUrlState';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {hideInPrintView, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, select} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {isFullReferencingType, isVersions} from 'app/common/gristTypes';
import {UIRowId} from 'app/plugin/GristAPI';
import {Computed, dom, styled} from 'grainjs';
@@ -16,6 +20,7 @@ const t = makeT('Reference');
* Reference - The widget for displaying references to another table's records.
*/
export class Reference extends NTextBox {
private _refTable: Computed<TableRec | null>;
private _visibleColRef: Computed<number>;
private _validCols: Computed<Array<IOptionFull<number>>>;
@@ -26,8 +31,10 @@ export class Reference extends NTextBox {
// Note that saveOnly is used here to prevent display value flickering on visible col change.
this._visibleColRef.onWrite((val) => this.field.visibleColRef.saveOnly(val));
this._refTable = Computed.create(this, (use) => use(use(this.field.column).refTable));
this._validCols = Computed.create(this, (use) => {
const refTable = use(use(this.field.column).refTable);
const refTable = use(this._refTable);
if (!refTable) { return []; }
return use(use(refTable.columns).getObservable())
.filter(col => !use(col.isHiddenCol))
@@ -75,16 +82,16 @@ export class Reference extends NTextBox {
return id && use(id);
});
const formattedValue = Computed.create(null, (use) => {
let [value, hasBlankReference] = ['', false];
let [value, hasBlankReference, hasRecordCard] = ['', false, false];
if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {
// Work around JS errors during certain changes (noticed when visibleCol field gets removed
// for a column using per-field settings).
return {value, hasBlankReference};
return {value, hasBlankReference, hasRecordCard};
}
const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)];
if (!displayValueObs) {
return {value, hasBlankReference};
return {value, hasBlankReference, hasRecordCard};
}
const displayValue = use(displayValueObs);
@@ -97,8 +104,12 @@ export class Reference extends NTextBox {
use(this.field.formatter).formatAny(displayValue);
hasBlankReference = referenceId.get() !== 0 && value.trim() === '';
const refTable = use(this._refTable);
if (refTable) {
hasRecordCard = !use(use(refTable.recordCardViewSection).disabled);
}
return {value, hasBlankReference};
return {value, hasBlankReference, hasRecordCard};
});
return cssRef(
@@ -107,12 +118,39 @@ export class Reference extends NTextBox {
cssRef.cls('-blank', use => use(formattedValue).hasBlankReference),
dom.style('text-align', this.alignment),
dom.cls('text_wrapping', this.wrapping),
cssRefIcon('FieldReference', testId('ref-link-icon'), hideInPrintView()),
dom.text(use => {
if (use(referenceId) === 0) { return ''; }
if (use(formattedValue).hasBlankReference) { return '[Blank]'; }
return use(formattedValue).value;
})
cssRefIcon('FieldReference',
cssRefIcon.cls('-view-as-card', use =>
RECORD_CARDS() && use(referenceId) !== 0 && use(formattedValue).hasRecordCard),
dom.on('click', async () => {
if (!RECORD_CARDS()) { return; }
if (referenceId.get() === 0 || !formattedValue.get().hasRecordCard) { return; }
const rowId = referenceId.get() as UIRowId;
const sectionId = this._refTable.get()?.recordCardViewSectionRef();
if (sectionId === undefined) {
throw new Error('Unable to find Record Card section');
}
const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}};
await urlState().pushUrl(anchorUrlState, {replace: true});
}),
dom.on('mousedown', (ev) => {
if (!RECORD_CARDS()) { return; }
ev.stopPropagation();
ev.preventDefault();
}),
hideInPrintView(),
testId('ref-link-icon'),
),
dom('span',
dom.text(use => {
if (use(referenceId) === 0) { return ''; }
if (use(formattedValue).hasBlankReference) { return '[Blank]'; }
return use(formattedValue).value;
}),
testId('ref-text'),
),
);
}
}
@@ -121,6 +159,13 @@ const cssRefIcon = styled(icon, `
float: left;
--icon-color: ${theme.lightText};
margin: -1px 2px 2px 0;
&-view-as-card {
cursor: pointer;
}
&-view-as-card:hover {
--icon-color: ${theme.controlFg};
}
`);
const cssRef = styled('div.field_clip', `

View File

@@ -290,9 +290,15 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
queryParams[`${k}_`] = v;
}
const hashParts: string[] = [];
if (state.hash && (state.hash.rowId || state.hash.popup)) {
if (state.hash && (state.hash.rowId || state.hash.popup || state.hash.recordCard)) {
const hash = state.hash;
hashParts.push(state.hash?.popup ? 'a2' : `a1`);
if (hash.recordCard) {
hashParts.push('a3');
} else if (hash.popup) {
hashParts.push('a2');
} else {
hashParts.push('a1');
}
for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof HashLink>) {
const partValue = hash[key];
if (partValue) {
@@ -480,13 +486,13 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
hashMap.set(part.slice(0, 1), part.slice(1));
}
}
if (hashMap.has('#') && ['a1', 'a2'].includes(hashMap.get('#') || '')) {
if (hashMap.has('#') && ['a1', 'a2', 'a3'].includes(hashMap.get('#') || '')) {
const link: HashLink = {};
const keys = [
'sectionId',
'rowId',
'colRef',
] as Array<Exclude<keyof HashLink, 'popup' | 'rickRow'>>;
] as Array<Exclude<keyof HashLink, 'popup' | 'rickRow' | 'recordCard'>>;
for (const key of keys) {
let ch: string;
if (key === 'rowId' && hashMap.has('rr')) {
@@ -504,7 +510,9 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
}
}
if (hashMap.get('#') === 'a2') {
link.popup = true;
link.popup = true;
} else if (hashMap.get('#') === 'a3') {
link.recordCard = true;
}
state.hash = link;
}
@@ -722,6 +730,8 @@ export interface GristLoadConfig {
// Whether to show the "Delete Account" button in the account page.
canCloseAccount?: boolean;
experimentalPlugins?: boolean;
}
export const Features = StringUnion(
@@ -966,6 +976,7 @@ export interface HashLink {
colRef?: number;
popup?: boolean;
rickRow?: boolean;
recordCard?: boolean;
}
// Check whether a urlId is a prefix of the docId, and adequately long to be

View File

@@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 39;
export const SCHEMA_VERSION = 40;
export const schema = {
@@ -23,6 +23,7 @@ export const schema = {
summarySourceTable : "Ref:_grist_Tables",
onDemand : "Bool",
rawViewSectionRef : "Ref:_grist_Views_section",
recordCardViewSectionRef: "Ref:_grist_Views_section",
},
"_grist_Tables_column": {
@@ -234,6 +235,7 @@ export interface SchemaTypes {
summarySourceTable: number;
onDemand: boolean;
rawViewSectionRef: number;
recordCardViewSectionRef: number;
};
"_grist_Tables_column": {

View File

@@ -6,8 +6,8 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',39,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_DocInfo VALUES(1,'','','',40,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_External_database" (id INTEGER PRIMARY KEY, "host" TEXT DEFAULT '', "port" INTEGER DEFAULT 0, "username" TEXT DEFAULT '', "dialect" TEXT DEFAULT '', "database" TEXT DEFAULT '', "storage" TEXT DEFAULT '');
@@ -43,9 +43,9 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',39,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2);
INSERT INTO _grist_DocInfo VALUES(1,'','','',40,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
INSERT INTO _grist_Tables_column VALUES(1,1,1,'manualSort','ManualSortPos','',0,'','manualSort','',0,0,0,0,NULL,0,NULL);
INSERT INTO _grist_Tables_column VALUES(2,1,2,'A','Any','',1,'','A','',0,0,0,0,NULL,0,NULL);
@@ -65,6 +65,7 @@ INSERT INTO _grist_Views VALUES(1,'Table1','raw_data','');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
INSERT INTO _grist_Views_section VALUES(1,1,1,'record','','',100,1,'','','','','','[]',0,0,0,'',NULL);
INSERT INTO _grist_Views_section VALUES(2,1,0,'record','','',100,1,'','','','','','',0,0,0,'',NULL);
INSERT INTO _grist_Views_section VALUES(3,1,0,'single','','',100,1,'','','','','','',0,0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
INSERT INTO _grist_Views_section_field VALUES(1,1,1,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(2,1,2,3,0,'',0,0,'',NULL);
@@ -72,6 +73,9 @@ INSERT INTO _grist_Views_section_field VALUES(3,1,3,4,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(4,2,4,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(5,2,5,3,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(6,2,6,4,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(7,3,7,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(8,3,8,3,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(9,3,9,4,0,'',0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);

View File

@@ -86,6 +86,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
deploymentType: server?.getDeploymentType(),
templateOrg: getTemplateOrg(),
canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE),
experimentalPlugins: isAffirmative(process.env.GRIST_EXPERIMENTAL_PLUGINS),
...extra,
};
}