(core) Record Cards

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

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

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

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: paulfitz

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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