gristlabs_grist-core/app/client/ui/ViewSectionMenu.ts
Jarosław Sadziński 59cf654190 (core) Minimazing widgets
Summary:
A feature that allows minimizing widgets on the ViewLayout.
- Code in ViewLayout and Layout hasn't been changed. Only some methods or variables were made public, and some events are now triggered when a section is dragged.
- Widgets can be collapsed or expanded (added back to the main area)
- Collapsed widgets can be expanded and shown as a popup
- Collapsed widgets support drugging, reordering, and transferring between the main and collapsed areas.

Test Plan: New test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3779
2023-03-02 11:22:49 +01:00

386 lines
12 KiB
TypeScript

import {GristDoc} from 'app/client/components/GristDoc';
import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {reportError} from 'app/client/models/AppModel';
import {DocModel, ViewSectionRec} from 'app/client/models/DocModel';
import {FilterConfig} from 'app/client/ui/FilterConfig';
import {cssLabel, cssSaveButtonsRow} from 'app/client/ui/RightPanelStyles';
import {hoverTooltip} from 'app/client/ui/tooltips';
import {SortConfig} from 'app/client/ui/SortConfig';
import {makeViewLayoutMenu} from 'app/client/ui/ViewLayoutMenu';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu} from 'app/client/ui2018/menus';
import {Computed, dom, IDisposableOwner, makeTestId, styled} from 'grainjs';
import {defaultMenuOptions} from 'popweasel';
const testId = makeTestId('test-section-menu-');
const t = makeT('ViewSectionMenu');
// Handler for [Save] button.
async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<void> {
await docModel.docData.bundleActions(t("Update Sort&Filter settings"), () => Promise.all([
viewSection.activeSortJson.save(), // Save sort
viewSection.saveFilters(), // Save filter
viewSection.activeCustomOptions.save(), // Save widget options
]));
}
// Handler for [Revert] button.
function doRevert(viewSection: ViewSectionRec) {
viewSection.activeSortJson.revert(); // Revert sort
viewSection.revertFilters(); // Revert filter
viewSection.activeCustomOptions.revert(); // Revert widget options
}
// [Filter Icon] - Filter toggle and all the components in the menu.
export function viewSectionMenu(
owner: IDisposableOwner,
gristDoc: GristDoc,
viewSection: ViewSectionRec,
) {
const {docModel, isReadonly} = gristDoc;
// If there is any filter (should [Filter Icon] background be filled).
const anyFilter = Computed.create(owner, (use) => Boolean(use(viewSection.activeFilters).length));
// Should we show [Save] [Revert] buttons.
const displaySaveObs: Computed<boolean> = Computed.create(owner, (use) => (
use(viewSection.filterSpecChanged)
|| !use(viewSection.activeSortJson.isSaved)
|| !use(viewSection.activeCustomOptions.isSaved)
));
const save = () => { doSave(docModel, viewSection).catch(reportError); };
const revert = () => doRevert(viewSection);
// Should we show expand icon.
const showExpandIcon = Computed.create(owner, (use) => {
return !use(isNarrowScreenObs()) // not on narrow screens
&& 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
});
return [
cssFilterMenuWrapper(
cssFilterMenuWrapper.cls('-unsaved', displaySaveObs),
testId('wrapper'),
cssMenu(
testId('sortAndFilter'),
// [Filter icon]
cssFilterIconWrapper(
testId('filter-icon'),
// Fill background when there are some filters. Ignore sort options.
cssFilterIconWrapper.cls('-any', anyFilter),
cssFilterIcon('Filter'),
hoverTooltip('Sort and filter', {key: 'sortFilterBtnTooltip'}),
),
),
// [Save] [Revert] buttons when there are unsaved options.
dom.maybe(displaySaveObs, () => cssSectionSaveButtonsWrapper(
cssSaveTextButton(
t("Save"),
cssSaveTextButton.cls('-accent'),
dom.on('click', save),
hoverTooltip('Save sort & filter settings', {key: 'sortFilterBtnTooltip'}),
testId('small-btn-save'),
dom.hide(isReadonly),
),
cssRevertIconButton(
cssRevertIcon('Revert', cssRevertIcon.cls('-normal')),
dom.on('click', revert),
hoverTooltip('Revert sort & filter settings', {key: 'sortFilterBtnTooltip'}),
testId('small-btn-revert'),
),
)),
menu(ctl => [
// Sort section.
makeSortPanel(viewSection, gristDoc),
// Filter section.
makeFilterPanel(viewSection),
// Widget options
dom.maybe(use => use(viewSection.parentKey) === 'custom', () =>
makeCustomOptions(viewSection)
),
// [Save] [Revert] buttons
dom.domComputed(displaySaveObs, displaySave => [
displaySave ? cssSaveButtonsRow(
cssSaveButton(t("Save"), testId('btn-save'),
dom.on('click', () => { ctl.close(); save(); }),
dom.boolAttr('disabled', isReadonly)),
basicButton(t("Revert"), testId('btn-revert'),
dom.on('click', () => { ctl.close(); revert(); }))
) : null,
]),
// Updates to active sort or filters can cause menu contents to grow, while
// leaving the position of the popup unchanged. This can sometimes lead to
// the menu growing beyond the boundaries of the viewport. To mitigate this,
// we subscribe to changes to the sort/filters and manually update the popup's
// position, which will re-position the popup if necessary so that it's fully
// visible.
dom.autoDispose(viewSection.activeFilters.addListener(() => ctl.update())),
dom.autoDispose(viewSection.activeSortJson.subscribe(() => ctl.update())),
], {...defaultMenuOptions, placement: 'bottom-end', trigger: [
// Toggle the menu whenever the filter icon button is clicked.
(el, ctl) => dom.onMatchElem(el, '.test-section-menu-sortAndFilter', 'click', () => {
ctl.toggle();
}),
// Close the menu whenever the save or revert button is clicked.
(el, ctl) => dom.onMatchElem(el, '.test-section-menu-small-btn-save', 'click', () => {
ctl.close();
}),
(el, ctl) => dom.onMatchElem(el, '.test-section-menu-small-btn-revert', 'click', () => {
ctl.close();
}),
]}),
),
cssMenu(
testId('viewLayout'),
cssDotsIconWrapper(cssIcon('Dots')),
menu(_ctl => makeViewLayoutMenu(viewSection, isReadonly.get()), {
...defaultMenuOptions,
placement: 'bottom-end',
})
),
dom.maybe(showExpandIcon, () =>
cssExpandIconWrapper(
cssSmallIcon('Grow'),
testId('expandSection'),
dom.on('click', () => allCommands.maximizeActiveSection.run()),
hoverTooltip('Expand section', {key: 'expandSection'}),
),
)
];
}
function makeSortPanel(section: ViewSectionRec, gristDoc: GristDoc) {
return [
cssLabel(t("SORT"), testId('heading-sort')),
dom.create(SortConfig, section, gristDoc, {
// Attach content to triggerElem's parent, which is needed to prevent view
// section menu to close when clicking an item in the advanced sort menu.
menuOptions: {attach: null},
}),
];
}
function makeFilterPanel(section: ViewSectionRec) {
return [
cssLabel(t("FILTER"), testId('heading-filter')),
dom.create(FilterConfig, section, {
// 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.
menuOptions: {attach: null},
}),
];
}
// Custom Options
// (empty)|(customized)|(modified) [Remove Icon]
function makeCustomOptions(section: ViewSectionRec) {
const color = Computed.create(null, use => use(section.activeCustomOptions.isSaved) ? "-normal" : "-accent");
const text = Computed.create(null, use => {
if (use(section.activeCustomOptions)) {
return use(section.activeCustomOptions.isSaved) ? t("(customized)") : t("(modified)");
} else {
return t("(empty)");
}
});
return [
cssMenuInfoHeader(t("Custom options"), testId('heading-widget-options')),
cssMenuText(
dom.autoDispose(text),
dom.autoDispose(color),
dom.text(text),
cssMenuText.cls(color),
cssSpacer(),
dom.maybe(use => Boolean(use(section.activeCustomOptions)), () =>
cssMenuIconWrapper(
cssIcon('Remove', testId('btn-remove-options'), dom.on('click', () =>
section.activeCustomOptions(null)
))
),
),
testId("custom-options")
)
];
}
const clsOldUI = styled('div', ``);
export const cssMenu = styled('div', `
display: flex;
cursor: pointer;
border-radius: 3px;
&.${clsOldUI.className} {
margin-top: 0px;
border-radius: 0px;
}
&:hover, &.weasel-popup-open {
background-color: ${theme.hover};
}
`);
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: ${theme.hover};
}
&-changed {
background-color: ${theme.accentIcon};
}
&-changed:hover, &-changed:hover.weasel-popup-open {
background-color: ${theme.controlHoverFg};
}
`);
const cssFilterMenuWrapper = styled('div', `
display: flex;
border-radius: 3px;
align-items: center;
&-unsaved {
border: 1px solid ${theme.accentBorder};
}
& .${cssMenu.className} {
border: none;
}
`);
const cssIcon = styled(icon, `
flex: none;
cursor: pointer;
background-color: ${theme.lightText};
.${cssMenuIconWrapper.className}-changed & {
background-color: ${theme.controlPrimaryFg};
}
.${clsOldUI.className} & {
background-color: ${theme.controlPrimaryFg};
}
&-accent {
background-color: ${theme.accentIcon};
}
`);
export const cssDotsIconWrapper = styled(cssIconWrapper, `
border-radius: 0px 2px 2px 0px;
display: flex;
.${clsOldUI.className} & {
border-radius: 0px;
}
`);
const cssExpandIconWrapper = styled('div', `
display: flex;
border-radius: 3px;
align-items: center;
padding: 4px;
cursor: pointer;
&:hover, &.weasel-popup-open {
background-color: ${theme.hover};
}
`);
const cssSmallIcon = styled(cssIcon, `
height: 13px;
width: 13px;
`);
const cssFilterIconWrapper = styled(cssIconWrapper, `
border-radius: 2px 0px 0px 2px;
display: flex;
&-any {
border-radius: 2px;
background-color: ${theme.controlSecondaryFg};
}
.${cssFilterMenuWrapper.className}-unsaved & {
background-color: ${theme.controlPrimaryBg};
}
`);
const cssFilterIcon = styled(cssIcon, `
.${cssFilterIconWrapper.className}-any & {
background-color: ${theme.controlPrimaryFg};
}
.${cssFilterMenuWrapper.className}-unsaved & {
background-color: ${theme.controlPrimaryFg};
}
`);
const cssMenuInfoHeader = styled('div', `
color: ${theme.menuSubheaderFg};
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;
&-accent {
color: ${theme.accentText};
}
&-normal {
color: ${theme.lightText};
}
`);
const cssSaveButton = styled(primaryButton, `
margin-right: 8px;
`);
const cssSaveTextButton = styled('div', `
display: flex;
align-items: center;
cursor: pointer;
font-size: ${vars.mediumFontSize};
padding: 0px 5px;
border-right: 1px solid ${theme.accentBorder};
&-accent {
color: ${theme.accentText};
}
`);
const cssRevertIconButton = styled('div', `
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
`);
const cssRevertIcon = styled(icon, `
--icon-color: ${theme.accentIcon};
margin: 0 5px 0 5px;
`);
const cssSectionSaveButtonsWrapper = styled('div', `
padding: 0 1px 0 1px;
display: flex;
justify-content: space-between;
align-self: normal;
`);
const cssSpacer = styled('div', `
margin: 0 auto;
`);