gristlabs_grist-core/app/client/components/DataTables.ts
George Gevoian 18ad39cba3 (core) Add cut, copy, and paste to context menu
Summary:
On supported browsers, the new context menu commands work exactly as they do
via keyboard shortcuts. On unsupported browsers, an unavailable command
modal is shown with a suggestion to use keyboard shortcuts instead.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3867
2023-05-10 00:48:15 -04:00

330 lines
9.5 KiB
TypeScript

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 {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable';
import {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 {confirmModal} from 'app/client/ui2018/modals';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization';
const testId = makeTestId('test-raw-data-');
const t = makeT('DataTables');
export class DataTables extends Disposable {
private _tables: Observable<TableRec[]>;
private readonly _rowCount = Computed.create(
this, this._gristDoc.docPageModel.currentDocUsage, (_use, usage) => {
return usage?.rowCount;
}
);
// TODO: Update this whenever the rest of the UI is internationalized.
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
constructor(private _gristDoc: GristDoc) {
super();
this._tables = Computed.create(this, use => {
const dataTables = use(_gristDoc.docModel.rawDataTables.getObservable());
const summaryTables = use(_gristDoc.docModel.rawSummaryTables.getObservable());
// Remove tables that we don't have access to. ACL will remove tableId from those tables.
return [...dataTables, ...summaryTables].filter(table => Boolean(use(table.tableId)));
});
}
public buildDom() {
return container(
cssTableList(
/*************** List section **********/
testId('list'),
cssHeader(t("Raw Data Tables")),
cssList(
dom.forEach(this._tables, tableRec =>
cssItem(
testId('table'),
cssLeft(
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'))),
cssDetailsRow(
cssTableIdWrapper(cssHoverWrapper(
cssUpperCase("Table ID: "),
cssTableId(
testId('table-id'),
dom.text(tableRec.tableId),
),
{ title : t("Click to copy") },
dom.on('click', async (e, d) => {
e.stopImmediatePropagation();
e.preventDefault();
showTransientTooltip(d, t("Table ID copied to clipboard"), {
key: 'copy-table-id'
});
await copyToClipboard(tableRec.tableId.peek());
setTestState({clipboard: tableRec.tableId.peek()});
})
)),
this._tableRows(tableRec),
),
),
cssRight(
docMenuTrigger(
testId('table-menu'),
icon('Dots'),
menu(() => this._menuItems(tableRec), {placement: 'bottom-start'}),
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
)
),
dom.on('click', () => {
const sectionId = tableRec.rawViewSection.peek().getRowId();
if (!sectionId) {
throw new Error(`Table ${tableRec.tableId.peek()} doesn't have a raw view section.`);
}
this._gristDoc.viewModel.activeSectionId(sectionId);
})
)
)
),
),
);
}
private _tableTitle(table: TableRec) {
return dom.domComputed((use) => {
const rawViewSectionRef = use(fromKo(table.rawViewSectionRef));
const isSummaryTable = use(table.summarySourceTable) !== 0;
if (!rawViewSectionRef || isSummaryTable) {
// Some very old documents might not have a rawViewSection, and raw summary
// tables can't currently be renamed.
const tableName = [
use(table.tableNameDef), isSummaryTable ? use(table.groupDesc) : ''
].filter(p => Boolean(p?.trim())).join(' ');
return cssTableName(tableName);
} else {
return dom('div', // to disable flex grow in the widget
dom.domComputed(fromKo(table.rawViewSection), vs =>
buildTableName(vs, testId('widget-title'))
)
);
}
});
}
private _menuItems(table: TableRec) {
const {isReadonly, docModel} = this._gristDoc;
return [
menuItem(
() => this._duplicateTable(table),
t("Duplicate Table"),
testId('menu-duplicate-table'),
dom.cls('disabled', use =>
use(isReadonly) ||
use(table.isHidden) ||
use(table.summarySourceTable) !== 0
),
),
menuItem(
() => this._removeTable(table),
'Remove',
testId('menu-remove'),
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)
))
),
dom.maybe(isReadonly, () => menuText(t("You do not have edit access to this document"))),
];
}
private _duplicateTable(r: TableRec) {
duplicateTable(this._gristDoc, r.tableId(), {
onSuccess: ({raw_section_id}: DuplicateTableResponse) =>
this._gristDoc.viewModel.activeSectionId(raw_section_id),
});
}
private _removeTable(r: TableRec) {
const {docModel} = this._gristDoc;
function doRemove() {
return docModel.docData.sendAction(['RemoveTable', r.tableId()]);
}
confirmModal(t(
"Delete {{formattedTableName}} data, and remove it from all pages?",
{formattedTableName : r.formattedTableName()}
), 'Delete', doRemove);
}
private _tableRows(table: TableRec) {
return dom.maybe(this._rowCount, (rowCounts) => {
if (rowCounts === 'hidden') { return null; }
return cssTableRowsWrapper(
cssUpperCase("Rows: "),
rowCounts === 'pending' ? cssLoadingDots() : cssTableRows(
rowCounts[table.getRowId()] !== undefined
? this._rowCountFormatter.format(rowCounts[table.getRowId()])
: '',
testId('table-rows'),
)
);
});
}
}
const container = styled('div', `
overflow-y: auto;
position: relative;
`);
const cssHeader = styled(docListHeader, `
display: inline-block;
`);
const cssList = styled('div', `
display: flex;
flex-direction: column;
gap: 12px;
`);
const cssItem = styled('div', `
display: flex;
align-items: center;
cursor: pointer;
border-radius: 3px;
width: 100%;
height: calc(1em * 56/13); /* 56px for 13px font */
max-width: 750px;
border: 1px solid ${css.theme.rawDataTableBorder};
&:hover {
border-color: ${css.theme.rawDataTableBorderHover};
}
`);
// Holds icon in top left corner
const cssLeft = 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;
min-width: 0px;
display: flex;
flex-wrap: wrap;
margin-top: 6px;
margin-bottom: 4px;
`);
const cssTitleRow = styled('div', `
min-width: 100%;
margin-right: 4px;
`);
const cssDetailsRow = styled('div', `
min-width: 100%;
display: flex;
gap: 8px;
`);
// 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;
display: flex;
flex: none;
`);
const cssTableTypeIcon = styled(icon, `
--icon-color: ${css.theme.accentIcon};
`);
const cssLine = styled('span', `
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
`);
const cssTableIdWrapper = styled('div', `
display: flex;
flex-grow: 1;
min-width: 0;
`);
const cssTableRowsWrapper = styled('div', `
display: flex;
flex-shrink: 0;
min-width: 100px;
overflow: hidden;
align-items: baseline;
color: ${css.theme.lightText};
line-height: 18px;
padding: 0px 2px;
`);
const cssHoverWrapper = styled('div', `
display: flex;
overflow: hidden;
cursor: default;
align-items: baseline;
color: ${css.theme.lightText};
transition: background 0.05s;
padding: 0px 2px;
line-height: 18px;
&:hover {
background: ${css.theme.lightHover};
}
`);
const cssTableId = styled(cssLine, `
font-size: ${css.vars.smallFontSize};
`);
const cssTableRows = cssTableId;
const cssTableTitle = styled('div', `
color: ${css.theme.text};
white-space: nowrap;
`);
const cssUpperCase = styled('span', `
text-transform: uppercase;
letter-spacing: 0.81px;
font-weight: 500;
font-size: 9px; /* xxsmallFontSize is to small */
margin-right: 2px;
flex: 0;
white-space: nowrap;
`);
const cssTableList = styled('div', `
overflow-y: auto;
position: relative;
margin-bottom: 56px;
`);
const cssLoadingDots = styled(loadingDots, `
--dot-size: 6px;
`);
const cssTableName = styled('span', `
color: ${css.theme.text};
`);