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 { 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 = 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); // If this section is the only one in the view (or view temporary has no sections at all). const singleVisible = Computed.create(owner, (use) => { const view = use(viewSection.view); const sections = use(use(view.viewSections).getObservable()); const expanded = sections.filter(s => use(s.isCollapsed) === false).length; return expanded === 1 || !expanded; // single, or no sections at all (temporary). }); // 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 && !use(viewSection.isRecordCard) && !use(singleVisible) // not in single section ; }); 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(); }), ]}), dom.hide(viewSection.isRecordCard), ), 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: ${theme.controlBorder}; } & .${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; color: ${theme.controlFg}; border-right: ${theme.controlBorder}; `); 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; `);