mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,6 +50,7 @@ export class RecordCardPopup extends DisposableWithEvents {
|
||||
focusable: false,
|
||||
renamable: false,
|
||||
}),
|
||||
testId('wrapper'),
|
||||
),
|
||||
cssCloseButton('CrossBig',
|
||||
dom.on('click', () => this._handleClose()),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user