mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
Card Settings: hamburger menus open on top of it, next to their button.
A hamburger of Card Settings opened its menu in Card Settings' place:
the popup slid to the menu and Card Settings disappeared until its back
arrow. Its menus, and the forms it opens (a custom field's edit form,
the + under Custom Fields, Subtask settings), now open as a popup
LAYER: on top of Card Settings, next to the button that opened them,
with Card Settings visible under it and scrolled where it was.
Popup.open(name, { layer: true }), from inside a popup, pushes the
layer on the stack as before, so Popup.back() - the layer's X, Escape,
a form's Save - closes the layer alone. The popup template draws the
popup under it with the layer on top, outside the scrolling content;
depth counts the sliding popups only, so a confirmation opened from a
layer still slides. One layer at a time: another replaces it, its own
button closes it, a press elsewhere in the popup closes it.
A layer opens toward the middle of the popup: a hamburger at the end of
a line gets its right edge, the + on the left its left edge, so the
create form no longer ran out of the popup's left side where its Save
could not be clicked; below the button, or above when there is more
room, its height and width capped to the room
(models/lib/popupLayerPosition.js). A menu's checkboxes start at its
edge rather than .check-div's form indent.
tests/popupLayer.test.cjs runs the placement and pins the Popup wiring
and that every Card Settings menu opens as a layer;
card-add-fields.e2e.js checks Card Settings stays visible under the
menu, the menu sits by its button, toggles, closes on a press elsewhere
and by its X alone, and that the + form opens inside Card Settings.
Thanks to xet7 !
This commit is contained in:
1 parent
0250e54a07
commit
9c9742e09d
14 files changed
+341
-24
No files matched your search
@@ -114,6 +114,12 @@
|
||||
align-items: center;
|
||||
column-gap: 0.3em;
|
||||
}
|
||||
/* A menu's options start at its edge, like its table: not .check-div's
|
||||
indent for a form's checkboxes. */
|
||||
.card-settings-item-menu .card-settings-board-option,
|
||||
.card-settings-section-menu .card-settings-board-option {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
/* A tick is drawn rotated (forms.css #6786) and reaches about 3px past its
|
||||
box toward the label, which ate the space: drawn 3px further left, it keeps
|
||||
it - the box, and so the label, do not move when it is ticked. Right to
|
||||
|
||||
@@ -1494,3 +1494,42 @@ body.mobile-mode .pop-over .content-wrapper {
|
||||
.pop-over.pop-over--maximized .card-drawing {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A layer (client/lib/popup.js `layer`): a menu on top of its popup, next to
|
||||
the button that opened it - Card Settings' hamburgers. Its place and its
|
||||
max-height come from models/lib/popupLayerPosition.js; the popup under it
|
||||
stays visible. */
|
||||
.pop-over .popup-layer {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
min-width: 16em;
|
||||
max-width: calc(100% - 16px);
|
||||
background: #fff;
|
||||
color: #4d4d4d;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.pop-over .popup-layer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px 6px 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-weight: bold;
|
||||
}
|
||||
.pop-over .popup-layer-title { flex: 1 1 auto; }
|
||||
.pop-over .popup-layer-close {
|
||||
color: #8c8c8c;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.pop-over .popup-layer-close:hover,
|
||||
.pop-over .popup-layer-close:focus-visible { color: #4d4d4d; }
|
||||
.pop-over .popup-layer-content {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
@@ -141,7 +141,16 @@ Popup.template.events({
|
||||
const direction = window.getComputedStyle(element).direction === 'rtl' ? -1 : 1;
|
||||
resizeDatePopup(element, bounds.width + direction * width, bounds.height + height);
|
||||
},
|
||||
'click .js-back-view'() {
|
||||
'click .js-back-view'(evt) {
|
||||
evt.preventDefault();
|
||||
Popup.back();
|
||||
},
|
||||
// A layer is a menu: a press anywhere else in the popup under it closes it,
|
||||
// except on the button that opened it, whose own click closes it.
|
||||
'mousedown .content-wrapper'(evt) {
|
||||
const top = Popup._getTopStack();
|
||||
if (!top || !top.layer) return;
|
||||
if (top.openerElement && top.openerElement.contains(evt.target)) return;
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-close-pop-over'() {
|
||||
|
||||
@@ -44,5 +44,17 @@
|
||||
.content(class="{{#unless $eq popupName ../popupName}}no-height{{/unless}}")
|
||||
+Template.dynamic(template=popupName data=dataContext)
|
||||
.clearfix
|
||||
//- A layer (Popup.open(name, { layer: true })): a menu opened from inside
|
||||
this popup, on top of it next to its button, the popup still visible under
|
||||
it. Outside .content-wrapper, so it does not scroll away with the popup's
|
||||
content. Its X goes back to the popup alone.
|
||||
if layer
|
||||
.popup-layer.js-popup-layer(data-popup="{{layer.popupName}}" role="dialog" aria-label="{{layer.title}}" style="{{layer.layerStyle}}")
|
||||
.popup-layer-header
|
||||
span.popup-layer-title= layer.title
|
||||
a.popup-layer-close.js-back-view(href="#" role="button" aria-label="{{_ 'close-popup'}}" title="{{_ 'close-popup'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
.popup-layer-content
|
||||
+Template.dynamic(template=layer.popupName data=layer.dataContext)
|
||||
button.js-date-popup-resize(type="button" aria-label="{{_ 'size'}}" title="{{_ 'size'}}")
|
||||
span(aria-hidden="true") ◢
|
||||
@@ -2662,7 +2662,7 @@ const cardSettingsEvents = {
|
||||
const id = evt.currentTarget.dataset.action;
|
||||
const action = Object.values(cardSettingsSections.CARD_ACTIONS).flat().find(a => a.id === id);
|
||||
if (!action || !action.board || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
Popup.open(action.popup)(evt);
|
||||
Popup.open(action.popup, { layer: true })(evt);
|
||||
},
|
||||
// Only the checkbox just clicked animates; its own handler does the change.
|
||||
'click .card-field-order-toggle'(evt, tpl) {
|
||||
@@ -3383,7 +3383,7 @@ Template.cardSettingsInline.events({
|
||||
evt.preventDefault();
|
||||
if (!cardSettingsSections.SECTIONS.some(s => s.id === id)) return;
|
||||
const data = { sectionId: id, side: tpl.showSide.get() };
|
||||
Popup.open('cardSettingsSection', { dataContext: data })(evt);
|
||||
Popup.open('cardSettingsSection', { dataContext: data, layer: true })(evt);
|
||||
},
|
||||
// Custom Fields' items: the checkbox is the definition's flag of this side.
|
||||
'click .js-card-settings-custom-field-toggle'(evt, tpl) {
|
||||
@@ -3416,13 +3416,13 @@ Template.cardSettingsInline.events({
|
||||
const item = evt.currentTarget.closest('.js-card-settings-custom-field');
|
||||
const definition = item && ReactiveCache.getCustomField(item.dataset.id);
|
||||
if (!definition || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
Popup.open('editCustomField', { dataContext: definition })(evt);
|
||||
Popup.open('editCustomField', { dataContext: definition, layer: true })(evt);
|
||||
},
|
||||
// "+" under the items: a new custom field of the board, the last item.
|
||||
'click .js-card-settings-add-custom-field'(evt) {
|
||||
evt.preventDefault();
|
||||
if (!(Utils.canModifyBoard && Utils.canModifyBoard())) return;
|
||||
Popup.open('createCustomField', { dataContext: { boardId: Session.get('currentBoard') } })(evt);
|
||||
Popup.open('createCustomField', { dataContext: { boardId: Session.get('currentBoard') }, layer: true })(evt);
|
||||
},
|
||||
'click .js-open-card-settings-item-menu'(evt, tpl) {
|
||||
const row = evt.currentTarget.closest('.js-card-field-order-row');
|
||||
@@ -3430,7 +3430,7 @@ Template.cardSettingsInline.events({
|
||||
if (!row || !sectionEl) return;
|
||||
evt.preventDefault();
|
||||
const data = { key: row.dataset.key, sectionId: sectionEl.dataset.section, side: tpl.showSide.get() };
|
||||
Popup.open('cardSettingsItem', { dataContext: data })(evt);
|
||||
Popup.open('cardSettingsItem', { dataContext: data, layer: true })(evt);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+57
-5
@@ -6,6 +6,7 @@ import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { computePopupOffset } from '/client/lib/popupOffset';
|
||||
import { focusFirstControl } from '/client/lib/accessibility';
|
||||
import { popupLayerPosition, popupLayerStyle } from '/models/lib/popupLayerPosition';
|
||||
|
||||
window.Popup = new (class {
|
||||
constructor() {
|
||||
@@ -30,7 +31,7 @@ window.Popup = new (class {
|
||||
|
||||
focusCurrentPopup() {
|
||||
Tracker.afterFlush(() => {
|
||||
const popup = document.querySelector('.js-pop-over');
|
||||
const popup = document.querySelector('.js-pop-over .js-popup-layer') || document.querySelector('.js-pop-over');
|
||||
if (!popup) return;
|
||||
focusFirstControl(popup);
|
||||
});
|
||||
@@ -49,6 +50,12 @@ window.Popup = new (class {
|
||||
/// adding the convention key would put a second copy of one phrase into all
|
||||
/// 147 language files - starting as English in every one of them, so most
|
||||
/// languages would show English for something they have already translated.
|
||||
///
|
||||
/// `openOptions.layer`: opened from inside a popup, the new one is a LAYER on
|
||||
/// top of it, next to the button that opened it, instead of sliding in its
|
||||
/// place - the popup stays visible under it (Card Settings' hamburgers).
|
||||
/// Popup.back() closes the layer alone; another layer from the same popup
|
||||
/// replaces it, and its own button closes it again.
|
||||
open(name, openOptions = {}) {
|
||||
const self = this;
|
||||
const popupName = `${name}Popup`;
|
||||
@@ -60,6 +67,22 @@ window.Popup = new (class {
|
||||
* @param options options (dataContextIfCurrentDataIsUndefined use this dataContext if this.currentData() is undefined)
|
||||
*/
|
||||
return function(evt, options) {
|
||||
const asLayer = Boolean(openOptions.layer) && self.isOpen() && clickFromPopup(evt);
|
||||
if (asLayer) {
|
||||
const top = self._getTopStack();
|
||||
if (top && top.layer) {
|
||||
// One layer at a time: the open one goes, and its own button only
|
||||
// closes it.
|
||||
self._stack.pop();
|
||||
$(top.openerElement).removeClass('is-active');
|
||||
if (top.openerElement === evt.currentTarget) {
|
||||
evt.preventDefault();
|
||||
self._dep.changed();
|
||||
self.focusCurrentPopup();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If a popup is already opened, clicking again on the opener element
|
||||
// should close it -- and interrupt the current `open` function.
|
||||
if (self.isOpen()) {
|
||||
@@ -93,7 +116,9 @@ window.Popup = new (class {
|
||||
// has one. This allows us to position a sub-popup exactly at the same
|
||||
// position than its parent.
|
||||
let openerElement;
|
||||
if (clickFromPopup(evt) && self._getTopStack()) {
|
||||
if (asLayer) {
|
||||
openerElement = evt.currentTarget;
|
||||
} else if (clickFromPopup(evt) && self._getTopStack()) {
|
||||
openerElement = self._getTopStack().openerElement;
|
||||
} else {
|
||||
// For Member Settings sub-popups, always start fresh to avoid content mixing
|
||||
@@ -117,10 +142,13 @@ window.Popup = new (class {
|
||||
depth: self._stack.length,
|
||||
offset: self._getOffset(openerElement, popupName),
|
||||
dataContext: openOptions.dataContext || (this && this.currentData && this.currentData()) || (options && options.dataContextIfCurrentDataIsUndefined) || this,
|
||||
layer: asLayer,
|
||||
layerStyle: asLayer ? self._layerStyle(openerElement) : '',
|
||||
});
|
||||
|
||||
const $contentWrapper = $('.content-wrapper')
|
||||
if ($contentWrapper.length > 0) {
|
||||
// A layer leaves the popup under it where it was scrolled to.
|
||||
if ($contentWrapper.length > 0 && !asLayer) {
|
||||
const contentWrapper = $contentWrapper[0];
|
||||
self._getTopStack().scrollTop = contentWrapper.scrollTop;
|
||||
// scroll from e.g. delete comment to the top (where the confirm button is)
|
||||
@@ -145,7 +173,7 @@ window.Popup = new (class {
|
||||
self.template,
|
||||
() => {
|
||||
self._dep.depend();
|
||||
return { ...self._getTopStack(), stack: self._stack };
|
||||
return self._renderData();
|
||||
},
|
||||
document.body,
|
||||
);
|
||||
@@ -196,7 +224,10 @@ window.Popup = new (class {
|
||||
back(n = 1) {
|
||||
if (this._stack.length > n) {
|
||||
const $contentWrapper = $('.content-wrapper')
|
||||
if ($contentWrapper.length > 0) {
|
||||
const top = this._getTopStack();
|
||||
if (top && top.layer) $(top.openerElement).removeClass('is-active');
|
||||
// Closing a layer leaves the popup under it as it is.
|
||||
if ($contentWrapper.length > 0 && !(n === 1 && top && top.layer)) {
|
||||
const contentWrapper = $contentWrapper[0];
|
||||
const stack = this._stack[this._stack.length - n];
|
||||
// scrollTopMax and scrollLeftMax only available at Firefox (https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTopMax)
|
||||
@@ -255,6 +286,27 @@ window.Popup = new (class {
|
||||
return this._stack[this._stack.length - 1];
|
||||
}
|
||||
|
||||
// What the popup template draws: the top of the stack - or, when that is a
|
||||
// layer, the popup under it, with the layer drawn on top of it.
|
||||
// `depth` counts the sliding popups only: a layer does not slide.
|
||||
_renderData() {
|
||||
const top = this._getTopStack();
|
||||
const stack = this._stack.filter(entry => !entry.layer);
|
||||
const depth = Math.max(0, stack.length - 1);
|
||||
if (!top || !top.layer) return { ...top, depth, stack, layer: null };
|
||||
return { ...this._stack[this._stack.length - 2], depth, stack, layer: top };
|
||||
}
|
||||
|
||||
// A layer's place in the popup, next to its button (models/lib/popupLayerPosition.js).
|
||||
_layerStyle(openerElement) {
|
||||
const popover = openerElement && openerElement.closest('.js-pop-over');
|
||||
if (!popover) return '';
|
||||
return popupLayerStyle(popupLayerPosition({
|
||||
opener: openerElement.getBoundingClientRect(),
|
||||
popover: popover.getBoundingClientRect(),
|
||||
}));
|
||||
}
|
||||
|
||||
_cleanupPreviousPopupContent() {
|
||||
// Force a re-render to ensure proper cleanup
|
||||
if (this._dep) {
|
||||
|
||||
@@ -120,6 +120,16 @@ sees no + and disabled checkboxes.
|
||||
|
||||
A hamburger is drawn **only when it has something in it**.
|
||||
|
||||
A hamburger's menu - and the forms Card Settings opens, a custom field's edit
|
||||
form and the + under Custom Fields - opens **on top of Card Settings**, next
|
||||
to the button that opened it, and Card Settings stays visible under it,
|
||||
scrolled where it was. It opens toward the middle of Card Settings: a
|
||||
hamburger at the end of a line gets the menu's right edge at its own, the +
|
||||
on the left its left edge; below the button, or above it when there is more
|
||||
room there. Its X, its own button again, a press anywhere else in Card
|
||||
Settings, or Escape closes it; another hamburger replaces it. This is a popup
|
||||
**layer** (`client/lib/popup.js`, `Popup.open(name, { layer: true })`).
|
||||
|
||||
| Hamburger | Holds |
|
||||
| --- | --- |
|
||||
| A row's | Labels text's way back to the board's default, while the user overrides it. Every other row has none. |
|
||||
@@ -169,13 +179,14 @@ setting, as checklists do:
|
||||
| `client/components/sidebar/sidebar.jade` | `boardCardSettingsPopup` is `+cardSettingsInline`; `cardSettingsInlineSection`, `cardSettingsSectionPopup`, `cardSettingsItemPopup` |
|
||||
| `client/components/sidebar/sidebar.js` | The sections (`inlineSectionsOf`, `single`, `customFieldItems`), the rows (`sectionRowsOf`), the handlers (`cardSettingsEvents`, `js-card-settings-section-shown`, `js-card-settings-single-toggle`, the custom field ones), the drags (`reorderOnDrop`, `ownListOnly`) |
|
||||
| `client/components/cards/cardAddFields.css` | The checklist look |
|
||||
| `client/lib/popup.js`, `client/components/main/popup.tpl.jade`, `models/lib/popupLayerPosition.js` | The hamburgers' menus as layers on top of Card Settings, and where one sits |
|
||||
| `models/lib/cardSettingsSections.js` | The sections, their order, and whether one is shown: `hiddenSectionsOf`, `sectionShown`, `hiddenSectionsAfter`, `rowKeysHiddenBySections`, `isSingleRowSection`, `singleLineChecked` |
|
||||
| `models/lib/cardSettingsCustomFields.js` | Custom Fields' items: their order, their checkbox per side, their new `sort` after a drop |
|
||||
| `models/lib/cardShownFields.js` | `boardForCard` / `boardForMinicard`: the board as its cards / minicards see it, the rows of hidden sections reading false |
|
||||
| `client/components/cards/cardAddFields.js` | `cardBoardView`, `CARD_FIELD_TEMPLATES`: the opened card's templates read that view |
|
||||
| `client/components/cards/minicard.js` | `MINICARD_FIELD_TEMPLATES` and its helpers read the minicard's view |
|
||||
| `models/boards.js` | `cardSettingsSectionOrder`, `cardSettingsHiddenOnCard`, `cardSettingsHiddenOnMinicard`, `setCardSettingsSectionOrder`, `setCardSettingsSectionShown` |
|
||||
| `tests/cardSettingsSectionShown.test.cjs`, `tests/cardSettingsCustomFields.test.cjs`, `tests/cardSettingsOnCard.test.cjs` | The rules, the layout, the handlers |
|
||||
| `tests/cardSettingsSectionShown.test.cjs`, `tests/cardSettingsCustomFields.test.cjs`, `tests/cardSettingsOnCard.test.cjs`, `tests/popupLayer.test.cjs` | The rules, the layout, the handlers, the layers |
|
||||
| `tests/playwright/specs/card-add-fields.e2e.js` | The popup, the checkboxes, the hamburgers, Custom Fields, the drag and collapse |
|
||||
|
||||
Card Settings itself reads the board as it is - its checkboxes show each
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
// Where a popup LAYER sits (client/lib/popup.js `Popup.open(name, { layer:
|
||||
// true })`): a menu opened from inside a popup - a hamburger of Card Settings -
|
||||
// drawn ON TOP of that popup, next to the button that opened it, while the
|
||||
// popup stays visible under it. Coordinates are relative to the popup's own
|
||||
// box, which the layer is positioned in.
|
||||
//
|
||||
// opener the button's rect { top, bottom, left, right }
|
||||
// popover the popup's rect { top, bottom, left, right }
|
||||
//
|
||||
// The layer opens toward the middle of the popup: a button in its right half
|
||||
// (a hamburger at the end of a line) gets the layer's right edge at its own, a
|
||||
// button in its left half (the + under Custom Fields) its left edge - so a
|
||||
// wide form never runs out of the popup's side. `maxWidth` is the room on
|
||||
// that side. Being physical, this is right in a right-to-left page too. It
|
||||
// opens below the button when there is room for `minHeight` there or at
|
||||
// least as much room as above, else above it; `maxHeight` is the room on that
|
||||
// side, so a long menu scrolls inside the popup rather than past it.
|
||||
function popupLayerPosition({ opener, popover, gap = 2, margin = 8, minHeight = 200 }) {
|
||||
const below = popover.bottom - opener.bottom - gap - margin;
|
||||
const above = opener.top - popover.top - gap - margin;
|
||||
const down = below >= minHeight || below >= above;
|
||||
const middle = (popover.left + popover.right) / 2;
|
||||
const fromLeft = (opener.left + opener.right) / 2 < middle;
|
||||
const x = fromLeft ? opener.left - popover.left : popover.right - opener.right;
|
||||
const room = fromLeft ? popover.right - opener.left - margin : opener.right - popover.left - margin;
|
||||
return {
|
||||
vertical: down ? 'top' : 'bottom',
|
||||
y: Math.round(down ? opener.bottom - popover.top + gap : popover.bottom - opener.top + gap),
|
||||
horizontal: fromLeft ? 'left' : 'right',
|
||||
x: Math.max(0, Math.round(x)),
|
||||
maxHeight: Math.max(0, Math.round(down ? below : above)),
|
||||
maxWidth: Math.max(0, Math.round(Math.min(room, popover.right - popover.left - margin))),
|
||||
};
|
||||
}
|
||||
|
||||
// The layer's inline style from its position.
|
||||
function popupLayerStyle(pos) {
|
||||
if (!pos) return '';
|
||||
return `${pos.vertical}:${pos.y}px; ${pos.horizontal}:${pos.x}px; max-height:${pos.maxHeight}px; max-width:${pos.maxWidth}px;`;
|
||||
}
|
||||
|
||||
module.exports = { popupLayerPosition, popupLayerStyle };
|
||||
@@ -88,7 +88,9 @@ test('Card Settings: only the board\'s actions, in the hamburger of their sectio
|
||||
assert.match(sidebarJs, /\.filter\(action => action\.board && mayBoard\)/);
|
||||
const click = sidebarJs.slice(sidebarJs.indexOf("'click .js-card-settings-action'(evt) {"));
|
||||
const handler = click.slice(0, click.indexOf('\n },\n'));
|
||||
assert.match(handler, /if \(!action \|\| !action\.board \|\| !\(Utils\.canModifyBoard && Utils\.canModifyBoard\(\)\)\) return;\n\s+Popup\.open\(action\.popup\)\(evt\);/);
|
||||
// Opened as a layer on top of Card Settings, which stays visible under it
|
||||
// (client/lib/popup.js `layer`), not in its place - a deliberate change.
|
||||
assert.match(handler, /if \(!action \|\| !action\.board \|\| !\(Utils\.canModifyBoard && Utils\.canModifyBoard\(\)\)\) return;\n\s+Popup\.open\(action\.popup, \{ layer: true \}\)\(evt\);/);
|
||||
assert.ok(!/filterSubtasksOf|settingsCard|dataContext: card/.test(sidebarJs), 'negative: Card Settings has no card path left');
|
||||
});
|
||||
|
||||
|
||||
@@ -99,8 +99,10 @@ test('the section: definitions as items, its own checkbox the Custom Fields flag
|
||||
assert.match(js, /definitionToggleSet\(side,\n\s+evt\.currentTarget\.getAttribute\('aria-checked'\) !== 'true'\)/);
|
||||
assert.match(js, /Object\.entries\(sorts\)\.forEach\(\(\[id, sort\]\) => CustomFields\.update\(id, \{ \$set: \{ sort \} \}\)\);/);
|
||||
assert.match(js, /ownListOnly: true,\n\s+items: '> \.js-card-settings-custom-field\.is-positioned', handle: '\.js-card-settings-custom-field-handle',/);
|
||||
assert.match(js, /Popup\.open\('createCustomField', \{ dataContext: \{ boardId: Session\.get\('currentBoard'\) \} \}\)\(evt\);/);
|
||||
assert.match(js, /Popup\.open\('editCustomField', \{ dataContext: definition \}\)\(evt\);/);
|
||||
// Opened as a layer on top of Card Settings, which stays visible under it
|
||||
// (client/lib/popup.js `layer`), not in its place - a deliberate change.
|
||||
assert.match(js, /Popup\.open\('createCustomField', \{ dataContext: \{ boardId: Session\.get\('currentBoard'\) \}, layer: true \}\)\(evt\);/);
|
||||
assert.match(js, /Popup\.open\('editCustomField', \{ dataContext: definition, layer: true \}\)\(evt\);/);
|
||||
// The + is Create Field: it is not also a hamburger entry, so Custom Fields
|
||||
// has no hamburger at all.
|
||||
assert.deepStrictEqual(require('../models/lib/cardSettingsSections.js').cardActionsOf('customFields'), []);
|
||||
|
||||
@@ -19,7 +19,7 @@ async function openSettings(page, section) {
|
||||
async function openCardSectionMenu(page) {
|
||||
const popup = page.locator('.pop-over[data-popup="boardCardSettingsPopup"]');
|
||||
await popup.locator('.js-card-settings-section[data-section="card"] .js-open-card-settings-section-menu').click();
|
||||
const menu = page.locator('.js-pop-over[data-popup="cardSettingsSectionPopup"]');
|
||||
const menu = page.locator('.js-pop-over .js-popup-layer[data-popup="cardSettingsSectionPopup"]');
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
}
|
||||
|
||||
@@ -52,28 +52,68 @@ const sectionOf = (inline, id) => inline.locator(`.js-card-settings-section[data
|
||||
const itemOf = (inline, key) => inline.locator(`.js-card-field-order-row[data-key="${key}"]`);
|
||||
async function openItemMenu(page, inline, key) {
|
||||
await itemOf(inline, key).locator('.js-open-card-settings-item-menu').click();
|
||||
const menu = page.locator('.js-pop-over[data-popup="cardSettingsItemPopup"]');
|
||||
const menu = page.locator('.js-pop-over .js-popup-layer[data-popup="cardSettingsItemPopup"]');
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
}
|
||||
// A hamburger opened from Card Settings takes the popup's place (its back
|
||||
// arrow returns to Card Settings); the X closes the popup, as every popup's does.
|
||||
// A hamburger opened from Card Settings is a layer on top of it, next to its
|
||||
// button, Card Settings still visible under it (client/lib/popup.js `layer`);
|
||||
// the popup's X closes both, the layer's X the layer alone.
|
||||
async function closePopup(page) {
|
||||
const close = page.locator('.js-pop-over .js-close-pop-over');
|
||||
await close.first().click();
|
||||
await expect(page.locator('.js-pop-over')).toHaveCount(0);
|
||||
}
|
||||
async function backToCardSettings(page) {
|
||||
await page.locator('.js-pop-over .js-back-view').first().click();
|
||||
await page.locator('.js-pop-over .js-popup-layer .popup-layer-close').click();
|
||||
await expect(page.locator('.js-pop-over .js-popup-layer')).toHaveCount(0);
|
||||
await expect(settingsPopup(page)).toBeVisible();
|
||||
}
|
||||
async function openSectionMenu(page, inline, id) {
|
||||
await sectionOf(inline, id).locator('.js-open-card-settings-section-menu').click();
|
||||
const menu = page.locator('.js-pop-over[data-popup="cardSettingsSectionPopup"]');
|
||||
const menu = page.locator('.js-pop-over .js-popup-layer[data-popup="cardSettingsSectionPopup"]');
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
}
|
||||
|
||||
// A hamburger opens ON TOP of Card Settings, next to its button, and Card
|
||||
// Settings stays visible and where it was scrolled; another hamburger replaces
|
||||
// it, its own button and a press elsewhere close it.
|
||||
test('a hamburger menu opens on top of Card Settings, next to its button, Card Settings still visible', async ({ boardPage: page }) => {
|
||||
await openBoardCardSettings(page);
|
||||
const popup = settingsPopup(page);
|
||||
const inline = popup.locator('.js-card-settings-inline');
|
||||
const button = sectionOf(inline, 'card').locator('.js-open-card-settings-section-menu');
|
||||
await button.click();
|
||||
const layer = popup.locator('.js-popup-layer[data-popup="cardSettingsSectionPopup"]');
|
||||
await expect(layer).toBeVisible();
|
||||
// Card Settings is still there and visible under it.
|
||||
await expect(sectionOf(inline, 'date')).toBeVisible();
|
||||
await expect(popup).toHaveAttribute('data-popup', 'boardCardSettingsPopup');
|
||||
// Next to its button: just below it, its end edge at the button's, inside the popup.
|
||||
const b = await button.boundingBox();
|
||||
const l = await layer.boundingBox();
|
||||
const p = await popup.boundingBox();
|
||||
expect(Math.abs(l.y - (b.y + b.height))).toBeLessThan(8);
|
||||
expect(Math.abs((l.x + l.width) - (b.x + b.width))).toBeLessThan(8);
|
||||
expect(l.x).toBeGreaterThanOrEqual(p.x);
|
||||
expect(l.y + l.height).toBeLessThanOrEqual(p.y + p.height + 1);
|
||||
// Its own button closes it; Card Settings stays.
|
||||
await button.click();
|
||||
await expect(layer).toHaveCount(0);
|
||||
await expect(sectionOf(inline, 'date')).toBeVisible();
|
||||
// A press elsewhere in Card Settings closes it too.
|
||||
await button.click();
|
||||
await expect(layer).toBeVisible();
|
||||
await sectionOf(inline, 'date').locator('.card-settings-inline-section-title').click();
|
||||
await expect(layer).toHaveCount(0);
|
||||
// Negative: the layer's X closes the layer alone, not Card Settings.
|
||||
await button.click();
|
||||
await popup.locator('.js-popup-layer .popup-layer-close').click();
|
||||
await expect(layer).toHaveCount(0);
|
||||
await expect(popup).toBeVisible();
|
||||
});
|
||||
|
||||
test('an empty card shows every field its board shows on cards - Due, Members, Labels - with no per-card action', async ({ boardPage: page, board }) => {
|
||||
const card = db.findOne('cards', { boardId: board.boardId, title: 'Alpha Card' });
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsDueDate: true, allowsMembers: true, allowsLabels: true, allowsComments: true } });
|
||||
@@ -482,8 +522,15 @@ test('Custom Fields: its items are the board\'s custom fields; + adds one, the l
|
||||
const last = await customItems(inline).last().boundingBox();
|
||||
expect((await plus.boundingBox()).y).toBeGreaterThan(last.y);
|
||||
await plus.click();
|
||||
const form = page.locator('.js-pop-over[data-popup="createCustomFieldPopup"]');
|
||||
const form = page.locator('.js-pop-over .js-popup-layer[data-popup="createCustomFieldPopup"]');
|
||||
await expect(form).toBeVisible();
|
||||
// The + is at the left: the form opens rightward, inside Card Settings,
|
||||
// which stays visible under it.
|
||||
const box = await form.boundingBox();
|
||||
const pop = await settingsPopup(page).boundingBox();
|
||||
expect(box.x).toBeGreaterThanOrEqual(pop.x);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(pop.x + pop.width + 1);
|
||||
await expect(cf).toBeVisible();
|
||||
await form.locator('.js-field-name').fill('Risk');
|
||||
await form.locator('button.primary, .primary').first().click();
|
||||
await expect.poll(() => fieldsOf(board).map(f => f.name).sort()).toEqual(['Estimate', 'Priority', 'Risk']);
|
||||
@@ -516,7 +563,7 @@ test('Custom Fields: an item\'s checkbox per side - Show on Minicard is showOnCa
|
||||
await expect.poll(() => db.findOne('customFields', { _id: id }).showOnCard).toBe(false);
|
||||
// Its hamburger is the custom field's own edit form.
|
||||
await customItems(inline).first().locator('.js-card-settings-edit-custom-field').click();
|
||||
await expect(page.locator('.js-pop-over[data-popup="editCustomFieldPopup"] .js-field-name')).toHaveValue('Priority');
|
||||
await expect(page.locator('.js-pop-over .js-popup-layer[data-popup="editCustomFieldPopup"] .js-field-name')).toHaveValue('Priority');
|
||||
await closePopup(page);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ test('Southern Sotho card settings identify data fields', async ({ boardPage: pa
|
||||
await expect(popup.locator('.js-card-settings-section[data-section="date"] .card-settings-inline-section-title')).toHaveText('Letsatsi');
|
||||
await expect(popup.locator('.js-card-settings-inline')).not.toContainText('masimo');
|
||||
await popup.locator('.js-card-settings-section[data-section="card"] .js-open-card-settings-section-menu').click();
|
||||
const menu = page.locator('.js-pop-over[data-popup="cardSettingsSectionPopup"]');
|
||||
const menu = page.locator('.js-pop-over .js-popup-layer[data-popup="cardSettingsSectionPopup"]');
|
||||
await expect(menu.locator('.js-ask-move-reason')).toBeVisible();
|
||||
await expect(menu).not.toContainText('masimo');
|
||||
await expect(menu.locator('.js-show-empty-card-fields')).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
// A popup LAYER (client/lib/popup.js `Popup.open(name, { layer: true })`): a
|
||||
// menu opened from inside a popup - Card Settings' hamburgers - drawn on top
|
||||
// of that popup next to its button, the popup still visible under it. Before,
|
||||
// a hamburger slid into the popup's place and Card Settings disappeared.
|
||||
// Run: node tests/popupLayer.test.cjs
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { popupLayerPosition, popupLayerStyle } = require('../models/lib/popupLayerPosition.js');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
console.log('popupLayer:');
|
||||
|
||||
const popover = { top: 100, bottom: 900, left: 200, right: 800 };
|
||||
|
||||
test('below a button in the right half, its right edge at the button\'s', () => {
|
||||
const pos = popupLayerPosition({ opener: { top: 150, bottom: 170, left: 760, right: 780 }, popover });
|
||||
assert.deepStrictEqual(pos, { vertical: 'top', y: 72, horizontal: 'right', x: 20, maxHeight: 720, maxWidth: 572 });
|
||||
assert.strictEqual(popupLayerStyle(pos), 'top:72px; right:20px; max-height:720px; max-width:572px;');
|
||||
});
|
||||
|
||||
// The + under Custom Fields is at the left: anchored by its right edge, the
|
||||
// create form ran out of the popup's left side and its Save was unreachable.
|
||||
test('a button in the left half: the layer\'s left edge at the button\'s, opening toward the middle', () => {
|
||||
const pos = popupLayerPosition({ opener: { top: 150, bottom: 170, left: 240, right: 260 }, popover });
|
||||
assert.strictEqual(pos.horizontal, 'left');
|
||||
assert.strictEqual(pos.x, 40);
|
||||
assert.strictEqual(pos.maxWidth, 800 - 240 - 8);
|
||||
});
|
||||
|
||||
test('above its button when there is more room there', () => {
|
||||
const pos = popupLayerPosition({ opener: { top: 820, bottom: 840, left: 760, right: 780 }, popover });
|
||||
assert.strictEqual(pos.vertical, 'bottom');
|
||||
assert.strictEqual(pos.y, 900 - 820 + 2);
|
||||
assert.strictEqual(pos.maxHeight, 820 - 100 - 2 - 8);
|
||||
});
|
||||
|
||||
test('below while there is room for a menu, even with more room above', () => {
|
||||
const pos = popupLayerPosition({ opener: { top: 600, bottom: 620, left: 760, right: 780 }, popover });
|
||||
assert.strictEqual(pos.vertical, 'top');
|
||||
assert.strictEqual(pos.maxHeight, 900 - 620 - 2 - 8);
|
||||
});
|
||||
|
||||
test('physical, so the same right to left: a hamburger on the left opens rightward', () => {
|
||||
const pos = popupLayerPosition({ opener: { top: 150, bottom: 170, left: 220, right: 240 }, popover });
|
||||
assert.strictEqual(pos.horizontal, 'left');
|
||||
assert.strictEqual(pos.x, 20);
|
||||
});
|
||||
|
||||
test('negative: never outside the popup', () => {
|
||||
const pos = popupLayerPosition({ opener: { top: 150, bottom: 170, left: 790, right: 820 }, popover });
|
||||
assert.strictEqual(pos.x, 0);
|
||||
// Its width is capped to the room on its side.
|
||||
assert.ok(pos.maxWidth <= 800 - 200);
|
||||
assert.strictEqual(popupLayerStyle(null), '');
|
||||
});
|
||||
|
||||
test('Popup keeps the popup under a layer, and back() closes the layer alone', () => {
|
||||
const js = read('client/lib/popup.js');
|
||||
assert.match(js, /const asLayer = Boolean\(openOptions\.layer\) && self\.isOpen\(\) && clickFromPopup\(evt\);/);
|
||||
// One layer at a time; its own button closes it.
|
||||
assert.match(js, /if \(top && top\.layer\) \{\n\s+\/\/[^\n]*\n\s+\/\/[^\n]*\n\s+self\._stack\.pop\(\);/);
|
||||
assert.match(js, /if \(top\.openerElement === evt\.currentTarget\) \{/);
|
||||
// Drawn: the popup under the layer, with the layer; depth counts sliding popups.
|
||||
assert.match(js, /return \{ \.\.\.this\._stack\[this\._stack\.length - 2\], depth, stack, layer: top \};/);
|
||||
assert.match(js, /const stack = this\._stack\.filter\(entry => !entry\.layer\);/);
|
||||
// A layer does not scroll the popup under it.
|
||||
assert.match(js, /if \(\$contentWrapper\.length > 0 && !asLayer\) \{/);
|
||||
const tpl = read('client/components/main/popup.tpl.jade');
|
||||
assert.match(tpl, /if layer\n\s+\.popup-layer\.js-popup-layer\(data-popup="\{\{layer\.popupName\}\}"/);
|
||||
// Negative: the layer is outside the scrolling content, so it stays by its button.
|
||||
assert.ok(tpl.indexOf('.popup-layer.js-popup-layer') > tpl.indexOf('.clearfix'));
|
||||
const events = read('client/components/main/popup.js');
|
||||
assert.match(events, /'mousedown \.content-wrapper'\(evt\) \{/);
|
||||
});
|
||||
|
||||
test('every hamburger and form of Card Settings opens as a layer', () => {
|
||||
const js = read('client/components/sidebar/sidebar.js');
|
||||
for (const name of ['cardSettingsSection', 'cardSettingsItem', 'editCustomField', 'createCustomField']) {
|
||||
const calls = [...js.matchAll(new RegExp(`Popup\\.open\\('${name}'[^\\n]*`, 'g'))].map(m => m[0]);
|
||||
assert.ok(calls.length, name);
|
||||
for (const call of calls) assert.match(call, /layer: true/, call);
|
||||
}
|
||||
assert.match(js, /Popup\.open\(action\.popup, \{ layer: true \}\)\(evt\);/);
|
||||
});
|
||||
|
||||
console.log(`popupLayer: ${passed} passed`);
|
||||
Reference in new issue
Block a user