(core) Polish Record Cards

Summary:
Improvements
 - Widget and column descriptions are now copied when duplicating a table.
 - A Grist Plugin API command to open a Record Card is now available.
 - New Card widgets set initial settings based on those used by their table's
 Record Card.

Fixes
 - Opening a reference in a Record Card from a Raw Data popup now opens
 the correct reference.

Test Plan: Browser and python tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4164
This commit is contained in:
George Gevoian
2024-01-30 09:49:00 -05:00
parent 11afc08f65
commit b1f7ca353a
19 changed files with 361 additions and 152 deletions

View File

@@ -816,5 +816,18 @@ BaseView.prototype._duplicateRows = async function() {
return result;
}
BaseView.prototype.viewSelectedRecordAsCard = function() {
if (this.isRecordCardDisabled()) { return; }
const colRef = this.viewSection.viewFields().at(this.cursor.fieldIndex()).column().id();
const rowId = this.viewData.getRowId(this.cursor.rowIndex());
const sectionId = this.viewSection.tableRecordCard().id();
const anchorUrlState = {hash: {colRef, rowId, sectionId, recordCard: true}};
urlState().pushUrl(anchorUrlState, {replace: true}).catch(reportError);
}
BaseView.prototype.isRecordCardDisabled = function() {
return this.viewSection.isTableRecordCardDisabled();
}
module.exports = BaseView;

View File

@@ -70,6 +70,17 @@ export class CustomView extends Disposable {
}
}
},
async viewAsCard(event: Event) {
if (event instanceof KeyboardEvent) {
// Ignore the keyboard shortcut if pressed; it's disabled at this time for custom widgets.
return;
}
(this as unknown as BaseView).viewSelectedRecordAsCard();
// Move focus back to the app, so that keyboard shortcuts work in the popup.
document.querySelector<HTMLElement>('textarea.copypaste.mousetrap')?.focus();
},
};
/**
* The HTMLElement embedding the content.

View File

@@ -55,8 +55,6 @@ 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 {urlState} = require('app/client/models/gristUrlState');
const t = makeT('GridView');
@@ -374,16 +372,10 @@ GridView.gridCommands = {
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
},
viewAsCard() {
if (this._isRecordCardDisabled()) { return; }
const selectedRows = this.selectedRows();
if (selectedRows.length !== 1) { return; }
const colRef = this.viewSection.viewFields().at(this.cursor.fieldIndex()).column().id();
const rowId = selectedRows[0];
const sectionId = this.viewSection.tableRecordCard().id();
const anchorUrlState = {hash: {colRef, rowId, sectionId, recordCard: true}};
urlState().pushUrl(anchorUrlState, {replace: true}).catch(reportError);
this.viewSelectedRecordAsCard();
},
};
@@ -1924,14 +1916,13 @@ GridView.prototype.rowContextMenu = function() {
GridView.prototype._getRowContextMenuOptions = function() {
return {
...this._getCellContextMenuOptions(),
disableShowRecordCard: this._isRecordCardDisabled(),
disableShowRecordCard: this.isRecordCardDisabled(),
};
};
GridView.prototype._isRecordCardDisabled = function() {
return this.getSelection().onlyAddRowSelected() ||
this.viewSection.isTableRecordCardDisabled() ||
this.viewSection.table().summarySourceTable() !== 0;
GridView.prototype.isRecordCardDisabled = function() {
return BaseView.prototype.isRecordCardDisabled.call(this) ||
this.getSelection().onlyAddRowSelected();
}
GridView.prototype.cellContextMenu = function() {

View File

@@ -206,7 +206,7 @@ export class GristDoc extends DisposableWithEvents {
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
private _popupSectionOptions: Observable<PopupSectionOptions | null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage | PopupSectionOptions>;
private _activeContent: Computed<IDocPage>;
private _docTutorialHolder = Holder.create<DocTutorial>(this);
private _isRickRowing: Observable<boolean> = Observable.create(this, false);
private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false);
@@ -261,7 +261,7 @@ 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._popupSectionOptions) ?? use(this.activeViewId));
this._activeContent = Computed.create(this, use => use(this.activeViewId));
this.externalSectionId = Computed.create(this, use => {
const externalContent = use(this._popupSectionOptions);
return externalContent ? use(externalContent.viewSection.id) : null;
@@ -308,7 +308,7 @@ export class GristDoc extends DisposableWithEvents {
try {
if (state.hash.popup || state.hash.recordCard) {
await this.openPopup(state.hash);
await this._openPopup(state.hash);
} else {
// Navigate to an anchor if one is present in the url hash.
const cursorPos = this._getCursorPosFromHash(state.hash);
@@ -343,7 +343,7 @@ export class GristDoc extends DisposableWithEvents {
}
this.behavioralPromptsManager.showTip(cursor, 'rickRow', {
onDispose: () => this.playRickRollVideo(),
onDispose: () => this._playRickRollVideo(),
});
})
.catch(reportError);
@@ -602,7 +602,7 @@ export class GristDoc extends DisposableWithEvents {
const isPopup = Computed.create(this, use => {
return ['data', 'settings'].includes(use(this.activeViewId) as any) // On Raw data or doc settings pages
|| use(isMaximized) // Layout has a maximized section visible
|| typeof use(this._activeContent) === 'object'; // We are on show raw data popup
|| Boolean(use(this._popupSectionOptions)); // Layout has a popup section visible
});
return cssViewContentPane(
testId('gristdoc'),
@@ -623,43 +623,48 @@ export class GristDoc extends DisposableWithEvents {
content === 'settings' ? dom.create(DocSettingsPage, this) :
content === 'webhook' ? dom.create(WebhookPage, this) :
content === 'GristDocTour' ? null :
(typeof content === 'object') ? dom.create(owner => {
// In case user changes a page, close the popup.
owner.autoDispose(this.activeViewId.addListener(content.close));
// In case the section is removed, close the popup.
content.viewSection.autoDispose({dispose: content.close});
[
dom.create((owner) => {
this.viewLayout = ViewLayout.create(owner, this, content);
this.viewLayout.maximized.addListener(sectionId => {
this.maximizedSectionId.set(sectionId);
const {recordCard, rowId} = content.hash;
if (recordCard) {
if (!rowId || rowId === 'new') {
// Should be unreachable, but just to be sure (and to satisfy type checking)...
throw new Error('Unable to open Record Card: undefined row id');
}
return dom.create(RecordCardPopup, {
gristDoc: this,
rowId,
viewSection: content.viewSection,
onClose: content.close,
if (sectionId === null && !this._isShowingPopupSection) {
// If we didn't navigate to another section in the popup, move focus
// back to the previous section.
this._focusPreviousSection();
}
});
} else {
return dom.create(RawDataPopup, this, content.viewSection, content.close);
}
}) :
dom.create((owner) => {
this.viewLayout = ViewLayout.create(owner, this, content);
this.viewLayout.maximized.addListener(sectionId => {
this.maximizedSectionId.set(sectionId);
owner.onDispose(() => this.viewLayout = null);
return this.viewLayout;
}),
dom.maybe(this._popupSectionOptions, (popupOptions) => {
return dom.create((owner) => {
// In case user changes a page, close the popup.
owner.autoDispose(this.activeViewId.addListener(popupOptions.close));
if (sectionId === null && !this._isShowingPopupSection) {
// If we didn't navigate to another section in the popup, move focus
// back to the previous section.
this._focusPreviousSection();
}
});
owner.onDispose(() => this.viewLayout = null);
return this.viewLayout;
})
// In case the section is removed, close the popup.
popupOptions.viewSection.autoDispose({dispose: popupOptions.close});
const {recordCard, rowId} = popupOptions.hash;
if (recordCard) {
if (!rowId || rowId === 'new') {
// Should be unreachable, but just to be sure (and to satisfy type checking)...
throw new Error('Unable to open Record Card: undefined row id');
}
return dom.create(RecordCardPopup, {
gristDoc: this,
rowId,
viewSection: popupOptions.viewSection,
onClose: popupOptions.close,
});
} else {
return dom.create(RawDataPopup, this, popupOptions.viewSection, popupOptions.close);
}
});
}),
]
);
}),
dom.maybe(this._showBackgroundVideoPlayer, () => [
@@ -1371,10 +1376,36 @@ export class GristDoc extends DisposableWithEvents {
await tableRec.tableName.saveOnly(newTableName);
}
/**
* Creates computed with all the data for the given column.
*/
public columnObserver(owner: IDisposableOwner, tableId: Observable<string>, columnId: Observable<string>) {
const tableModel = Computed.create(owner, (use) => this.docModel.dataTables[use(tableId)]);
const refreshed = Observable.create(owner, 0);
const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1);
const holder = Holder.create(owner);
const listener = (tab: TableModel) => {
// Now subscribe to any data change in that table.
const subs = MultiHolder.create(holder);
subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle));
subs.autoDispose(tab.tableData.tableActionEmitter.addListener(toggle));
tab.fetch().catch(reportError);
};
owner.autoDispose(tableModel.addListener(listener));
listener(tableModel.get());
const values = Computed.create(owner, refreshed, (use) => {
const rows = use(tableModel).getAllRows();
const colValues = use(tableModel).tableData.getColValues(use(columnId));
if (!colValues) { return []; }
return rows.map((row, i) => [row, colValues[i]]);
});
return values;
}
/**
* Opens popup with a section data (used by Raw Data view).
*/
public async openPopup(hash: HashLink) {
private async _openPopup(hash: HashLink) {
// We can only open a popup for a section.
if (!hash.sectionId) {
return;
@@ -1386,13 +1417,17 @@ export class GristDoc extends DisposableWithEvents {
if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) {
this.viewModel.activeSectionId(hash.sectionId);
// If the anchor link is valid, set the cursor.
if (hash.colRef && hash.rowId) {
if (hash.colRef || hash.rowId) {
const activeSection = this.viewModel.activeSection.peek();
const fieldIndex = activeSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef);
if (fieldIndex >= 0) {
const view = await this._waitForView(activeSection);
view?.setCursorPos({rowId: hash.rowId, fieldIndex});
const {rowId} = hash;
let fieldIndex = undefined;
if (hash.colRef) {
const maybeFieldIndex = activeSection.viewFields.peek().all()
.findIndex(f => f.colRef.peek() === hash.colRef);
if (maybeFieldIndex !== -1) { fieldIndex = maybeFieldIndex; }
}
const view = await this._waitForView(activeSection);
view?.setCursorPos({rowId, fieldIndex});
}
this.viewLayout?.maximized.set(hash.sectionId);
return;
@@ -1451,7 +1486,7 @@ export class GristDoc extends DisposableWithEvents {
/**
* Starts playing the music video for Never Gonna Give You Up in the background.
*/
public async playRickRollVideo() {
private async _playRickRollVideo() {
const backgroundVideoPlayer = this._backgroundVideoPlayerHolder.get();
if (!backgroundVideoPlayer) {
return;
@@ -1487,32 +1522,6 @@ export class GristDoc extends DisposableWithEvents {
this._showBackgroundVideoPlayer.set(false);
}
/**
* Creates computed with all the data for the given column.
*/
public columnObserver(owner: IDisposableOwner, tableId: Observable<string>, columnId: Observable<string>) {
const tableModel = Computed.create(owner, (use) => this.docModel.dataTables[use(tableId)]);
const refreshed = Observable.create(owner, 0);
const toggle = () => !refreshed.isDisposed() && refreshed.set(refreshed.get() + 1);
const holder = Holder.create(owner);
const listener = (tab: TableModel) => {
// Now subscribe to any data change in that table.
const subs = MultiHolder.create(holder);
subs.autoDispose(tab.tableData.dataLoadedEmitter.addListener(toggle));
subs.autoDispose(tab.tableData.tableActionEmitter.addListener(toggle));
tab.fetch().catch(reportError);
};
owner.autoDispose(tableModel.addListener(listener));
listener(tableModel.get());
const values = Computed.create(owner, refreshed, (use) => {
const rows = use(tableModel).getAllRows();
const colValues = use(tableModel).tableData.getColValues(use(columnId));
if (!colValues) { return []; }
return rows.map((row, i) => [row, colValues[i]]);
});
return values;
}
private _focusPreviousSection() {
const prevSectionId = this._prevSectionId;
if (!prevSectionId) { return; }
@@ -1890,26 +1899,25 @@ async function finalizeAnchor() {
}
const cssViewContentPane = styled('div', `
--view-content-page-margin: 12px;
--view-content-page-padding: 12px;
flex: auto;
display: flex;
flex-direction: column;
overflow: visible;
position: relative;
min-width: 240px;
margin: var(--view-content-page-margin, 12px);
padding: var(--view-content-page-padding, 12px);
@media ${mediaSmall} {
& {
margin: 4px;
padding: 4px;
}
}
@media print {
& {
margin: 0px;
padding: 0px;
}
}
&-contents {
margin: 0px;
overflow: hidden;
}
`);

View File

@@ -501,7 +501,7 @@ export class LayoutEditor extends Disposable {
handles: isWidth ? 'e' : 's',
start: this.onResizeStart.bind(this, helperObj, isWidth),
resize: this.onResizeMove.bind(this, helperObj, isWidth),
stop: this.triggerUserEditStop.bind(this)
stop: this.triggerUserEditStop.bind(this),
});
}
public unmakeResizable(box: LayoutBox) {

View File

@@ -1179,7 +1179,7 @@ const cssCollapsedTray = styled('div.collapsed_layout', `
overflow: hidden;
transition: height 0.2s;
position: relative;
margin: calc(-1 * var(--view-content-page-margin, 12px));
margin: calc(-1 * var(--view-content-page-padding, 12px));
margin-bottom: 0;
user-select: none;
background-color: ${theme.pageBg};

View File

@@ -4,7 +4,7 @@ import {DocumentUsage} from 'app/client/components/DocumentUsage';
import {GristDoc} from 'app/client/components/GristDoc';
import {printViewSection} from 'app/client/components/Printing';
import {ViewSectionHelper} from 'app/client/components/ViewLayout';
import {mediaSmall, theme} from 'app/client/ui2018/cssVars';
import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
import {reportError} from 'app/client/models/errors';
@@ -115,7 +115,8 @@ export class RawDataPopup extends Disposable {
const cssContainer = styled('div', `
height: 100%;
overflow: hidden;
position: relative;
inset: 0px;
position: absolute;
`);
const cssPage = styled('div', `
@@ -132,10 +133,9 @@ const cssPage = styled('div', `
export const cssOverlay = styled('div', `
background-color: ${theme.modalBackdrop};
inset: 0px;
height: 100%;
width: 100%;
padding: 20px 56px 20px 56px;
position: absolute;
z-index: ${vars.popupSectionBackdropZIndex};
@media ${mediaSmall} {
& {
padding: 22px;

View File

@@ -50,6 +50,7 @@ export class RecordCardPopup extends DisposableWithEvents {
focusable: false,
renamable: false,
}),
testId('wrapper'),
),
cssCloseButton('CrossBig',
dom.on('click', () => this._handleClose()),

View File

@@ -544,6 +544,7 @@ export class WidgetAPIImpl implements WidgetAPI {
const COMMAND_MINIMUM_ACCESS_LEVELS: Map<CommandName, AccessLevel> = new Map([
['undo', AccessLevel.full],
['redo', AccessLevel.full],
['viewAsCard', AccessLevel.read_table],
]);
export class CommandAPI {