mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
592a43ec36
Summary: - Added a new special page for viewing raw data widgets: - Implemented in DataTables.ts - Accessible only via the special URL path `/p/data` - Future diffs should make this page prettier and easily accessible - Shows a list of user tables - Clicking on a table name shows its `rawViewSection` by setting `GristDoc.viewModel.activeSectionId`. Note that in this case `GristDoc.viewModel` is an empty record, so this is a bit of a hack, but it works well and causes no known issues. - Added `ViewSectionRec.isRaw` to know if the record represents a raw data widget. - Added various restrictions in the UI for raw data widgets: - 'Delete widget' is disabled in the 3-dot widget menu. - Prevent hiding columns: - "Hide column" in the column context menu is disabled - The "VISIBLE/HIDDEN COLUMNS" section of the right panel > Table > Widget is hidden - The toggle bar isn't configurable to ensure that users know when raw data is filtered: - The filter bar always shows if and only if some filters are present - "Toggle Filter Bar" is hidden in: - Right panel > Table > Sort & Filter - The sort/filter menu next to the three-dot menu for widgets. - Other restrictions in the right panel: - In the Column tab: - 'Use separate settings' is disabled - In the Table tab: - In the Widget subtab: - 'Change Widget' is hidden - In the Data subtab: - 'Edit Data Selection' is hidden - 'SELECT BY' is hidden Test Plan: Tested manually. The behaviour of raw data widgets may still change and they aren't easily visible to users yet. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3248
440 lines
14 KiB
TypeScript
440 lines
14 KiB
TypeScript
import {reportError} from 'app/client/models/AppModel';
|
|
import {ColumnRec, DocModel, ViewSectionRec} from 'app/client/models/DocModel';
|
|
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec';
|
|
import {CustomComputed} from 'app/client/models/modelUtil';
|
|
import {attachColumnFilterMenu} from 'app/client/ui/ColumnFilterMenu';
|
|
import {addFilterMenu} from 'app/client/ui/FilterBar';
|
|
import {hoverTooltip} from 'app/client/ui/tooltips';
|
|
import {makeViewLayoutMenu} from 'app/client/ui/ViewLayoutMenu';
|
|
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
|
import {colors, vars} from 'app/client/ui2018/cssVars';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {menu} from 'app/client/ui2018/menus';
|
|
import {Sort} from 'app/common/SortSpec';
|
|
import {Computed, dom, fromKo, IDisposableOwner, makeTestId, Observable, styled} from 'grainjs';
|
|
import {PopupControl} from 'popweasel';
|
|
import difference = require('lodash/difference');
|
|
|
|
const testId = makeTestId('test-section-menu-');
|
|
|
|
const TOOLTIP_DELAY_OPEN = 750;
|
|
|
|
// Handler for [Save] button.
|
|
async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<void> {
|
|
await docModel.docData.bundleActions("Update Sort&Filter settings", () => Promise.all([
|
|
viewSection.activeSortJson.save(), // Save sort
|
|
viewSection.saveFilters(), // Save filter
|
|
viewSection.activeFilterBar.save(), // Save bar
|
|
viewSection.activeCustomOptions.save(), // Save widget options
|
|
]));
|
|
}
|
|
|
|
// Handler for [Revert] button.
|
|
function doRevert(viewSection: ViewSectionRec) {
|
|
viewSection.activeSortJson.revert(); // Revert sort
|
|
viewSection.revertFilters(); // Revert filter
|
|
viewSection.activeFilterBar.revert(); // Revert bar
|
|
viewSection.activeCustomOptions.revert(); // Revert widget options
|
|
}
|
|
|
|
// [Filter Icon] (v) (x) - Filter toggle and all the components in the menu.
|
|
export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, viewSection: ViewSectionRec,
|
|
isReadonly: Observable<boolean>) {
|
|
|
|
const popupControls = new WeakMap<ColumnRec, PopupControl>();
|
|
|
|
// If there is any filter (should [Filter Icon] be green).
|
|
const anyFilter = Computed.create(owner, (use) => Boolean(use(viewSection.activeFilters).length));
|
|
|
|
// Should border be green, and should we show [Save] [Revert] (v) (x) buttons.
|
|
const displaySaveObs: Computed<boolean> = Computed.create(owner, (use) => (
|
|
use(viewSection.filterSpecChanged)
|
|
|| !use(viewSection.activeSortJson.isSaved)
|
|
|| !use(viewSection.activeFilterBar.isSaved)
|
|
|| !use(viewSection.activeCustomOptions.isSaved)
|
|
));
|
|
|
|
const save = () => { doSave(docModel, viewSection).catch(reportError); };
|
|
const revert = () => doRevert(viewSection);
|
|
|
|
return [
|
|
cssFilterMenuWrapper(
|
|
cssFixHeight.cls(''),
|
|
cssFilterMenuWrapper.cls('-unsaved', displaySaveObs),
|
|
testId('wrapper'),
|
|
cssMenu(
|
|
testId('sortAndFilter'),
|
|
// [Filter icon] grey or green
|
|
cssFilterIconWrapper(
|
|
testId('filter-icon'),
|
|
// Make green when there are some filters. If there are only sort options, leave grey.
|
|
cssFilterIconWrapper.cls('-any', anyFilter),
|
|
cssFilterIcon('Filter')
|
|
),
|
|
menu(ctl => [
|
|
// Sorted by section.
|
|
dom.domComputed(use => {
|
|
use(viewSection.activeSortJson.isSaved); // Rebuild sort panel if sort gets saved. A little hacky.
|
|
return makeSortPanel(viewSection, use(viewSection.activeSortSpec),
|
|
(row: number) => docModel.columns.getRowModel(row));
|
|
}),
|
|
// Filtered by section.
|
|
dom.domComputed(viewSection.activeFilters, filters =>
|
|
makeFilterPanel(viewSection, filters, popupControls, () => ctl.close())),
|
|
// [+] Add filter
|
|
makeAddFilterButton(viewSection, popupControls),
|
|
// [+] Toggle filter bar
|
|
dom.maybe((use) => !use(viewSection.isRaw),
|
|
() => makeFilterBarToggle(viewSection.activeFilterBar)),
|
|
// Widget options
|
|
dom.maybe(use => use(viewSection.parentKey) === 'custom', () =>
|
|
makeCustomOptions(viewSection)
|
|
),
|
|
// [Save] [Revert] buttons
|
|
dom.domComputed(displaySaveObs, displaySave => [
|
|
displaySave ? cssMenuInfoHeader(
|
|
cssSaveButton('Save', testId('btn-save'),
|
|
dom.on('click', () => { save(); ctl.close(); }),
|
|
dom.boolAttr('disabled', isReadonly)),
|
|
basicButton('Revert', testId('btn-revert'),
|
|
dom.on('click', () => { revert(); ctl.close(); }))
|
|
) : null,
|
|
]),
|
|
]),
|
|
),
|
|
// Two icons (v) (x) left to the toggle, when there are unsaved filters or sort options.
|
|
// Those buttons are equivalent of the [Save] [Revert] buttons in the menu.
|
|
dom.maybe(displaySaveObs, () => cssSaveIconsWrapper(
|
|
// (v)
|
|
cssSmallIconWrapper(
|
|
cssIcon('Tick'), cssSmallIconWrapper.cls('-green'),
|
|
dom.on('click', save),
|
|
hoverTooltip(() => 'Save', {key: 'sortFilterButton', openDelay: TOOLTIP_DELAY_OPEN}),
|
|
testId('small-btn-save'),
|
|
dom.hide(isReadonly),
|
|
),
|
|
// (x)
|
|
cssSmallIconWrapper(
|
|
cssIcon('CrossSmall'), cssSmallIconWrapper.cls('-gray'),
|
|
dom.on('click', revert),
|
|
hoverTooltip(() => 'Revert', {key: 'sortFilterButton', openDelay: TOOLTIP_DELAY_OPEN}),
|
|
testId('small-btn-revert'),
|
|
),
|
|
)),
|
|
),
|
|
cssMenu(
|
|
testId('viewLayout'),
|
|
cssFixHeight.cls(''),
|
|
cssDotsIconWrapper(cssIcon('Dots')),
|
|
menu(_ctl => makeViewLayoutMenu(viewSection, isReadonly.get()))
|
|
)
|
|
];
|
|
}
|
|
|
|
// Sorted by section (and all columns underneath or (Default) label).
|
|
function makeSortPanel(section: ViewSectionRec, sortSpec: Sort.SortSpec, getColumn: (row: number) => ColumnRec) {
|
|
const changedColumns = difference(sortSpec, Sort.parseSortColRefs(section.sortColRefs.peek()));
|
|
const sortColumns = sortSpec.map(colSpec => {
|
|
// colRef is a rowId of a column or its negative value (indicating descending order).
|
|
const col = getColumn(Sort.getColRef(colSpec));
|
|
return cssMenuText(
|
|
cssMenuIconWrapper(
|
|
cssMenuIconWrapper.cls('-changed', changedColumns.includes(colSpec)),
|
|
cssMenuIconWrapper.cls(Sort.isAscending(colSpec) ? '-asc' : '-desc'),
|
|
cssIcon('Sort',
|
|
dom.style('transform', Sort.isAscending(colSpec) ? 'scaleY(-1)' : 'none'),
|
|
dom.on('click', () => {
|
|
section.activeSortSpec(Sort.flipSort(sortSpec, colSpec));
|
|
})
|
|
)
|
|
),
|
|
cssMenuTextLabel(col.colId()),
|
|
cssMenuIconWrapper(
|
|
cssIcon('Remove', testId('btn-remove-sort'), dom.on('click', () => {
|
|
if (Sort.findCol(sortSpec, colSpec)) {
|
|
section.activeSortSpec(Sort.removeCol(sortSpec, colSpec));
|
|
}
|
|
}))
|
|
),
|
|
testId('sort-col')
|
|
);
|
|
});
|
|
|
|
return [
|
|
cssMenuInfoHeader('Sorted by', testId('heading-sorted')),
|
|
sortColumns.length > 0 ? sortColumns : cssGrayedMenuText('(Default)')
|
|
];
|
|
}
|
|
|
|
// [+] Add Filter.
|
|
export function makeAddFilterButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap<ColumnRec, PopupControl>) {
|
|
return dom.domComputed((use) => {
|
|
const filters = use(viewSectionRec.filters);
|
|
return cssMenuText(
|
|
cssMenuIconWrapper(
|
|
cssIcon('Plus'),
|
|
addFilterMenu(filters, viewSectionRec, popupControls, {
|
|
placement: 'bottom-end',
|
|
// Attach content to triggerElem's parent, which is needed to prevent view section menu to
|
|
// close when clicking an item of the add filter menu.
|
|
attach: null
|
|
}),
|
|
testId('plus-button'),
|
|
dom.on('click', (ev) => ev.stopPropagation()),
|
|
),
|
|
cssMenuTextLabel('Add Filter'),
|
|
);
|
|
});
|
|
}
|
|
|
|
// [v] or [x] Toggle Filter Bar.
|
|
export function makeFilterBarToggle(activeFilterBar: CustomComputed<boolean>) {
|
|
return cssMenuText(
|
|
cssMenuIconWrapper(
|
|
testId('btn'),
|
|
cssMenuIconWrapper.cls('-changed', (use) => !use(activeFilterBar.isSaved)),
|
|
dom.domComputed((use) => {
|
|
const filterBar = use(activeFilterBar);
|
|
const isSaved = use(activeFilterBar.isSaved);
|
|
return cssIcon(filterBar ? "Tick" : (isSaved ? "Plus" : "CrossSmall"),
|
|
cssIcon.cls('-green', Boolean(filterBar)),
|
|
testId('icon'));
|
|
}),
|
|
),
|
|
dom.on('click', () => activeFilterBar(!activeFilterBar.peek())),
|
|
cssMenuTextLabel("Toggle Filter Bar"),
|
|
);
|
|
}
|
|
|
|
// Filtered by - section in the menu (contains all filtered columns or (Not filtered) label).
|
|
function makeFilterPanel(section: ViewSectionRec, activeFilters: FilterInfo[],
|
|
popupControls: WeakMap<ColumnRec, PopupControl>,
|
|
onCloseContent: () => void) {
|
|
const filters = activeFilters.map(filterInfo => {
|
|
const filterChanged = Computed.create(null, fromKo(filterInfo.filter.isSaved), (_use, isSaved) => !isSaved);
|
|
return cssMenuText(
|
|
cssMenuIconWrapper(
|
|
cssMenuIconWrapper.cls('-changed', filterChanged),
|
|
cssIcon('FilterSimple'),
|
|
attachColumnFilterMenu(section, filterInfo, {
|
|
placement: 'bottom-end',
|
|
trigger: [
|
|
'click',
|
|
(_el, popupControl) => popupControls.set(filterInfo.fieldOrColumn.origCol(), popupControl)
|
|
],
|
|
onCloseContent,
|
|
}),
|
|
testId('filter-icon'),
|
|
),
|
|
cssMenuTextLabel(filterInfo.fieldOrColumn.label()),
|
|
cssMenuIconWrapper(cssIcon('Remove',
|
|
dom.on('click', () => section.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), ''))),
|
|
testId('btn-remove-filter')
|
|
),
|
|
testId('filter-col')
|
|
);
|
|
});
|
|
|
|
return [
|
|
cssMenuInfoHeader('Filtered by', {style: 'margin-top: 4px'}, testId('heading-filtered')),
|
|
activeFilters.length > 0 ? filters : cssGrayedMenuText('(Not filtered)')
|
|
];
|
|
}
|
|
|
|
|
|
// Custom Options
|
|
// (empty)|(customized)|(modified) [Remove Icon]
|
|
function makeCustomOptions(section: ViewSectionRec) {
|
|
const color = Computed.create(null, use => use(section.activeCustomOptions.isSaved) ? "-gray" : "-green");
|
|
const text = Computed.create(null, use => {
|
|
if (use(section.activeCustomOptions)) {
|
|
return use(section.activeCustomOptions.isSaved) ? "(customized)" : "(modified)";
|
|
} else {
|
|
return "(empty)";
|
|
}
|
|
});
|
|
return [
|
|
cssMenuInfoHeader('Custom options', testId('heading-widget-options')),
|
|
cssMenuText(
|
|
dom.autoDispose(text),
|
|
dom.autoDispose(color),
|
|
dom.text(text),
|
|
cssMenuText.cls(color),
|
|
cssSpacer(),
|
|
dom.maybe(use => use(section.activeCustomOptions), () =>
|
|
cssMenuIconWrapper(
|
|
cssIcon('Remove', testId('btn-remove-options'), dom.on('click', () =>
|
|
section.activeCustomOptions(null)
|
|
))
|
|
),
|
|
),
|
|
testId("custom-options")
|
|
)
|
|
];
|
|
}
|
|
|
|
const clsOldUI = styled('div', ``);
|
|
|
|
const cssFixHeight = styled('div', `
|
|
margin-top: -3px; /* Section header is 24px, so need to move this up a little bit */
|
|
`);
|
|
|
|
const cssMenu = styled('div', `
|
|
|
|
display: inline-flex;
|
|
cursor: pointer;
|
|
|
|
border-radius: 3px;
|
|
border: 1px solid transparent;
|
|
&.${clsOldUI.className} {
|
|
margin-top: 0px;
|
|
border-radius: 0px;
|
|
}
|
|
|
|
&:hover, &.weasel-popup-open {
|
|
background-color: ${colors.mediumGrey};
|
|
}
|
|
`);
|
|
|
|
const cssIconWrapper = styled('div', `
|
|
padding: 3px;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
`);
|
|
|
|
const cssMenuIconWrapper = styled(cssIconWrapper, `
|
|
display: flex;
|
|
margin: -3px 0;
|
|
width: 22px;
|
|
height: 22px;
|
|
|
|
&:hover, &.weasel-popup-open {
|
|
background-color: ${colors.mediumGrey};
|
|
}
|
|
&-changed {
|
|
background-color: ${colors.lightGreen};
|
|
}
|
|
&-changed:hover, &-changed:hover.weasel-popup-open {
|
|
background-color: ${colors.darkGreen};
|
|
}
|
|
`);
|
|
|
|
const cssFilterMenuWrapper = styled('div', `
|
|
display: inline-flex;
|
|
margin-right: 10px;
|
|
border-radius: 3px;
|
|
align-items: center;
|
|
&-unsaved {
|
|
border: 1px solid ${colors.lightGreen};
|
|
}
|
|
& .${cssMenu.className} {
|
|
border: none;
|
|
}
|
|
|
|
`);
|
|
|
|
const cssIcon = styled(icon, `
|
|
flex: none;
|
|
cursor: pointer;
|
|
background-color: ${colors.slate};
|
|
|
|
.${cssMenuIconWrapper.className}-changed & {
|
|
background-color: white;
|
|
}
|
|
|
|
.${clsOldUI.className} & {
|
|
background-color: white;
|
|
}
|
|
|
|
&-green {
|
|
background-color: ${colors.lightGreen};
|
|
}
|
|
`);
|
|
|
|
const cssDotsIconWrapper = styled(cssIconWrapper, `
|
|
border-radius: 0px 2px 2px 0px;
|
|
|
|
.${clsOldUI.className} & {
|
|
border-radius: 0px;
|
|
}
|
|
`);
|
|
|
|
const cssFilterIconWrapper = styled(cssIconWrapper, `
|
|
border-radius: 2px 0px 0px 2px;
|
|
.${cssFilterMenuWrapper.className}-unsaved & {
|
|
background-color: ${colors.lightGreen};
|
|
}
|
|
`);
|
|
|
|
const cssFilterIcon = styled(cssIcon, `
|
|
.${cssFilterIconWrapper.className}-any & {
|
|
background-color: ${colors.lightGreen};
|
|
}
|
|
.${cssFilterMenuWrapper.className}-unsaved & {
|
|
background-color: white;
|
|
}
|
|
`);
|
|
|
|
const cssMenuInfoHeader = styled('div', `
|
|
font-weight: ${vars.bigControlTextWeight};
|
|
padding: 8px 24px 8px 24px;
|
|
cursor: default;
|
|
`);
|
|
|
|
const cssMenuText = styled('div', `
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0px 24px 8px 24px;
|
|
cursor: default;
|
|
white-space: nowrap;
|
|
&-green {
|
|
color: ${colors.lightGreen};
|
|
}
|
|
&-gray {
|
|
color: ${colors.slate};
|
|
}
|
|
`);
|
|
|
|
const cssGrayedMenuText = styled(cssMenuText, `
|
|
color: ${colors.slate};
|
|
`);
|
|
|
|
const cssMenuTextLabel = styled('span', `
|
|
flex-grow: 1;
|
|
padding: 0 4px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`);
|
|
|
|
const cssSaveButton = styled(primaryButton, `
|
|
margin-right: 8px;
|
|
`);
|
|
|
|
const cssSmallIconWrapper = styled('div', `
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 8px;
|
|
margin: 0 5px 0 5px;
|
|
|
|
&-green {
|
|
background-color: ${colors.lightGreen};
|
|
}
|
|
&-gray {
|
|
background-color: ${colors.slate};
|
|
}
|
|
& > .${cssIcon.className} {
|
|
background-color: white;
|
|
}
|
|
`);
|
|
|
|
const cssSaveIconsWrapper = styled('div', `
|
|
padding: 0 1px 0 1px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
`);
|
|
|
|
const cssSpacer = styled('div', `
|
|
margin: 0 auto;
|
|
`);
|