mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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;
|
||||
`);
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
69
app/client/components/RecordCardPopup.ts
Normal file
69
app/client/components/RecordCardPopup.ts
Normal 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;
|
||||
}
|
||||
`);
|
||||
@@ -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')
|
||||
)
|
||||
),
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
49
app/client/ui/CardContextMenu.ts
Normal file
49
app/client/ui/CardContextMenu.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
),
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', `
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`);
|
||||
|
||||
|
||||
@@ -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')),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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', `
|
||||
|
||||
Reference in New Issue
Block a user