Board Settings / Card is the opened card's Card Settings, same templates.

Board Settings / Card had its own layout: an Admin Panel left menu of
sections and a pane with two lists, column headings and per-column Select
all. It now renders the same template as the opened card's Card Settings
(cardSettingsInline), with no card: Show on Minicard / Show on Card, the
sections as checklists, their rows as the items, and the same section and
row hamburgers. The popup's header is the title and the close button, so
the inline's own title and X are not drawn there, and a row's hamburger
has no On this card. The popup is 600px wide, about an opened card,
instead of 1200px.

The popup no longer registers the Card Settings handlers itself: the
template inside it does, and registering them on both would handle every
click twice. The left menu, the pane, the column Select all, the
in-place action forms and the personal-only mode are removed with their
helpers and CSS. A non-admin sees what a card shows them: the board's
rows disabled, their own rows (Labels text, List title) usable.

Dragging, on the card and in Board Settings / Card:
- With drag handles on, a row or a section is dragged only by its
  handle; the rest of the line scrolls the page. With them off, by its
  icon and name.
- A row dropped on another section goes back where it was and nothing
  is saved: the sections are unrelated kinds of fields. jQuery UI fires
  a delayed "out" just before "stop", so where it was dropped is read
  in beforeStop.

Found while testing both open at once:
- Each Card Settings has its own Show radio group name; one name made
  the card's and the popup's radios one group.
- A section hamburger's Select all acts on the Card Settings it was
  opened from.
- Rows are keyed, so switching Show moves each row with its side instead
  of relabelling rows in place a flush before they reorder, when a
  click could land on the neighbouring field.

Docs: Card-Settings-On-Card.md, Card-Fields.md and Board Settings /
Card.md. The node guards that pinned the old layout now pin the shared
template, with negatives that the old layout and the double handler
registration are gone; card-add-fields.e2e.js compares the two views
section by section and row by row on both sides, and tests the drag
rules, and the specs that drove the old pane drive the new one.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-10-28 20:23:05 +02:00
1 parent 81e6988491
commit 52de57c844
28 files changed
+1060 -912

No files matched your search

+7 -2
View File
@@ -81,8 +81,8 @@
/* Collapsed: the items hidden, not removed, so their drag stays bound. */
.card-settings-inline-section.is-collapsed .card-settings-inline-items { display: none; }
/* An item: the checkbox, the name, and its hamburger at the end - not the
popup's fixed columns (sidebar.css .card-settings-line). */
/* An item: the checkbox, the name, and its hamburger at the end
(sidebar.css .card-settings-line is the line itself). */
.card-settings-inline .card-settings-line {
grid-template-columns: auto minmax(0, 1fr) auto;
border-bottom: 0;
@@ -91,6 +91,11 @@
.card-settings-inline .card-settings-line.ui-sortable-helper { background: #fff; }
.card-settings-inline .materialCheckBox { transition: none; }
.card-settings-inline .card-field-order-toggle.is-animating .materialCheckBox { transition: 0.2s; }
/* The hamburgers' checkboxes, likewise: only the one just clicked animates. */
.card-settings-section-menu .materialCheckBox,
.card-settings-item-menu .materialCheckBox { transition: none; }
.card-settings-section-menu .is-animating .materialCheckBox,
.card-settings-item-menu .is-animating .materialCheckBox { transition: 0.2s; }
/* The hamburgers' popups. */
.card-settings-section-menu .card-settings-section-menu-select {
+5 -10
View File
@@ -367,19 +367,14 @@
max-width: 90vw !important;
}
/* Board Settings / Card: Draggable, Show on Minicard and Show on Card.
#6796: as wide as the window allows, up to 1800px - width buys COLUMNS
inside each list (sidebar.css), and enough of them means the whole of
both lists is read without scrolling. client/lib/popupOffset.js centres
it at this width (CARD_SETTINGS_MAX_WIDTH) and gives it the window's
height. */
/* Board Settings / Card, and the opened card's "+ Add": the Admin Panel's
left menu and right pane, wide enough for a row's label and both sides
(sidebar.css .card-settings-sections). */
/* Board Settings / Card: the opened card's Card Settings, one column of
checklists (sidebar.jade cardSettingsInline), about as wide as an opened
card. client/lib/popupOffset.js centres it at this width
(CARD_SETTINGS_MAX_WIDTH) and gives it the window's height (#6796). */
.pop-over[data-popup='boardCardSettingsPopup'] {
position: fixed;
box-sizing: border-box;
width: min(96vw, 1200px) !important;
width: min(96vw, 600px) !important;
max-width: 96vw !important;
max-height: calc(100dvh - 24px) !important;
margin-top: 0;
+8 -126
View File
@@ -147,55 +147,17 @@
cursor: pointer;
}
/* Board Settings / Card - also the opened card's "+ Add" - is the Admin
Panel's left menu and right pane (sidebar.jade boardCardSettingsPopup,
docs/Features/Cards/Card-Fields.md): the sections on the left, the open
section's rows on the right, one line each - the label, then the
"Show on Minicard" cell and the "Show on Card" cell (a checkbox, the
position and the order arrows of that side), and opened from a card the
"On this card" checkbox. */
.board-card-settings {
container-type: inline-size;
}
.card-settings-sections .setting-content {
position: relative;
/* High enough for every entry of the left menu - Card Settings and the
twelve sections - where the window has the room, so the menu does not
scroll; a shorter window caps it. */
height: 52em;
max-height: calc(100dvh - 7em);
background: transparent;
}
.card-settings-sections .setting-content .content-body { height: 100%; }
.card-settings-sections .main-body { overflow: auto; flex: 1 1 auto; min-width: 16em; }
.card-settings-pane-head,
/* Card Settings - on the opened card and in Board Settings / Card, one
template (sidebar.jade cardSettingsInline; its checklist look is
client/components/cards/cardAddFields.css): a row's line, its checkbox and
its name. */
.card-settings-line {
display: grid;
/* Two lists: Show on Card - opened from a card On this card first, then
Show on Card, Show empty (#6806) and the field - and Show on Minicard,
then the field. Logical order: a right-to-left page mirrors it. Each
line is its own grid, so the side columns have FIXED widths - sized by
content, every line and the heading line would size them differently and
nothing would line up. */
grid-template-columns: 9em 8em minmax(10em, 1fr);
column-gap: 14px;
align-items: center;
/* Every heading and every cell starts at its column's left edge (its start,
right-to-left), so a heading and the checkboxes under it line up. */
justify-items: start;
text-align: start;
}
.board-card-settings.has-card .card-settings-list-card > .card-settings-pane-head,
.board-card-settings.has-card .card-settings-list-card > .card-settings-line {
grid-template-columns: 7em 9em 8em minmax(10em, 1fr);
}
.card-settings-list-minicard > .card-settings-pane-head,
.card-settings-list-minicard > .card-settings-line {
grid-template-columns: 9em minmax(10em, 1fr);
}
/* The minicard's list under the card's, a little apart. */
.card-settings-list + .card-settings-list {
margin-block-start: 14px;
padding: calc(4px * min(1, var(--wekan-ui-font-scale, 1))) 0;
border-bottom: 1px solid #eee;
}
/* What a row is dragged by: its handle (drag handles on), its icon or name. */
.card-field-order-drag-label {
@@ -205,30 +167,6 @@
background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.card-settings-pane-head {
font-weight: bold;
border-bottom: 1px solid #eee;
padding-block-end: 4px;
align-items: start;
}
/* The headings' smaller font is on the headings, not on their line: the
columns are in em, and a smaller font on the line made every column of the
heading narrower than the same column of the rows - each heading drifted
further left of its checkboxes, and the rows' labels past the pane. */
.card-settings-pane-head > span {
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
}
.card-settings-line {
padding: calc(4px * min(1, var(--wekan-ui-font-scale, 1))) 0;
border-bottom: 1px solid #eee;
}
.card-settings-side-head {
/* A heading longer than its column, in another language, wraps in it. */
overflow-wrap: anywhere;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.card-settings-line-label {
display: inline-flex;
align-items: center;
@@ -246,9 +184,8 @@
.card-settings-side > a.flex {
margin: 0;
}
/* Select all / Select none under a column's heading, for that column's rows
of the open section: the Draggable table's buttons
(boardSettingsColumnActions.css), a little closer to their heading. */
/* Select all / Select none in a section's hamburger: the Draggable table's
buttons (boardSettingsColumnActions.css). */
.card-settings-select {
margin: 3px 0 0;
font-weight: normal;
@@ -256,24 +193,6 @@
.card-settings-select button {
white-space: nowrap;
}
.card-settings-inline-action {
/* The forms float their buttons (Save left, Delete right); flow-root keeps
those floats inside the form, or the rows below wrapped around them. */
display: flow-root;
padding: 6px 0 10px;
border-bottom: 1px solid #eee;
}
.card-settings-inline-action h4 { margin: 0 0 6px; }
/* Create Field and Link fields to another card are forms of their own in the
Custom Fields pane: the list's two buttons that open them are not repeated. */
.card-settings-inline-action .card-custom-fields-more { display: none; }
.card-settings-sections .materialCheckBox { transition: none; }
.card-settings-sections .card-add-field.is-animating .materialCheckBox,
.card-settings-sections .card-field-order-toggle.is-animating .materialCheckBox,
.card-settings-sections .card-always-show.is-animating .materialCheckBox,
.card-settings-sections .card-show-empty-fields.is-animating .materialCheckBox,
.card-settings-sections .card-ask-move-reason.is-animating .materialCheckBox { transition: 0.2s; }
.card-field-order-toggle,
.card-field-order-move {
flex-shrink: 0;
@@ -287,45 +206,8 @@
cursor: not-allowed;
}
/* Show empty fields: every field is shown on every card, so the Show empty
column is set aside - hidden, and its track taken out of the grid. */
.board-card-settings.show-empty-fields .card-settings-side-always,
.board-card-settings.show-empty-fields .card-settings-side-head-always {
display: none;
}
.board-card-settings.show-empty-fields .card-settings-list-card > .card-settings-pane-head,
.board-card-settings.show-empty-fields .card-settings-list-card > .card-settings-line {
grid-template-columns: 9em minmax(10em, 1fr);
}
.board-card-settings.show-empty-fields.has-card .card-settings-list-card > .card-settings-pane-head,
.board-card-settings.show-empty-fields.has-card .card-settings-list-card > .card-settings-line {
grid-template-columns: 7em 9em minmax(10em, 1fr);
}
.card-settings-board-option { margin-block-end: 6px; }
/* One side of the same popup: a menu that asks for one side hides the other
side's list, so there is still ONE set of lists and one set of handlers. */
.board-card-settings.show-card-only .card-settings-list-minicard,
.board-card-settings.show-minicard-only .card-settings-list-card {
display: none;
}
/* "Labels text" and "List title" are the user's or the card's own, not the
board's: they are not among the board-wide rows of Board Settings / Card,
but they are where they belong - opened from a card, from the minicard's
own view, and for a reader who may change only those. */
.board-card-settings .card-settings-row-personal {
display: none;
}
.board-card-settings.show-minicard-only .card-settings-row-personal,
.board-card-settings.has-card .card-settings-row-personal,
.board-card-settings.show-personal-only .card-settings-row-personal {
display: grid;
}
.board-card-settings.show-personal-only .card-settings-line:not(.card-settings-row-personal) {
display: none;
}
/* Board Settings / Board View: the same table as Card Settings above, with
FIVE columns (Default/Show on Public, Default/Show on Private, the view's
name) - or three when Admin Panel hides public boards and the popup does
+32 -162
View File
@@ -270,141 +270,16 @@ template(name="boardInfoOnMyBoardsPopup")
| {{_ 'personal-list-width'}}
//- Board Settings / Card - what the opened card and the minicard show, and
in what order. Opened from the board's cog menu (boardMenuPopup above); it
used to be split across the CARD's own menu ("Show on Card", side="card")
and the MINICARD's own menu ("Show on Minicard", side="minicard"), and
settingsSideClass() still hides one column when a side is passed.
"Labels text" - the one PERSONAL setting here - stays reachable by a
non-admin, who is passed `personalOnly=true` so every board-wide,
admin-only row is hidden and only that one shows. client/lib/minicardLabelText.js
//- ONE heading, "Card field order", over TWO lists: "Show on Minicard" in
the board's minicard order and "Show on Card" in its card order. The lists
are independent because the orders are: a field can sit third on the
minicard and last on the card. Every row of either list is
`[checkbox] [up] [down] icon label` - the checkbox is whether that side
shows the field, the arrows move it on THAT side only, and the icon and
label are the field's own (models/lib/cardSettingsRows.js). The rows are
not written out here: the two `each` loops draw them from that table in
the order models/lib/cardFieldOrder.js resolves for the board, the SAME
source cardDetails.jade and minicard.jade render from, so what the popup
lists top to bottom is what the card and the minicard draw top to bottom.
A row whose arrows would do nothing (the card's title bar and its
galleries, a minicard row that modifies another element rather than being
one) keeps them, disabled, so every row reads the same.
Up/down buttons rather than a drag library: no sortable-based reorder of a
plain settings LIST exists elsewhere in this codebase to reuse, so buttons
are the simpler, safer mechanism. Keyboard-reachable: `href="#"` and
`role="button"`, titled with the existing move-up/move-down keys.
client/components/sidebar/sidebar.js, tests/cardSettingsCoverage.test.cjs
in what order. Opened from the board's cog menu (boardMenuPopup above).
Whoever may not change the board sees the board's rows disabled and may
still set their own ("Labels text", "List title"), as on the opened card.
template(name="boardCardSettingsPopup")
//- Board Settings / Card, and the opened card's "+ Add": ONE popup, the Admin
Panel's left menu and right pane (docs/Features/Cards/Card-Fields.md). The
menu lists the sections (models/lib/cardSettingsSections.js); the pane the
open section's rows, each with its "Show on Minicard" and "Show on Card"
cell - a checkbox and the order arrows of that side - and, opened from a
card, whether THIS card shows the field. A side cell is the
`.js-card-field-order-row` the handlers read data-key and data-side from.
form.board-card-settings.card-settings-sections(class=settingsSideClass)
.setting-content
.content-body
+leftMenu(settingsMenuItems)
.main-body
+paneTitle(settingsPaneTitle)
//- The first entry, Card: what is not a field, then its rows.
if openSettingsSectionIsGeneral
//- Every card shows all its fields, the empty ones too; the Show
empty column is then set aside, its ticks kept.
div.check-div.card-settings-board-option.card-settings-show-empty-fields
a.flex.card-show-empty-fields(class="{{#if canModifyBoard}}js-show-empty-card-fields{{else}}is-disabled{{/if}} {{#if isAnimating 'showEmptyCardFields'}}is-animating{{/if}}" role="checkbox" aria-checked="{{#if showEmptyCardFields}}true{{else}}false{{/if}}")
.materialCheckBox(class="{{#if showEmptyCardFields}}is-checked{{/if}}")
span {{_ 'card-settings-show-empty-fields'}}
//- The same checkbox, in the same place, as Show empty fields.
div.check-div.card-settings-board-option.card-settings-ask-move-reason
a.flex.card-ask-move-reason(class="{{#if canModifyBoard}}js-ask-move-reason{{else}}is-disabled{{/if}} {{#if isAnimating 'askForMoveReason'}}is-animating{{/if}}" role="checkbox" aria-checked="{{#if askForMoveReason}}true{{else}}false{{/if}}")
.materialCheckBox(class="{{#if askForMoveReason}}is-checked{{/if}}")
span {{_ 'ask-move-reason'}}
//- No select-all of Show on Minicard / Show on Card here: their
checkboxes are in the sections, not under it.
+boardDragSettings(section="card")
.card-settings-pane
//- Under the title: everything in the hamburger of this section on
the opened card - Filter: Subtasks, Edit voting, Card kind, the
section's settings ... (docs/Features/Cards/Card-Fields.md). The
card's own actions only when opened from a card.
if sectionCardActions.length
ul.pop-over-list.card-settings-card-actions
each action in sectionCardActions
li
//- An inline action is its form, right here (Edit voting,
Change spent time, Card kind, ...); the others open
their popup.
if action.inline
.card-settings-inline-action
h4
i.fa(class=action.icon aria-hidden="true")
| {{action.title}}
+Template.dynamic(template=action.inline data=action.inlineData)
else
a.js-card-settings-action(data-action="{{action.id}}")
i.fa(class=action.icon aria-hidden="true")
| {{action.title}}
//- Two lists, each in its own order and reordered by drag and
drop (sidebar.js reorderOnDrop): Show on Card - the opened
card's fields of this section, in the card's order, with On
this card (opened from a card) and Show empty beside each - and
Show on Minicard, in the minicard's order. Under each heading,
Select all / Select none for that column. The columns are
mirrored in a right-to-left language by the grid itself.
.card-settings-list.card-settings-list-card.js-card-field-order-sortable(data-side="card")
.card-settings-pane-head
if settingsCardId
span.card-settings-side-head.card-settings-side-head-this
span {{_ 'card-settings-this-card'}}
+cardSettingsSelectButtons(column="this")
span.card-settings-side-head.card-settings-side-head-card
span {{_ 'show-on-card'}}
if canModifyBoard
+cardSettingsSelectButtons(column="card")
span.card-settings-side-head.card-settings-side-head-always
span {{_ 'card-settings-show-empty'}}
if canModifyBoard
+cardSettingsSelectButtons(column="always")
span.card-settings-pane-head-label {{_ 'card-field-order'}}
each row in sectionCardRows
.card-settings-line.js-card-field-order-row(class="{{#if row.personal}}card-settings-row-personal{{/if}} {{#if row.positioned}}is-positioned{{/if}}" data-key="{{row.key}}" data-side="card")
//- Opened from a card: whether this card shows the field -
the field with data is shown, an empty one when ticked
here (models/lib/cardShownFields.js).
if settingsCardId
if row.perCard
span.card-settings-side.card-settings-side-this
a.flex.card-add-field.js-card-add-field(data-key="{{row.key}}" role="checkbox" aria-checked="{{#if row.shownOnThisCard}}true{{else}}false{{/if}}" class="{{#if isAnimating row.key}}is-animating{{/if}}" title="{{_ 'card-settings-this-card'}}")
.materialCheckBox(class="{{#if row.shownOnThisCard}}is-checked{{/if}}")
else
span.card-settings-side.card-settings-side-this.card-settings-side-empty
+cardSettingsSideToggle(row=row)
//- #6806: Show empty - every card of the board shows the
field, empty or not - by default the description,
attachments and comments; "On this card" still hides it
from one card. Hidden while Show empty fields is on.
if row.alwaysShowable
span.card-settings-side.card-settings-side-always
a.flex.card-always-show(class="{{#if canModifyBoard}}js-card-always-show{{else}}is-disabled{{/if}} {{#if row.animatingAlways}}is-animating{{/if}}" data-key="{{row.key}}" role="checkbox" aria-checked="{{#if row.alwaysShown}}true{{else}}false{{/if}}" title="{{_ 'card-settings-show-empty'}}")
.materialCheckBox(class="{{#if row.alwaysShown}}is-checked{{/if}}")
else
span.card-settings-side.card-settings-side-always.card-settings-side-empty
+cardSettingsRowLabel(row=row)
.card-settings-list.card-settings-list-minicard.js-card-field-order-sortable(data-side="minicard")
.card-settings-pane-head
span.card-settings-side-head.card-settings-side-head-minicard
span {{_ 'show-on-minicard'}}
if canModifyBoard
+cardSettingsSelectButtons(column="minicard")
span.card-settings-pane-head-label
each row in sectionMinicardRows
.card-settings-line.js-card-field-order-row(class="{{#if row.personal}}card-settings-row-personal{{/if}} {{#if row.positioned}}is-positioned{{/if}}" data-key="{{row.key}}" data-side="minicard")
+cardSettingsSideToggle(row=row)
+cardSettingsRowLabel(row=row)
//- Board Settings / Card is the SAME template as the opened card's Card
Settings (cardSettingsInline below), with no card: Show, then the sections
as checklists, their rows as the items, the rest in their hamburgers
(docs/Features/Cards/Card-Settings-On-Card.md). The popup's own header is
its title and its close button.
+cardSettingsInline(inPopup=true)
//- One side's checkbox of a row of Card Settings: the board's Show on Card or
@@ -417,12 +292,12 @@ template(name="cardSettingsSideToggle")
//- A row's name in Card Settings, and what it is dragged by: with drag handles
on, its handle, its icon or its name; with them off, its icon or its name.
The Up and Down arrow keys move a focused row one step. A row with no place
of its own (one that only modifies another, or is drawn at a fixed place)
is not dragged.
on, ONLY its handle - the rest of the row then scrolls the page, as a touch
there means to; with them off, its icon and its name. The Up and Down arrow
keys move a focused row one step. A row with no place of its own (one that
only modifies another, or is drawn at a fixed place) is not dragged.
template(name="cardSettingsRowLabel")
span.card-settings-line-label(class="{{#if row.draggable}}js-card-field-order-handle card-field-order-drag-label{{/if}}" tabindex="{{#if row.draggable}}{{#unless row.showDragHandles}}0{{/unless}}{{/if}}" title="{{#if row.draggable}}{{_ 'drag-to-reorder'}}{{/if}}")
span.card-settings-line-label(class="{{#if row.draggable}}{{#unless row.showDragHandles}}js-card-field-order-handle card-field-order-drag-label{{/unless}}{{/if}}" tabindex="{{#if row.draggable}}{{#unless row.showDragHandles}}0{{/unless}}{{/if}}" title="{{#if row.draggable}}{{#unless row.showDragHandles}}{{_ 'drag-to-reorder'}}{{/unless}}{{/if}}")
if row.draggable
if row.showDragHandles
a.flex.card-field-order-move.card-field-order-handle.js-card-field-order-handle(href="#" draggable="false" role="button" tabindex="0" title="{{_ 'drag-to-reorder'}}" aria-label="{{_ 'drag-to-reorder'}}")
@@ -442,16 +317,6 @@ template(name="cardSettingsRowLabel")
|  ({{_ 'label-text-follows-board'}})
//- Select all / Select none of one column of Card Settings' pane
(boardCardSettingsPopup's 'click .js-card-settings-select').
template(name="cardSettingsSelectButtons")
//- The Draggable table's buttons (boardSettingsColumnActions): white text
on the theme's colour.
span.board-settings-column-actions.card-settings-select
button.js-card-settings-select(type="button" data-column="{{column}}" data-value="all") {{_ 'select-all'}}
button.js-card-settings-select(type="button" data-column="{{column}}" data-value="none") {{_ 'select-none'}}
//- Card Settings on the opened card (docs/Features/Cards/Card-Settings-On-Card.md).
The card's "Card Settings" button, and everything below it, is replaced by
this until its X closes it: the title, "Show" choosing whether the
@@ -462,21 +327,25 @@ template(name="cardSettingsSelectButtons")
section or a row has is in its hamburger (cardSettingsSectionPopup,
cardSettingsItemPopup). The rows, checkboxes and handlers are the popup's
above (client/components/sidebar/sidebar.js sectionRowsOf,
cardSettingsEvents), so the two cannot disagree. Board Settings / Card
still opens the popup.
cardSettingsEvents), so the two cannot disagree. Board Settings / Card is
this same template, in a popup (boardCardSettingsPopup above).
template(name="cardSettingsInline")
.card-settings-inline.js-card-settings-inline(class=settingsSideClass)
.card-settings-inline-head
h3.card-settings-inline-title {{_ 'card-settings'}}
a.card-settings-inline-close.js-close-card-settings(href="#" role="button" title="{{_ 'close'}}" aria-label="{{_ 'close'}}")
i.fa.fa-times(aria-hidden="true")
.card-settings-inline.js-card-settings-inline(class="{{settingsSideClass}} {{#if inPopup}}card-settings-in-popup{{/if}}")
//- In Board Settings / Card the popup's header is the title and the X.
unless inPopup
.card-settings-inline-head
h3.card-settings-inline-title {{_ 'card-settings'}}
a.card-settings-inline-close.js-close-card-settings(href="#" role="button" title="{{_ 'close'}}" aria-label="{{_ 'close'}}")
i.fa.fa-times(aria-hidden="true")
//- Its own radio name: the opened card's Card Settings and Board Settings /
Card may be open together, and one name would make them one group.
.card-settings-inline-show(role="radiogroup" aria-label="{{_ 'card-settings-show'}}")
span.card-settings-inline-show-label {{_ 'card-settings-show'}}
label.card-settings-inline-show-option
input.js-card-settings-show-side(type="radio" name="card-settings-show-side" value="minicard" checked="{{showsSide 'minicard'}}")
input.js-card-settings-show-side(type="radio" name="card-settings-show-side-{{#if inPopup}}popup{{else}}card{{/if}}" value="minicard" checked="{{showsSide 'minicard'}}")
span {{_ 'show-on-minicard'}}
label.card-settings-inline-show-option
input.js-card-settings-show-side(type="radio" name="card-settings-show-side" value="card" checked="{{showsSide 'card'}}")
input.js-card-settings-show-side(type="radio" name="card-settings-show-side-{{#if inPopup}}popup{{else}}card{{/if}}" value="card" checked="{{showsSide 'card'}}")
span {{_ 'show-on-card'}}
.card-settings-inline-sections.js-card-settings-sections-sortable
each section in inlineSections
@@ -497,7 +366,8 @@ template(name="cardSettingsInlineSection")
if section.showDragHandles
a.card-settings-inline-section-move.js-card-settings-section-handle(href="#" draggable="false" role="button" tabindex="0" title="{{_ 'drag-to-reorder'}}" aria-label="{{_ 'drag-to-reorder'}}")
i.fa.fa-arrows(aria-hidden="true")
h4.card-settings-inline-section-title(class="{{#if section.draggable}}js-card-settings-section-handle{{/if}}" tabindex="{{#if section.draggable}}{{#unless section.showDragHandles}}0{{/unless}}{{/if}}" title="{{#if section.draggable}}{{_ 'drag-to-reorder'}}{{/if}}")
//- Like a row: with drag handles on, only the handle drags the section.
h4.card-settings-inline-section-title(class="{{#if section.draggable}}{{#unless section.showDragHandles}}js-card-settings-section-handle{{/unless}}{{/if}}" tabindex="{{#if section.draggable}}{{#unless section.showDragHandles}}0{{/unless}}{{/if}}" title="{{#if section.draggable}}{{#unless section.showDragHandles}}{{_ 'drag-to-reorder'}}{{/unless}}{{/if}}")
i.fa(class=section.icon aria-hidden="true")
| {{section.title}}
span.card-settings-inline-section-count {{section.checkedCount}}/{{section.rows.length}}
@@ -505,7 +375,7 @@ template(name="cardSettingsInlineSection")
i.fa.fa-bars(aria-hidden="true")
.card-settings-inline-items.js-card-field-order-sortable(data-side="{{side}}")
each row in section.rows
.card-settings-line.card-settings-inline-item.js-card-field-order-row(class="{{#if row.personal}}card-settings-row-personal{{/if}} {{#if row.positioned}}is-positioned{{/if}}" data-key="{{row.key}}" data-side="{{side}}")
.card-settings-line.card-settings-inline-item.js-card-field-order-row(class="{{#if row.personal}}card-settings-row-personal{{/if}} {{#if row.positioned}}is-positioned{{/if}}" data-key="{{row.key}}" data-side="{{row.side}}")
+cardSettingsSideToggle(row=row)
+cardSettingsRowLabel(row=row)
if row.hasMenu
@@ -869,8 +739,8 @@ template(name="boardMenuPopup")
board-wide list of toggles behind two different places depending on
what you were looking at, and which also let anyone open it for the one
PERSONAL row in it ("Labels text" - stored on the user's own profile,
not the board). It is back here as one board setting, and keeps that
same personal-only fallback for a non-admin.
not the board). It is back here as one board setting; a non-admin sees
the board's rows disabled and may still set that personal row.
client/components/cards/cardDetails.jade
ul.pop-over-list
if currentUser.isBoardAdmin
+47 -157
View File
@@ -24,7 +24,6 @@ const { rowsForSide, CARD_SETTINGS_ROWS } = require('/models/lib/cardSettingsRow
const { isRowVisible } = require('/models/lib/cardFieldVisibility');
const cardSettingsSections = require('/models/lib/cardSettingsSections');
const cardShownFields = require('/models/lib/cardShownFields');
import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
import { shownFieldsOf, closeCardSettings } from '/client/components/cards/cardAddFields';
import { filterSubtasksOf } from '/client/lib/filterSubtasks';
import { invalidateScrumNames } from '/client/components/boards/scrum/scrumFields';
@@ -697,17 +696,13 @@ Template.boardMenuPopup.events({
'click .js-open-board-list-settings': Popup.open('boardListSettings', { titleKey: 'list' }),
// #2489: WIP Limit Groups is reached from Board Settings / Swimlane now -
// its click handler sits with the Swimlane popup's events below.
// A non-admin may still open this for the one PERSONAL row in it ("Labels
// text"), same as the old showOnMinicardPopup did - `personalOnly` hides
// every other row (client/components/sidebar/sidebar.css). Overriding
// Board Settings / Card: Card Settings with no card. Overriding
// `currentData` (rather than passing openOptions) is what actually reaches
// boardCardSettingsPopup's data context: Popup.open only falls back to
// `dataContextIfCurrentDataIsUndefined` when currentData() is falsy, and
// here it is always the board.
// here it is always the board - whose fields are not Card Settings' data.
'click .js-open-board-card-settings'(event) {
const isAdmin = Boolean(ReactiveCache.getCurrentUser()?.isBoardAdmin());
const data = isAdmin ? {} : { personalOnly: true };
Popup.open('boardCardSettings').call({ currentData: () => data }, event);
Popup.open('boardCardSettings').call({ currentData: () => ({}) }, event);
},
});
@@ -736,15 +731,28 @@ Template.boardMenuPopup.onCreated(function() {
// `handle` is the drag handle when drag handles are on, else the icon and
// label (the templates put the class on whichever it is).
// `axis`: 'y' for a single column; false where the rows flow into several
// columns (Board Settings / Card, #6796), so a row can be dropped sideways.
function reorderOnDrop($list, { items, handle, keyOf, drop, axis = 'y' }) {
// columns, so a row can be dropped sideways.
// `ownListOnly`: an item dropped outside its own list - on another, unrelated
// list - goes back to where it was, and nothing is written (Card Settings: a
// row belongs to its section).
function reorderOnDrop($list, { items, handle, keyOf, drop, axis = 'y', ownListOnly = false }) {
if (!$list.length || typeof $list.sortable !== 'function') return;
let outside = false;
let droppedOutside = false;
$list.sortable({
items, handle, axis, tolerance: 'pointer', distance: 5,
start() { outside = false; droppedOutside = false; },
out() { outside = true; },
over() { outside = false; },
// Read where it was dropped HERE: on release jQuery UI fires a delayed
// "out" for the list it was over just before "stop", so in stop the item
// always reads as outside.
beforeStop() { droppedOutside = outside; },
stop(evt, ui) {
const key = keyOf(ui.item[0]);
const keys = $list.find(items).toArray().map(keyOf);
$list.sortable('cancel');
if (ownListOnly && droppedOutside) return;
if (key) drop(key, keys);
},
});
@@ -2026,9 +2034,7 @@ Template.boardSubtaskSettingsBody.events({
// Card Settings and its hamburgers - has the state its shared handlers read
// (cardSettingsEvents below).
function setUpCardSettings() {
// The section open in the right pane (models/lib/cardSettingsSections.js),
// and the one per-card checkbox that animates: the one just clicked.
this.settingsSection = new ReactiveVar(null);
// The one checkbox that animates: the one just clicked.
this.animating = new ReactiveVar(null);
// Keep currentBoard reactive. The toggle handlers compute the new value from
// tpl.currentBoard.allowsX, so a one-time snapshot went stale after the first
@@ -2040,12 +2046,11 @@ function setUpCardSettings() {
this.currentBoard = Utils.getCurrentBoard();
});
}
Template.boardCardSettingsPopup.onCreated(setUpCardSettings);
// The card this popup was opened for, when it was opened for one. Board
// Settings / Card Settings has no card - it is about every card of the board -
// so the two rows that belong to a card are only ever drawn from the minicard's
// own menu, which passes it in. client/components/sidebar/sidebar.jade
// The card Card Settings is drawn for: the opened card's own Card Settings
// passes it in (cardDetails.jade `+cardSettingsInline(card=this)`), and its
// hamburgers pass it on. Board Settings / Card has none - it is about every
// card of the board - so On this card is only ever on a card.
function settingsCard() {
const passed = (Template.currentData() || {}).card;
return passed && passed._id ? ReactiveCache.getCard(passed._id) : null;
@@ -2102,37 +2107,12 @@ function buildCardSettingsRows(side, data) {
});
}
// A named object rather than an inline `.helpers({...})`: the two row
// builders below (cardSettingsRows / minicardSettingsRows) ask each row's
// A named object rather than an inline `.helpers({...})`: the row builder
// (buildCardSettingsRows) asks each row's
// "is it checked" helper by name - allowsLabels(), showsListOnMinicard(), ...
// - so a row's checkbox reads exactly what its old hand-written `{{#if
// allowsLabels}}` read, defaults and fallbacks included, without a second
// copy of that logic. Registered at the end of the object.
// The sections of the left menu this popup offers: every one, or - for a
// reader who may set only their own PERSONAL rows - those that have one.
function settingsSectionsFor(data) {
if (!data || !data.personalOnly) return cardSettingsSections.SECTIONS;
return cardSettingsSections.SECTIONS.filter(s => s.keys.some(k => {
const row = CARD_SETTINGS_ROWS.find(r => r.key === k);
return row && ['card', 'minicard'].some(side => row[side] && row[side].personal);
}));
}
function openSettingsSectionOf(tpl, data) {
const sections = settingsSectionsFor(data);
const chosen = tpl && tpl.settingsSection && tpl.settingsSection.get();
// It opens on the topmost entry, Card - from a card's "+ Add" too.
return (sections.find(s => s.id === chosen) || sections[0] || {}).id;
}
function settingsMenuOf(data) {
return settingsSectionsFor(data).map(s => ({ id: s.id, icon: s.icon, labelKey: s.labelKey }));
}
function sectionSideRows(tpl, data, side) {
return sectionRowsOf(openSettingsSectionOf(tpl, data), data, side, tpl.animating.get());
}
// The rows of section `id` on one side, in that side's own order - the open
// pane's rows in the popup, and each checklist of the opened card's Card
// Settings. `animatingKey` is the checkbox just clicked.
@@ -2187,9 +2167,6 @@ function sectionCardActionsOf(id) {
.filter(action => (action.board ? mayBoard : (action.id === 'filterSubtasks' ? Boolean(card) : mayCard)))
.map(action => ({
...action,
// An inline form reads its card from its data (Card kind) or the
// opened card (the others); a board one has none.
inlineData: action.board ? {} : card,
title: action.label.map(k => TAPi18n.__(k)).join(action.labelSeparator || ' '),
}));
}
@@ -2199,85 +2176,20 @@ const boardCardSettingsHelpers = {
// is a plain property, which no helper re-runs for).
askForMoveReason() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.askForMoveReason === true; },
settingsMenuItems() {
return leftMenuData(settingsMenuOf(this), openSettingsSectionOf(Template.instance(), this), 'js-card-settings-menu');
},
settingsPaneTitle() {
return paneTitle(settingsMenuOf(this), openSettingsSectionOf(Template.instance(), this));
},
openSettingsSection() {
return openSettingsSectionOf(Template.instance(), this);
},
// The first entry, Card, also holds what is not a field.
openSettingsSectionIsGeneral() {
const id = openSettingsSectionOf(Template.instance(), this);
return Boolean(cardSettingsSections.SECTIONS.find(s => s.id === id)?.general);
},
// The card the popup was opened from ("+ Add"), if any.
settingsCardId() {
const card = settingsCard();
return card && Utils.canModifyCard(card) ? card._id : null;
},
isAnimating(key) { return Template.instance().animating.get() === key; },
showEmptyCardFields() {
return cardShownFields.boardShowsEmptyFields(ReactiveCache.getBoard(Session.get('currentBoard')));
},
// The open section's card actions, opened from a card.
sectionCardActions() {
return sectionCardActionsOf(openSettingsSectionOf(Template.instance(), this));
},
// The open section's rows of one side's list - Show on Card or Show on
// Minicard - in that side's own order (buildCardSettingsRows, the builder
// the two lists always used, so each checkbox, its handler class and its
// default are what they were), each list reordered by drag and drop. The
// card list also carries On this card and Show empty.
// (Two helpers rather than one with an argument: the Jade compiler does not
// take a helper call as `each`'s list.)
sectionCardRows() { return sectionSideRows(Template.instance(), Template.currentData(), 'card'); },
sectionMinicardRows() { return sectionSideRows(Template.instance(), Template.currentData(), 'minicard'); },
allowsMinicardCollapse() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.allowsMinicardCollapse !== false; },
labelsAboveTitleOnMinicard() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.labelsAboveTitleOnMinicard === true; },
allowsChecklistDueDate() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.allowsChecklistDueDate !== false; },
allowsChecklistTitle() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.allowsChecklistTitle !== false; },
// The work item type badge was on every minicard before it had a row: on unless turned off.
allowsIssueTypeOnMinicard() { return ReactiveCache.getBoard(Session.get('currentBoard'))?.allowsIssueTypeOnMinicard !== false; },
// Board Settings / Card Settings shows both columns - "Show on Card" and
// "Show on Minicard" beside each other. The card's own menu and the
// minicard's menu open the SAME popup asking for one of them, and the other
// column is hidden by this class rather than by a second copy of the list:
// twenty-four settings written out twice would drift the first time one is
// added. client/components/sidebar/sidebar.css
// Show empty fields: every field is shown, so Show empty is set aside -
// hidden from the rows' hamburgers, its ticks kept.
settingsSideClass() {
const data = Template.currentData() || {};
const classes = [];
if (data.side === 'card') classes.push('show-card-only');
if (data.side === 'minicard') classes.push('show-minicard-only');
// A user who is not a board admin may still set the one PERSONAL row of
// this table - "Labels text" - and none of the board's own. They get that
// row alone rather than a table of checkboxes the server would refuse.
if (data.personalOnly) classes.push('show-personal-only');
// Opened from a card's "+ Add": the card's own rows are shown too.
// The same test as settingsCardId: the column is drawn only then.
if (data.card && data.card._id && Utils.canModifyCard(data.card)) classes.push('has-card');
// Show empty fields: every field is shown, so the Show empty column is
// set aside - hidden, its ticks kept.
if (cardShownFields.boardShowsEmptyFields(ReactiveCache.getBoard(Session.get('currentBoard')))) classes.push('show-empty-fields');
return classes.join(' ');
},
// The rows of the "Show on Card" list, in the board's card order, and of
// the "Show on Minicard" list, in its minicard order - each row with its
// checkbox state, its handler class, its icons and its translated label,
// and whether each arrow does anything. models/lib/cardSettingsRows.js
// is the table, models/lib/cardFieldOrder.js the order.
cardSettingsRows() {
return buildCardSettingsRows('card', this);
},
minicardSettingsRows() {
return buildCardSettingsRows('minicard', this);
return cardShownFields.boardShowsEmptyFields(ReactiveCache.getBoard(Session.get('currentBoard'))) ? 'show-empty-fields' : '';
},
// Board-level DEFAULT (#4256, Board Settings): whether this board shows
@@ -2709,7 +2621,6 @@ const boardCardSettingsHelpers = {
},
};
boardCardSettingsHelpers.showDragHandles = () => Utils.showDragHandles();
Template.boardCardSettingsPopup.helpers(boardCardSettingsHelpers);
// The order of each side is set with the up and down arrows of a row's side
// cell (moveCardSettingsRow below), or the Up and Down keys on a focused
@@ -2752,16 +2663,6 @@ function placeInListOrder(side, key, keys) {
else board.setCardFieldOrder(order);
}
Template.boardCardSettingsPopup.onRendered(function () {
this.$('.js-card-field-order-sortable').each((i, list) => {
reorderOnDrop(this.$(list), {
items: '> .js-card-field-order-row.is-positioned', handle: '.js-card-field-order-handle',
keyOf: el => el.dataset.key,
drop: (key, keys) => placeInListOrder(list.dataset.side, key, keys),
});
});
});
// The keyboard way to drag: one step up or down in the list as it is shown -
// past the row above or below it - placed the same way as a drop.
function moveCardSettingsRow(evt, direction) {
@@ -2780,8 +2681,8 @@ function moveCardSettingsRow(evt, direction) {
// Select all / Select none of one column of Card Settings: the board's columns
// for a board admin, On this card for who may change the card. `container`
// holds the rows of the side columns (the popup's pane, or one checklist of
// the opened card's Card Settings), each set through its own checkbox's
// holds the rows of the side columns (one checklist of Card Settings), each
// set through its own checkbox's
// handler; `keys` are the rows of On this card and Show empty. A personal
// row (Labels text, List title) is the user's or the card's own, not the
// board's: left as it is.
@@ -2877,11 +2778,6 @@ const cardSettingsEvents = {
const cell = evt.currentTarget.closest('.js-card-field-order-row');
if (cell) tpl.animating.set(`${cell.dataset.key}:${cell.dataset.side}`);
},
'click .js-card-settings-menu'(evt, tpl) {
evt.preventDefault();
tpl.animating.set(null);
tpl.settingsSection.set(evt.currentTarget.dataset.id);
},
// Opened from a card: whether THIS card shows the field
// (models/lib/cardShownFields.js) - ticked, an empty one is shown to be
// filled in; unticked, one with data is hidden from this card.
@@ -2896,21 +2792,6 @@ const cardSettingsEvents = {
const next = cardShownFields.cardFieldsAfter(view, key, !cardShownFields.isShown(key, view, counts, board), counts, board);
await Cards.updateAsync(card._id, { $set: next });
},
// Select all / Select none under a column's heading: that column's rows of
// the open section (the ones drawn in the pane), each set the way its own
// checkbox would set it - the board's columns for a board admin, On this
// card for who may change the card. A personal row (Labels text, List
// title) is the user's or the card's own, not the board's: left as it is.
async 'click .js-card-settings-select'(evt, tpl) {
evt.preventDefault();
const { column, value } = evt.currentTarget.dataset;
const pane = evt.currentTarget.closest('.card-settings-pane');
if (!pane) return;
const keysOf = selector => [...pane.querySelectorAll(selector)].map(el => el.dataset.key).filter(Boolean);
const keys = column === 'this' ? keysOf('.js-card-add-field')
: column === 'always' ? keysOf('.js-card-always-show') : [];
await selectCardSettingsColumn(tpl, pane, column, value === 'all', keys);
},
// Card Settings / Card's "Show empty fields": every card shows all its
// fields, the empty ones too. A board admin's, like the rest.
async 'click .js-show-empty-card-fields'(evt, tpl) {
@@ -3374,7 +3255,6 @@ const cardSettingsEvents = {
Boards.update(tpl.currentBoard._id, { $set: { allowsCardSortingByNumberOnMinicard: newValue } });
},
};
Template.boardCardSettingsPopup.events(cardSettingsEvents);
// ── Card Settings on the opened card ────────────────────────────────────────
// docs/Features/Cards/Card-Settings-On-Card.md. The opened card's "Card
@@ -3415,11 +3295,14 @@ function inlineSectionsOf(tpl, data) {
const side = tpl.showSide.get();
const mayBoard = Boolean(Utils.canModifyBoard && Utils.canModifyBoard());
const showDragHandles = Utils.showDragHandles();
return cardSettingsSections.orderedSections(settingsSectionsFor(data), board && board.cardSettingsSectionOrder)
return cardSettingsSections.orderedSections(cardSettingsSections.SECTIONS, board && board.cardSettingsSectionOrder)
.map(section => {
const rows = sectionRowsOf(section.id, data, side, tpl.animating.get()).map(row => {
const menu = inlineRowMenu(row);
return { ...row, menu, hasMenu: menu.thisCard || menu.always || menu.labelTextReset };
// Blaze keeps a row's element by _id, so switching Show moves each
// row to its place on the other side rather than relabelling the rows
// where they stand - a click in between would land on another field.
return { ...row, _id: row.key, menu, hasMenu: menu.thisCard || menu.always || menu.labelTextReset };
});
return {
// Blaze keeps a section's element (and the drag bound to it) by _id.
@@ -3473,11 +3356,15 @@ Template.cardSettingsInline.onRendered(function () {
});
// Each section's rows reorder within it, as checklist items do, through the
// same placing as the popup's lists (placeInListOrder).
// board's order of that side (placeInListOrder). A row dropped on another
// section goes back: the sections are unrelated kinds of fields. Dragged by
// the handle with drag handles on, by the icon and name with them off
// (cardSettingsRowLabel).
Template.cardSettingsInlineSection.onRendered(function () {
const list = this.find('.js-card-field-order-sortable');
if (!list) return;
reorderOnDrop(this.$(list), {
ownListOnly: true,
items: '> .js-card-field-order-row.is-positioned', handle: '.js-card-field-order-handle',
keyOf: el => el.dataset.key,
drop: (key, keys) => placeInListOrder(list.dataset.side, key, keys),
@@ -3524,7 +3411,7 @@ Template.cardSettingsInline.events({
const id = evt.currentTarget.closest('.js-card-settings-section')?.dataset.section;
evt.preventDefault();
if (!cardSettingsSections.SECTIONS.some(s => s.id === id)) return;
const data = { card: settingsCard(), sectionId: id, side: tpl.showSide.get() };
const data = { card: settingsCard(), sectionId: id, side: tpl.showSide.get(), inPopup: Boolean(tpl.data && tpl.data.inPopup) };
Popup.open('cardSettingsSection', { dataContext: data })(evt);
},
'click .js-open-card-settings-item-menu'(evt, tpl) {
@@ -3541,8 +3428,11 @@ Template.cardSettingsInline.events({
// this card and of Show empty; the section's card actions; and, for Card,
// what is not a field - Show empty fields, asking for a move reason and the
// Draggable table.
function inlineSectionEl(id) {
return document.querySelector(`.js-card-settings-inline .js-card-settings-section[data-section="${id}"]`);
// The section in the Card Settings this hamburger was opened from: the opened
// card's and Board Settings / Card's may both be open at once.
function inlineSectionEl(id, inPopup) {
const which = inPopup ? '.card-settings-in-popup' : ':not(.card-settings-in-popup)';
return document.querySelector(`.js-card-settings-inline${which} .js-card-settings-section[data-section="${id}"]`);
}
Template.cardSettingsSectionPopup.onCreated(setUpCardSettings);
@@ -3573,7 +3463,7 @@ Template.cardSettingsSectionPopup.events({
const rows = sectionRowsOf(data.sectionId, data, 'card', null);
const keys = column === 'this' ? rows.filter(r => r.perCard).map(r => r.key)
: column === 'always' ? rows.filter(r => r.alwaysShowable).map(r => r.key) : [];
await selectCardSettingsColumn(tpl, inlineSectionEl(data.sectionId), column, value === 'all', keys);
await selectCardSettingsColumn(tpl, inlineSectionEl(data.sectionId, data.inPopup), column, value === 'all', keys);
},
});
+8 -8
View File
@@ -25,11 +25,11 @@
// Keep in step with the `@media screen and (max-width: 800px)` block there.
const MOBILE_POPUP_MAX_WIDTH = 800;
// #6796: Board Settings / Card's widest; popup.css says the same
// (`.pop-over[data-popup='boardCardSettingsPopup']`).
// A left menu and a pane of rows (docs/Features/Cards/Card-Fields.md): 1200px
// is room for the menu, a row's four checkbox columns and its whole label.
const CARD_SETTINGS_MAX_WIDTH = 1200;
// Board Settings / Card's widest; popup.css says the same
// (`.pop-over[data-popup='boardCardSettingsPopup']`). It is the opened
// card's Card Settings (docs/Features/Cards/Card-Settings-On-Card.md) - one
// column of checklists - so about as wide as an opened card.
const CARD_SETTINGS_MAX_WIDTH = 600;
function computePopupOffset(params) {
const {
@@ -64,9 +64,9 @@ function computePopupOffset(params) {
return { left: (viewportWidth - width) / 2, top: 12,
maxHeight: Math.max(0, viewportHeight - 24) };
}
// #6796: Board Settings / Card is a panel as wide and as tall as the window
// allows, centred, so its lists can spread into columns and be read without
// scrolling. Same width as popup.css.
// #6796: Board Settings / Card is a panel as tall as the window allows,
// centred, so its checklists are read with little scrolling. Same width
// as popup.css.
if (popupName === 'boardCardSettingsPopup') {
const width = Math.min(CARD_SETTINGS_MAX_WIDTH, viewportWidth * 0.96);
return { left: (viewportWidth - width) / 2, top: 12,
@@ -12,80 +12,66 @@ and comments - and its own **Card Settings** button shows these same
settings on the card itself, as checklists, with **On this card** in each
field's hamburger ([Card Settings on the opened card](../../Cards/Card-Settings-On-Card.md)).
The popup is the Admin Panel's left menu and pane: the rows are grouped into
sections (Card, Date, Time, Members, Labels, ... Voting, Planning Poker,
Scrum settings, Comments), and each section's pane has two lists, each in its
own order and reordered by **drag and drop**: **Show on Card** - the opened
card's fields - and **Show on Minicard**. The first menu entry, **Card**,
also holds Ask for a reason when moving cards and the Draggable table; a
section's own settings - Subtask settings, Edit custom fields, Change spent
time, Card kind, Edit voting, Edit Planning Poker - are forms at the top of
its pane ([Card fields](../../Cards/Card-Fields.md)).
Board Settings / Card is that same Card Settings, drawn by the same
templates, in a popup: **Show** chooses **Show on Minicard** or **Show on
Card**, then the sections (Card, Date, Time, Members, Labels, ... Voting,
Planning Poker, Scrum settings, Comments) are checklists - a section is a
checklist's title, its fields the items. There is no card here, so a row's
hamburger has no **On this card**; everything else is as on the card
([Card Settings on the opened card](../../Cards/Card-Settings-On-Card.md)).
## Where to find it
**Board sidebar (hamburger menu) → Board Settings → Card**; on an opened
card, its **Card Settings** button shows the same settings on the card
([Card Settings on the opened card](../../Cards/Card-Settings-On-Card.md)).
The left menu lists the sections; the right pane shows the open section's
rows:
**Show on Card**, in the opened card's order:
| On this card | Show on Card | Show empty | Card field order |
| --- | --- | --- | --- |
| opened from a card only | checkbox | checkbox: every card shows the field, empty or not - by default the description, attachments and comments ([#6806](https://github.com/wekan/wekan/issues/6806)) | the field, with the icon and name the card uses - drag it |
**Show on Minicard**, in the minicard's order:
| Show on Minicard | |
| --- | --- |
| checkbox | the field - drag it |
card, its **Card Settings** button shows the same settings on the card.
| part | what it does |
| --- | --- |
| Show | which side the checkboxes and the order are: the minicard's or the opened card's |
| checkbox | whether that side shows the field at all - the same board toggles as before (`allowsLabels`, `allowsLabelsOnMinicard`, ...) |
| drag and drop | move the field earlier or later **on that side only**: by its handle when drag handles are on, else by its icon or name; Up and Down on a focused handle or name move it one row |
| a row's hamburger | **Show empty**: every card shows the field, empty or not - by default the description, attachments and comments ([#6806](https://github.com/wekan/wekan/issues/6806)); on a card also **On this card** |
| a section's hamburger | **Select all** / **Select none** for that section's rows; its actions (Subtask settings, Create Field, ...); for **Card** also Show empty fields, Ask for a reason when moving cards and the Draggable table |
| drag and drop | move a field earlier or later **on the chosen side only**, within its section; move a whole section above or below another |
Under each column's heading, **Select all** and **Select none** set that
column for every row of the open section at once.
What is dragged follows the member's drag handles setting: with drag handles
on, only the handle - the rest of the row scrolls the page; with them off,
the icon and name. Up and Down on a focused handle or name move it one row. A
field cannot move to another section: dropped there, it goes back where it
was and nothing is saved.
## Show empty fields
The popup opens on **Card**, the first entry of its left menu. Right under
that pane's title (on the card: in Card's hamburger) is **Show
empty fields**, a board-wide switch, off by default, saved to the board
(`showEmptyCardFields`):
In Card's hamburger is **Show empty fields**, a board-wide switch, off by
default, saved to the board (`showEmptyCardFields`):
- **Off** - an opened card shows the fields it has data in, plus the fields
ticked **Show empty**.
- **On** - every opened card shows every field ticked **Show on Card**, empty
or not. The **Show empty** columns are then hidden in every section, because
they have nothing left to decide; what was ticked in them is kept, and comes
back into effect when the switch is turned off.
or not. **Show empty** then leaves the rows' hamburgers, because it has
nothing left to decide; what was ticked is kept, and comes back into effect
when the switch is turned off.
**Ask for a reason when moving cards** sits under it, as the same animated
checkbox.
The minicard order and the card order are independent: a field can be third
on the minicard and last on the card. On a narrow screen the menu stacks above
the pane, as it does in the Admin Panel.
on the minicard and last on the card.
## Steps to use it
1. Open a board you are an admin of, click the sidebar's hamburger menu,
then **Board Settings → Card** - or click **Card Settings** on an opened
card, which shows the same settings on the card.
2. Pick a section in the left menu.
3. Drag a row of the **Show on Card** list to move the field on the opened
card; a row of the **Show on Minicard** list, to move it on the minicard.
2. Choose **Show on Card** or **Show on Minicard**.
3. Drag a field within its section to move it on that side, or a section to
move the whole section.
4. The change takes effect immediately on every card of this board; there
is no separate save step.
5. Untick a checkbox to hide the field on that side; tick it to show it.
A row that cannot move (see below) is not dragged. A list shows one
section's fields; a dropped row goes right after the row now above it in the
side's whole order (or right before the row below it, at the top).
A row that cannot move (see below) is not dragged. A dropped row goes right
after the row now above it in the side's whole order (or right before the row
below it, at the top).
## What moves, and how
+36 -50
View File
@@ -40,38 +40,28 @@ Minicard / Show on Card choosing what the checkboxes are, and On this card and
Show empty in each row's hamburger. That design is
[Card Settings on the opened card](Card-Settings-On-Card.md).
[Board Settings / Card](../Board-Sidebar/Board-Settings/Card.md) opens the same
settings as a popup. It looks and works like the Admin Panel: the same **left
menu** and the same **pane title** (the shared templates of the
[Left Menu](../All-Boards/Left-Menu.md) design), with the rows on the right.
[Board Settings / Card](../Board-Sidebar/Board-Settings/Card.md) is the same
Card Settings, drawn by the same templates, in a popup: **Show**, then the same
sections as checklists, the same rows and the same hamburgers. There is no
card there, so a row's hamburger has no **On this card**, and the popup's
header is the title and the close button.
- The **left menu** lists the sections, and the popup opens on the first,
**Card**. It holds what is not
a field - **Show empty fields** right under its title, Ask for a reason when
moving cards and the Draggable table, with its select-all - and the card's
own rows. Then Date, Time, Members,
Labels, Description, Checklists, Subtasks, Attachments, Custom Fields,
Voting, Planning Poker, Scrum settings and Comments.
- The **right pane** lists the open section's rows twice: **Show on Card**, in
the opened card's order, and **Show on Minicard**, in the minicard's - each
row with the board-wide checkbox of that side, and reordered by **drag and
drop** (the Up and Down keys on a focused handle or name move it one row; a
row that only modifies another is not dragged).
- In the Show on Card list, a **Show empty** cell, for every per-card field: ticked, every card of the
board shows the field, empty or not
- A row's **checkbox** is the board-wide Show on Card or Show on Minicard,
whichever **Show** chooses.
- **Show empty**, in a row's hamburger, for every per-card field: ticked,
every card of the board shows the field, empty or not
([#6806](https://github.com/wekan/wekan/issues/6806)). By default the
description, attachments and comments are ticked.
- Opened for a card, one more cell, **On this card**, says whether this card
shows the field (the per-card fields only); on the card itself it is in the
row's hamburger.
- Under each column's heading, **Select all** and **Select none** set that
column for every row of the open section at once - the board's columns for a
board admin, On this card for who may change the card. A personal row
(Labels text, List title) is left as it is.
Each heading and the cells under it start at the same place, and the popup is
up to 1200px wide - as wide as the window allows - so that where there is room
every row's whole label is shown; a label longer than its column wraps.
- **On this card**, in a row's hamburger on a card, says whether this card
shows the field (the per-card fields only).
- A section's hamburger has **Select all** and **Select none** of the chosen
side, of On this card and of Show empty, for that section's rows - the
board's for a board admin, On this card for who may change the card. A
personal row (Labels text, List title) is left as it is. Card's hamburger
also holds what is not a field: **Show empty fields**, Ask for a reason when
moving cards and the Draggable table.
- Sections and rows are reordered by **drag and drop**, a row only within its
section (see [Card Settings on the opened card](Card-Settings-On-Card.md)).
A row's name leaves out what its section already says: under **Scrum settings**
the rows are Sprint, Past sprints, Release, Issue type, Acceptance criteria and
@@ -106,14 +96,14 @@ labels, a due date, a checklist - ticks those; a board that does not want an
empty description or comments on every card unticks them, and they are shown
when they have something in them. A card still hides an always-shown field by
unticking **On this card**, and a field turned off in **Show on Card** stays
off. It is the board's setting, for a board admin, and the minicard's own view
of the popup does not show it.
off. It is the board's setting, for a board admin; with Show on Minicard
chosen it is not in the rows' hamburgers.
**Show empty fields**, in the Card pane under its title, is off by default.
**Show empty fields**, in Card's hamburger, is off by default.
Ticked, every card of the board shows all its fields - the empty ones too, as
far as Show on Card allows - and the **Show empty** columns are hidden, because
far as Show on Card allows - and **Show empty** leaves the rows' hamburgers, because
every field is then shown anyway. Their ticks are kept: unticked again, Show
empty fields brings the columns back as they were. It is saved to the board
empty fields brings them back as they were. It is saved to the board
(`showEmptyCardFields`), for a board admin.
**Card Settings** on the card is shown to whoever may change the card. Its board-wide checkboxes
@@ -122,17 +112,16 @@ and only **On this card** and their own rows (Labels text, List title) change.
Below the **Card Settings** button there is one separator line, above the first section the card
shows: a section the card does not show draws no line of its own.
The popup moves by its title bar (or the drag handle, when drag handles are on)
and has maximize / minimize beside its close button.
Board Settings / Card moves by its title bar (or the drag handle, when drag
handles are on) and has maximize / minimize beside its close button.
## Card actions in their sections
Five actions that were in the opened card's actions menu are in the section
they act on: in the **hamburger** at the end of that section's heading on the
opened card, at the top of that section's pane in the popup, and in that
section's hamburger in Card Settings on the card.
opened card, and in that section's hamburger in Card Settings.
| Action | Opened card | Card Settings pane |
| Action | Opened card | Card Settings section |
| --- | --- | --- |
| Filter: Subtasks | Subtasks hamburger (with the subtask settings) | Subtasks |
| Edit voting | Voting hamburger | Voting |
@@ -144,16 +133,13 @@ section's hamburger in Card Settings on the card.
| Create Field | Custom Fields hamburger, then Create Field | Custom Fields - also from Board Settings, for a board admin |
| Link fields to another card | Custom Fields hamburger, then Link fields | Custom Fields |
In the Card Settings pane each of these is the **form itself**, right under the
pane's title - not a link to a popup - except Filter: Subtasks, which is an
action. Saved there, Card Settings stays open (a new custom field's form is
emptied for the next one); the hamburger on the opened card still opens the
same form as its own popup.
Each opens its own popup, as from the opened card. A card's action is there
only with a card; a board's (Subtask settings, Create Field) is also in Board
Settings / Card, for a board admin.
Everything in a section's hamburger on the opened card is also at the top of
that section's pane in Card Settings, under its title and above Show on
Minicard / Show on Card (`tests/cardSectionActions.test.cjs` fails for a
hamburger that is not).
Everything in a section's hamburger on the opened card is also in that
section's hamburger in Card Settings (`tests/cardSectionActions.test.cjs` fails
for a hamburger that is not).
A section is on the opened card when the field is on in Card Settings and the
card has data in it or shows it **On this card**: tick Voting "On this card" to
@@ -211,9 +197,9 @@ section order the same way.
| --- | --- |
| `models/lib/cardShownFields.js` | "Has data", what a card shows, the card's board view |
| `models/lib/cardSettingsSections.js` | The popup's sections, and the row names within them |
| `client/components/sidebar/sidebar.jade` | The popup (`boardCardSettingsPopup`): the left menu, the pane, the rows |
| `client/components/sidebar/sidebar.jade` | Card Settings (`cardSettingsInline`), and Board Settings / Card (`boardCardSettingsPopup`), which is the same template |
| `client/components/sidebar/sidebar.js` | Its sections, the two lists, checkboxes, drag and drop and On this card |
| `client/components/sidebar/sidebar.css` | The pane's layout |
| `client/components/sidebar/sidebar.css`, `client/components/cards/cardAddFields.css` | A row's line; the checklist look |
| `client/components/cards/cardAddFields.js` | Card Settings opened on the card, and the card's own `currentBoard` |
| `client/components/cards/cardDetails.jade` | The Card Settings button under the cover, the section hamburgers |
| `client/lib/filterSubtasks.js` | Filter: Subtasks |
+29 -16
View File
@@ -5,8 +5,11 @@ The opened card has a **Card Settings** button under its cover (it used to be
below it on the card, is **replaced** by Card Settings itself, right on the
card. The **X** at the end of its title puts the card back.
Board Settings / Card still opens the same settings as a popup, with its left
menu and pane ([Card fields](Card-Fields.md)); this page is about the card.
**Board Settings / Card is the same Card Settings**, drawn by the same
templates, in a popup: Show, then the same sections as checklists with the
same rows and hamburgers. Only two things differ, because there is no card:
the popup's own header is the title and the close button, and a row's
hamburger has no **On this card**.
## Top to bottom
@@ -48,29 +51,39 @@ hamburger holds what is not a field.
An item's checkbox says whether that field is **visible on the chosen side**,
for every card of the board: with **Show on Card** it is the board's Show on
Card, with **Show on Minicard** the board's Show on Minicard. It is the same
setting as the popup's columns of those names, saved the same way, so the two
always agree. Like the popup's, it is a board admin's: for anybody else it is
shown disabled, except their own rows (Labels text, List title).
setting Board Settings / Card shows, so the two always agree. It is a board
admin's: for anybody else it is shown disabled, except their own rows (Labels
text, List title).
## Hamburgers
| Hamburger | Holds |
| --- | --- |
| A row's | **On this card** - whether THIS card shows the field ([Card fields](Card-Fields.md)); **Show empty** - whether every card shows it empty ([#6806](https://github.com/wekan/wekan/issues/6806)), while Show empty fields is off; Labels text's way back to the board's default. A row with none of these has no hamburger. |
| A section's | **Select all** / **Select none** of the chosen side, of On this card and of Show empty, for that section's rows; the section's card actions (Filter: Subtasks, Edit voting, Card kind, Edit custom fields, ...) as in the popup's pane; and for **Card**: Show empty fields, Ask for a reason when moving cards, and the Draggable table. |
| A section's | **Select all** / **Select none** of the chosen side, of On this card and of Show empty, for that section's rows; the section's card actions (Filter: Subtasks, Edit voting, Card kind, Edit custom fields, ... - a card's own only on a card, a board's also in Board Settings); and for **Card**: Show empty fields, Ask for a reason when moving cards, and the Draggable table. |
On this card is for whoever may change the card; Show empty and the selects of
the board's columns are a board admin's.
## Drag and drop, collapse
- A **section** is dragged by its title (or its handle, with drag handles on)
above or below another, as a checklist is. The order is the board's
(`cardSettingsSectionOrder`), so every member sees the sections in it; a
section added to WeKan later appears in its default place.
What a section or a row is dragged by follows the member's **drag handles**
setting, as checklists do:
| Drag handles | Dragged by | The rest of the line |
| --- | --- | --- |
| On | the handle only | scrolls the page, as a touch there means to |
| Off | the icon and name (a section: its title) | - |
- A **section** is dragged above or below another, as a checklist is. The
order is the board's (`cardSettingsSectionOrder`), so every member sees the
sections in it; a section added to WeKan later appears in its default place.
- A **row** is dragged within its section, as a checklist item is. That moves
the field on the chosen side: the card's order with Show on Card, the
minicard's with Show on Minicard - the same order the popup's lists set.
minicard's with Show on Minicard.
- A row **cannot move to another section**: the sections are unrelated kinds
of fields. Dropped on another section, it goes back where it was, and
nothing is saved.
- The **Up** and **Down** keys on a focused section title or row name move it
one step.
- Only a board admin drags.
@@ -84,14 +97,14 @@ the board's columns are a board admin's.
| --- | --- |
| `client/components/cards/cardDetails.jade` | The Card Settings button; `if cardSettingsOpen` draws `+cardSettingsInline` in place of the button and everything below it |
| `client/components/cards/cardAddFields.js` | Which card it is open on (`isCardSettingsOpen`, `closeCardSettings`), closed when the card closes |
| `client/components/sidebar/sidebar.jade` | `cardSettingsInline`, `cardSettingsInlineSection`, `cardSettingsSectionPopup`, `cardSettingsItemPopup` |
| `client/components/sidebar/sidebar.js` | The rows (`sectionRowsOf`, the popup's), the handlers (`cardSettingsEvents`, the popup's), Select all (`selectCardSettingsColumn`), the drags |
| `client/components/sidebar/sidebar.jade` | `cardSettingsInline`, `cardSettingsInlineSection`, `cardSettingsSectionPopup`, `cardSettingsItemPopup`; `boardCardSettingsPopup` is `+cardSettingsInline(inPopup=true)` |
| `client/components/sidebar/sidebar.js` | The rows (`sectionRowsOf`), the handlers (`cardSettingsEvents`), Select all (`selectCardSettingsColumn`), the drags (`reorderOnDrop`, `ownListOnly` for a row) |
| `client/components/cards/cardAddFields.css` | The checklist look |
| `models/lib/cardSettingsSections.js` | The sections, and their order: `sectionOrder`, `orderedSections`, `sectionOrderAfterDrop` |
| `models/boards.js` | `cardSettingsSectionOrder`, `setCardSettingsSectionOrder` |
| `tests/cardSettingsOnCard.test.cjs` | The order rules, the layout, the shared handlers, the translations |
| `tests/playwright/specs/card-add-fields.e2e.js` | The button, the replaced card, the checkbox, the hamburgers, the drag and collapse |
The rows, their checkboxes and their handlers are the popup's, not a copy: a
checkbox does the same thing wherever it is drawn, and a row added to the
popup is on the card too.
The card and Board Settings / Card draw the same templates with the same
handlers, not a copy: a checkbox does the same thing wherever it is drawn, and
a row added to Card Settings is in both.
+42 -27
View File
@@ -225,51 +225,66 @@ test('"Show on Card" / "Show on Minicard" are gone from the card and minicard me
test('the shared settings table itself still exists - Board Settings/Card includes it', () => {
assert.ok(sidebarJade.includes('template(name="boardCardSettingsPopup")'),
'boardCardSettingsPopup was not deleted, only its two menu entry points were');
assert.ok(/settingsSideClass\(\) \{/.test(sidebarJs), 'the side-hiding helper is unchanged');
// Deliberate change: the helper no longer hides a side (there is no
// second list to hide - "Show" picks the side); it only marks Show empty
// fields. Negative: no show-*-only class is ever produced.
assert.ok(/settingsSideClass\(\) \{/.test(sidebarJs), 'the helper still exists');
assert.ok(!/show-card-only|show-minicard-only/.test(sidebarJs), 'negative: no side-hiding class');
});
test('Board Settings/Card shows both columns - no side is passed', () => {
test('Board Settings/Card shows both sides - no side is passed', () => {
const handler = sidebarJs.slice(sidebarJs.indexOf("'click .js-open-board-card-settings'"),
sidebarJs.indexOf('});', sidebarJs.indexOf("'click .js-open-board-card-settings'")));
assert.ok(!/side\s*[:=]\s*['"]card['"]/.test(handler) && !/side\s*[:=]\s*['"]minicard['"]/.test(handler),
'no side is set, so settingsSideClass() returns no show-*-only class');
'no side is set');
// Both are reached through the Show radios of cardSettingsInline.
const inline = sidebarJade.slice(sidebarJade.indexOf('template(name="cardSettingsInline")')).split('\ntemplate(')[0];
assert.ok(inline.includes('value="minicard"') && inline.includes('value="card"'), 'Show on Minicard and Show on Card');
});
test('a non-admin still reaches the one PERSONAL row ("Labels text") via personalOnly', () => {
test('a non-admin still reaches the PERSONAL rows ("Labels text", "List title")', () => {
// Deliberate change: there is no personalOnly mode any more. Board
// Settings / Card is the opened card's Card Settings (cardSettingsInline),
// and everybody opens the same view with no data: a non-admin sees the
// board's rows disabled and may still set their own (row.writable).
const handler = sidebarJs.slice(sidebarJs.indexOf("'click .js-open-board-card-settings'"),
sidebarJs.indexOf('});', sidebarJs.indexOf("'click .js-open-board-card-settings'")));
assert.ok(/isBoardAdmin/.test(handler), 'the handler checks board-admin status');
assert.ok(/personalOnly:\s*true/.test(handler), 'and asks for personalOnly when the user is not an admin');
assert.ok(/Popup\.open\('boardCardSettings'\)\.call\(\{ currentData: \(\) => \(\{\}\) \}, event\)/.test(handler),
'the same data for everybody');
assert.ok(!/personalOnly/.test(sidebarJs), 'negative: no personal-only mode left');
assert.ok(/writable: writable \|\| Boolean\(built\.personal\),/.test(sidebarJs), 'a personal row is writable for its owner');
// The menu entry is outside the isBoardAdmin gate, so a non-admin reaches it.
const menu = sidebarJade.slice(sidebarJade.indexOf('template(name="boardMenuPopup")')).split('\ntemplate(')[0];
const entry = menu.split('\n').find(l => l.includes('a.js-open-board-card-settings'));
assert.ok(entry && /^ {8}a\./.test(entry), 'the Card entry is not nested in if currentUser.isBoardAdmin');
});
// ── the layout: sections on the left, Show on Minicard / Show on Card on the right
// ── the layout: the opened card's Card Settings ─────────────────────────────
test('Board Settings / Card is the Admin Panel left menu and pane, with Show on Minicard and Show on Card', () => {
// docs/Features/Cards/Card-Fields.md (2026-10-25): the same popup the opened
// card's "+ Add" opens. It was one "Card field order" heading over two long
// lists; the rows are grouped into sections now, each line with its minicard
// and its card checkbox side by side.
test('Board Settings / Card is the opened card\'s Card Settings, with Show on Minicard and Show on Card', () => {
// Deliberate change: it was the Admin Panel's left menu and a pane of two
// lists (docs/Features/Cards/Card-Fields.md, 2026-10-25/27). It is now the
// SAME template as the opened card's Card Settings,
// `+cardSettingsInline(inPopup=true)`: "Show" radios choose Show on
// Minicard or Show on Card, and each section is a checklist of that side's
// rows, each side still in its own order (tests/cardSettingsCoverage.test.cjs).
const tpl = sidebarJade.slice(sidebarJade.indexOf('template(name="boardCardSettingsPopup")'));
const form = tpl.slice(0, tpl.indexOf('\ntemplate(name='));
assert.ok(form.includes('+leftMenu(settingsMenuItems)') && form.includes('+paneTitle(settingsPaneTitle)'));
// The heading text is its own span, with Select all / Select none under it
// (tests/cardSettingsSelectAllAligned.test.cjs). Since 2026-10-27 each
// section has two lists again, the card's first (each in its own order,
// reordered by drag and drop - tests/cardSettingsCoverage.test.cjs).
const minicard = form.indexOf("span.card-settings-side-head.card-settings-side-head-minicard\n span {{_ 'show-on-minicard'}}");
const card = form.indexOf("span.card-settings-side-head.card-settings-side-head-card\n span {{_ 'show-on-card'}}");
assert.ok(minicard !== -1 && card !== -1 && card < minicard, 'Show on Card\'s list, then Show on Minicard\'s');
assert.ok(form.includes('+cardSettingsInline(inPopup=true)'));
assert.ok(!form.includes('+leftMenu(settingsMenuItems)') && !form.includes('+paneTitle(settingsPaneTitle)'),
'negative: the menu and pane are gone');
const inline = sidebarJade.slice(sidebarJade.indexOf('template(name="cardSettingsInline")')).split('\ntemplate(')[0];
const minicard = inline.indexOf("span {{_ 'show-on-minicard'}}");
const card = inline.indexOf("span {{_ 'show-on-card'}}");
assert.ok(minicard !== -1 && card !== -1 && minicard < card, 'Show on Minicard, then Show on Card');
assert.ok(en['show-on-card'] && en['show-on-minicard'], 'existing, translated keys');
assert.ok(!form.includes('card-field-order-heading'), 'the old single heading is gone (negative)');
});
test('a menu that asks for one side hides the other side\'s list', () => {
// The card's list carries On this card and Show empty too, so the
// minicard's own view hides them with it.
assert.ok(sidebarCss.includes(
'.board-card-settings.show-card-only .card-settings-list-minicard,\n'
+ '.board-card-settings.show-minicard-only .card-settings-list-card {'),
'show-card-only hides the minicard list and show-minicard-only the card list');
test('negative: no CSS hides one side\'s list any more', () => {
// Deliberate change: there are no two lists to hide one of - the old
// show-card-only / show-minicard-only rules went with the pane.
assert.ok(!/show-card-only|show-minicard-only|card-settings-list-(card|minicard)/.test(sidebarCss));
});
console.log(`\nboardSettingsSwimlaneListCard: ${passed} tests passed`);
+55 -30
View File
@@ -6,9 +6,9 @@
// comments; a board admin can untick those (then they are shown when they
// have something in them) and tick any other per-card field - a board where
// nearly every card gets labels, a due date and a checklist. "On this card"
// still hides one from one card. And the Card pane's "Show empty fields"
// (showEmptyCardFields, off by default): every field on every card, the Show
// empty columns hidden with their ticks kept. The popup opens on Card.
// still hides one from one card. And the Card section's "Show empty fields"
// (showEmptyCardFields, off by default): every field on every card, Show
// empty set aside with its ticks kept. Card Settings starts with Card.
// docs/Features/Cards/Card-Fields.md
// Run: node tests/cardFieldsAlwaysShown6806.test.cjs
@@ -117,16 +117,28 @@ test('stored on the board, through one normalising method, as a board admin', ()
}
});
test('Board Settings / Card and + Add: a Show empty column, for every per-card field', () => {
test('Board Settings / Card and + Add: a Show empty choice, for every per-card field', () => {
const jade = read('client/components/sidebar/sidebar.jade');
const popup = jade.slice(jade.indexOf('template(name="boardCardSettingsPopup")')).split('\ntemplate(')[0];
// Its text is a span of its own, with Select all / Select none under it.
assert.match(popup, /span\.card-settings-side-head\.card-settings-side-head-always\n\s+span \{\{_ 'card-settings-show-empty'\}\}/);
assert.match(popup, /if row\.alwaysShowable\n\s+span\.card-settings-side\.card-settings-side-always\n\s+a\.flex\.card-always-show\(class="\{\{#if canModifyBoard\}\}js-card-always-show\{\{else\}\}is-disabled\{\{\/if\}\}/);
assert.match(popup, /role="checkbox" aria-checked="\{\{#if row\.alwaysShown\}\}true\{\{else\}\}false\{\{\/if\}\}"/);
const head = popup.slice(popup.indexOf('.card-settings-pane-head'), popup.indexOf('each row in sectionSettingsRows'));
assert.ok(!/if settingsCardId\n\s+span\.card-settings-side-head\.card-settings-side-head-always/.test(head), 'the board\'s, in Board Settings too');
const tpl = name => jade.slice(jade.indexOf(`template(name="${name}")`)).split('\ntemplate(')[0];
// Deliberate change: Board Settings / Card is now the SAME template as the
// opened card's Card Settings (boardCardSettingsPopup is only
// `+cardSettingsInline(inPopup=true)`), so the pane's "Show empty" COLUMN
// is gone. Show empty is now in each row's hamburger (cardSettingsItemPopup)
// and its Select all / Select none in the section's (cardSettingsSectionPopup),
// the same in Board Settings and on the opened card.
assert.match(tpl('boardCardSettingsPopup'), /\+cardSettingsInline\(inPopup=true\)/);
const item = tpl('cardSettingsItemPopup');
assert.match(item, /if menu\.always\n\s+div\.check-div\.card-settings-board-option\n\s+a\.flex\.card-always-show\.js-card-always-show\(data-key="\{\{key\}\}"/);
assert.match(item, /role="checkbox" aria-checked="\{\{#if alwaysShown\}\}true\{\{else\}\}false\{\{\/if\}\}"/);
assert.match(item, /span \{\{_ 'card-settings-show-empty'\}\}/);
const section = tpl('cardSettingsSectionPopup');
assert.match(section, /if maySelectAlways\n\s+li\.card-settings-section-menu-select\n\s+span \{\{_ 'card-settings-show-empty'\}\}/);
assert.match(section, /button\.js-card-settings-inline-select\(type="button" data-column="always" data-value="all"\)/);
const js = read('client/components/sidebar/sidebar.js');
// The board's, in Board Settings too: Show empty is offered for who may
// change the board, with or without a card (negative: not tied to a card).
assert.match(js, /always: Boolean\(row\.alwaysShowable && mayBoard && !cardShownFields\.boardShowsEmptyFields\(board\)\),/);
assert.ok(!/always: Boolean\([^)]*settingsCard/.test(js), 'Show empty is the board\'s, not only from a card');
// In the Show on Card list, for every per-card field - the default four too.
assert.match(js, /alwaysShowable: side === 'card' && cardShownFields\.FIELD_KEYS\.includes\(built\.key\),/, 'the default four too');
assert.match(js, /alwaysShown: cardShownFields\.boardAlwaysShows\(board, built\.key\),/);
@@ -140,8 +152,9 @@ test('Board Settings / Card and + Add: a Show empty column, for every per-card f
assert.match(add, /attachments: typeof card\.attachments === 'function' \? countOf\(card\.attachments\(\)\) : 0,/);
assert.match(add, /comments: typeof card\.comments === 'function' \? countOf\(card\.comments\(\)\) : 0,/);
assert.match(read('models/lib/cardShownFields.js'), /FIELD_KEYS\.filter\(k => !isShown\(k, card, counts, board\)\)/);
// The minicard's own view hides the card's list, Show empty with it.
assert.match(read('client/components/sidebar/sidebar.css'), /\.board-card-settings\.show-minicard-only \.card-settings-list-card \{/);
// The minicard side has no Show empty: alwaysShowable is the card side's
// only (asserted above), and Select all of Show empty only with side card.
assert.match(js, /maySelectAlways\(\) \{[\s\S]{0,200}return this\.side === 'card' && /);
});
test('negative: every caller of isShown and cardFieldsAfter passes the board', () => {
@@ -191,38 +204,50 @@ test('Show empty fields: every field on every card, off by default', () => {
assert.ok(!f.boardShowsEmptyFields({ showEmptyCardFields: 'yes' }), 'negative: only true');
});
test('Show empty fields: saved to the board, under the Card pane\'s title; on, the Show empty columns hidden', () => {
test('Show empty fields: saved to the board, in the Card section\'s hamburger; on, Show empty is set aside', () => {
const boards = read('models/boards.js');
assert.match(boards, /showEmptyCardFields: \{ type: Boolean, defaultValue: false \},/);
assert.match(boards, /async setShowEmptyCardFields\(showEmptyCardFields\) \{\n\s+return Boards\.updateAsync\(this\._id, \{ \$set: \{ showEmptyCardFields: showEmptyCardFields === true \} \}\);/);
const jade = read('client/components/sidebar/sidebar.jade');
const popup = jade.slice(jade.indexOf('template(name="boardCardSettingsPopup")')).split('\ntemplate(')[0];
// In the Card pane, right under the title, before Ask for a reason.
const general = popup.slice(popup.indexOf('if openSettingsSectionIsGeneral'));
assert.ok(popup.indexOf('+paneTitle(settingsPaneTitle)') < popup.indexOf('if openSettingsSectionIsGeneral'));
// Deliberate change: Board Settings / Card is now the SAME template as the
// opened card's Card Settings, so what sat under the Card pane's title is
// in the Card section's hamburger (cardSettingsSectionPopup, `sectionIsGeneral`),
// first, before Ask for a reason.
const menu = jade.slice(jade.indexOf('template(name="cardSettingsSectionPopup")')).split('\ntemplate(')[0];
const general = menu.slice(menu.indexOf('if sectionIsGeneral'));
assert.ok(menu.indexOf('if sectionIsGeneral') !== -1);
const showEmptyAt = general.indexOf('div.check-div.card-settings-board-option.card-settings-show-empty-fields');
assert.ok(showEmptyAt !== -1 && showEmptyAt < general.indexOf('js-ask-move-reason'));
assert.match(general, /a\.flex\.card-show-empty-fields\(class="\{\{#if canModifyBoard\}\}js-show-empty-card-fields\{\{else\}\}is-disabled\{\{\/if\}\}[^"]*" role="checkbox" aria-checked="\{\{#if showEmptyCardFields\}\}true\{\{else\}\}false\{\{\/if\}\}"\)/);
assert.match(general, /span \{\{_ 'card-settings-show-empty-fields'\}\}/);
const popup = jade.slice(jade.indexOf('template(name="boardCardSettingsPopup")')).split('\ntemplate(')[0];
assert.ok(!popup.includes('openSettingsSectionIsGeneral'), 'negative: no second copy in the popup');
const js = read('client/components/sidebar/sidebar.js');
assert.match(js, /'click \.js-show-empty-card-fields'\(evt, tpl\) \{[\s\S]{0,300}if \(!board \|\| !\(Utils\.canModifyBoard && Utils\.canModifyBoard\(\)\)\) return;[\s\S]{0,200}board\.setShowEmptyCardFields\(!cardShownFields\.boardShowsEmptyFields\(board\)\)/);
assert.match(js, /if \(cardShownFields\.boardShowsEmptyFields\(ReactiveCache\.getBoard\(Session\.get\('currentBoard'\)\)\)\) classes\.push\('show-empty-fields'\);/);
const css = read('client/components/sidebar/sidebar.css');
assert.match(css, /\.board-card-settings\.show-empty-fields \.card-settings-side-always,\n\.board-card-settings\.show-empty-fields \.card-settings-side-head-always \{\n\s+display: none;/);
// Its grid track goes too, or the field names would sit one column late.
// (In the Show on Card list, which has the column.)
assert.match(css, /\.board-card-settings\.show-empty-fields \.card-settings-list-card > \.card-settings-line \{\n\s+grid-template-columns: 9em minmax\(10em, 1fr\);/);
assert.match(css, /\.board-card-settings\.show-empty-fields\.has-card \.card-settings-list-card > \.card-settings-line \{\n\s+grid-template-columns: 7em 9em minmax\(10em, 1fr\);/);
// Negative: hidden by CSS only - turning it on does not clear the ticks.
// On, Show empty is set aside: the row hamburgers do not offer it and the
// section hamburger has no Select all / none for it - its ticks are kept.
// (It used to be a CSS-hidden column of the pane.)
assert.match(js, /settingsSideClass\(\) \{\n\s+return cardShownFields\.boardShowsEmptyFields\(ReactiveCache\.getBoard\(Session\.get\('currentBoard'\)\)\) \? 'show-empty-fields' : '';/);
assert.match(js, /always: Boolean\(row\.alwaysShowable && mayBoard && !cardShownFields\.boardShowsEmptyFields\(board\)\),/);
assert.match(js, /maySelectAlways\(\) \{[\s\S]{0,300}&& !cardShownFields\.boardShowsEmptyFields\(board\);/);
// Negative: set aside, not cleared - turning it on does not clear the ticks.
assert.ok(!/setShowEmptyCardFields[\s\S]{0,300}cardFieldsAlwaysShown/.test(boards));
});
test('Card Settings opens on Card, the topmost entry - also from + Add', () => {
test('Card Settings starts with Card, the topmost section - also from + Add', () => {
// Deliberate change: there is no left menu to "open on" a section any more
// (Board Settings / Card is the opened card's cardSettingsInline). Every
// section is a checklist, in the board's section order whose default
// starts with Card; Card is kept even with no row on the shown side,
// because its hamburger holds what is not a field.
const js = read('client/components/sidebar/sidebar.js');
assert.match(js, /return \(sections\.find\(s => s\.id === chosen\) \|\| sections\[0\] \|\| \{\}\)\.id;/);
assert.match(js, /cardSettingsSections\.orderedSections\(cardSettingsSections\.SECTIONS, board && board\.cardSettingsSectionOrder\)/);
assert.match(js, /\.filter\(section => section\.rows\.length \|\| cardSettingsSections\.SECTIONS\.find\(s => s\.id === section\.id\)\?\.general\)/);
assert.ok(!/sections\.length > 1 \? sections\[1\]/.test(js), 'negative: no longer the first section of fields');
const { SECTIONS } = require('../models/lib/cardSettingsSections.js');
assert.strictEqual(SECTIONS[0].id, 'card');
assert.ok(!/function openSettingsSectionOf/.test(js), 'negative: the left menu\'s open section is gone');
const sections = require('../models/lib/cardSettingsSections.js');
assert.strictEqual(sections.SECTIONS[0].id, 'card');
assert.strictEqual(sections.sectionOrder(undefined)[0], 'card');
});
test('the headings in every language, and the earlier names gone', () => {
+27 -16
View File
@@ -2,8 +2,8 @@
// docs/Features/Cards/Card-Fields.md: five actions of the opened card's actions
// menu are in the section they act on - in the hamburger of that section on the
// opened card, and in that section's pane of Card Settings when it is opened
// from a card:
// opened card, and in that section's hamburger of Card Settings (the same
// cardSettingsInline template on the opened card and in Board Settings / Card):
// Filter: Subtasks - Subtasks hamburger; Card Settings / Subtasks
// Edit voting - Voting hamburger; Card Settings / Voting
// Edit Planning Poker - Planning Poker hamburger; Card Settings / Planning Poker
@@ -62,39 +62,50 @@ test('the Scrum fields are one section under one heading, its fields without the
assert.match(jade, /if isSectionOpen "scrum"\n\s+if \$eq section "scrumSprint"\n\s+\+cardFieldSectionScrumSprint/);
});
test('Card Settings, opened from a card: the actions in the pane of their section', () => {
test('Card Settings: the actions in the hamburger of their section', () => {
assert.deepStrictEqual(Object.keys(CARD_ACTIONS).sort(), ['customFields', 'poker', 'scrum', 'subtasks', 'time', 'vote']);
for (const id of Object.keys(CARD_ACTIONS)) assert.ok(SECTIONS.some(s => s.id === id), id);
assert.deepStrictEqual(cardActionsOf('vote').map(a => a.popup), ['cardStartVoting']);
assert.deepStrictEqual(cardActionsOf('poker').map(a => a.popup), ['cardStartPlanningPoker']);
assert.deepStrictEqual(cardActionsOf('date'), [], 'negative: a section without one has none');
for (const action of Object.values(CARD_ACTIONS).flat()) for (const k of action.label) assert.ok(en[k], k);
const popup = sidebarJade.slice(sidebarJade.indexOf('template(name="boardCardSettingsPopup")')).split('\ntemplate(')[0];
assert.match(popup, /if sectionCardActions\.length\n\s+ul\.pop-over-list\.card-settings-card-actions/);
// Under the title, above the column heads.
assert.ok(popup.indexOf('card-settings-card-actions') < popup.indexOf('.card-settings-pane-head'));
// Deliberate change: Board Settings / Card is now the SAME template as the
// opened card's Card Settings (boardCardSettingsPopup is only
// `+cardSettingsInline(inPopup=true)`), so there is no pane listing the
// section's actions under its title. They are in the section's hamburger,
// cardSettingsSectionPopup, from both places.
const tplOf = name => sidebarJade.slice(sidebarJade.indexOf(`template(name="${name}")`)).split('\ntemplate(')[0];
const popup = tplOf('boardCardSettingsPopup');
assert.ok(!popup.includes('sectionCardActions') && !popup.includes('card-settings-card-actions'),
'negative: no second list of the actions in the popup');
const sectionMenu = tplOf('cardSettingsSectionPopup');
assert.match(sectionMenu, /each action in sectionActions\n\s+li\n\s+a\.js-card-settings-action\(data-action="\{\{action\.id\}\}"\)/);
assert.match(sidebarJs, /sectionActions\(\) \{ return sectionCardActionsOf\(this\.sectionId\); \},/);
// The section menu runs the shared handlers (the js-card-settings-action click).
assert.match(sidebarJs, /Template\.cardSettingsSectionPopup\.events\(\{\n\s+\.\.\.cardSettingsEvents,/);
assert.match(sidebarJs, /if \(id === 'filterSubtasks'\) \{\n\s+filterSubtasksOf\(card\);/);
assert.match(sidebarJs, /if \(action\.board\) \{\n\s+Popup\.open\(action\.popup\)\(evt\);/, 'a board action works from Board Settings too');
// In the pane each is the form itself, not a link to a popup - all but
// Filter: Subtasks, which is an action, not a form.
// The table still names, for each action but Filter: Subtasks, the form
// it is (an action, not a form) - the popup it opens.
for (const action of Object.values(CARD_ACTIONS).flat()) {
if (action.id === 'filterSubtasks') assert.ok(!action.inline);
else assert.ok(action.inline, `${action.id} is inline`);
}
assert.deepStrictEqual(cardActionsOf('time').map(a => a.inline), ['editCardSpentTimePopup']);
// Edit custom fields, then Create Field and Link fields to another card -
// the two its buttons opened - as forms of their own under it.
// the two its buttons opened.
assert.deepStrictEqual(cardActionsOf('customFields').map(a => a.inline), ['cardCustomFieldsPopup', 'createCustomFieldPopup', 'cardCustomFieldLinksPopup']);
assert.strictEqual(cardActionsOf('customFields').find(a => a.id === 'createCustomField').board, true, 'creating a field is the board\'s, also from Board Settings');
// In the pane the list's own two buttons are not repeated.
assert.match(read('client/components/cards/cardCustomFields.jade'), /\.card-custom-fields-more\n[\s\S]{0,400}a\.quiet-button\.full\.js-open-create-custom-field[\s\S]{0,200}a\.quiet-button\.full\.js-open-custom-field-links/);
assert.match(read('client/components/sidebar/sidebar.css'), /\.card-settings-inline-action \.card-custom-fields-more \{ display: none; \}/);
// Saved in the pane the create form stays, emptied; in its own popup it steps back.
assert.match(read('client/components/sidebar/sidebarCustomFields.js'), /if \(!backIfOwnPopup\(evt, \['createCustomFieldPopup', 'editCustomFieldPopup'\]\) && !editing\) \{/);
// Deliberate change (same as above): the pane drew these as forms in place
// (`+Template.dynamic(template=action.inline ...)` in .card-settings-inline-action);
// with the pane gone each is a link in the section's hamburger that opens
// its popup. Negative: no in-place form is left half-wired.
assert.ok(!/Template\.dynamic\(template=action\.inline/.test(sidebarJade), 'negative: no in-place action form');
assert.ok(!read('client/components/sidebar/sidebar.css').includes('.card-settings-inline-action'), 'negative: no CSS for the gone forms');
assert.deepStrictEqual(cardActionsOf('scrum').map(a => a.inline), ['cardKindPopup']);
assert.deepStrictEqual(cardActionsOf('subtasks').map(a => a.inline), [undefined, 'boardSubtaskSettingsBody']);
assert.match(popup, /if action\.inline\n\s+.card-settings-inline-action\n[\s\S]{0,400}\+Template\.dynamic\(template=action\.inline data=action\.inlineData\)/);
// Saved in the pane, Card Settings stays open; saved in their own popup, it steps back.
// Saved in their own popup (opened from a section hamburger), it steps back.
assert.match(read('client/lib/popupBack.js'), /if \(popup && names\.includes\(popup\.dataset\.popup\)\) \{\n\s+Popup\.back\(\);\n\s+return true;/);
assert.match(read('client/lib/popupBack.js'), /export function backIfOwnPopup\(evt, popupName\)/);
assert.match(js, /backIfOwnPopup\(evt, 'cardStartVotingPopup'\);/);
+71 -38
View File
@@ -12,9 +12,9 @@
// (default true, so nothing an existing board shows disappears), a setter, a
// row, a click handler, and a gate in the card or minicard template.
//
// The popup is ONE "Card field order" heading over TWO lists, "Show on
// Minicard" and "Show on Card", each row `[checkbox] [up] [down] icon label`,
// each list in its own board order. The rows are not hand-written: the
// The popup is the opened card's Card Settings (cardSettingsInline): "Show"
// picks Show on Minicard or Show on Card, and each section lists that side's
// rows `[checkbox] icon label`, in that side's own board order. The rows are not hand-written: the
// template draws them from models/lib/cardSettingsRows.js in the order
// models/lib/cardFieldOrder.js resolves - the SAME source cardDetails.jade
// and minicard.jade render from. So the order check here is no longer "the
@@ -58,6 +58,13 @@ const popupStart = sidebarJade.indexOf('template(name="boardCardSettingsPopup")'
assert.ok(popupStart !== -1, 'boardCardSettingsPopup found');
const popupEnd = sidebarJade.indexOf('\ntemplate(name=', popupStart + 1);
const popup = sidebarJade.slice(popupStart, popupEnd);
// Board Settings / Card is now the SAME template as the opened card's Card
// Settings (a deliberate change): the popup is `+cardSettingsInline(inPopup=true)`,
// and its rows are drawn by cardSettingsInline / cardSettingsInlineSection.
// What this suite asked of the popup's own template it asks of those.
const inlineTpl = template(sidebarJade, 'cardSettingsInline');
const inlineSection = template(sidebarJade, 'cardSettingsInlineSection');
const settingsUi = [popup, inlineTpl, inlineSection].join('\n');
const cardRows = CARD_SETTINGS_ROWS.filter(r => r.card);
const minicardRows = CARD_SETTINGS_ROWS.filter(r => r.minicard);
@@ -215,30 +222,38 @@ test('every row has a checkbox helper, a click handler, existing label keys and
}
});
// ── the popup: sections on the left, two lists on the right ──────────────────
// docs/Features/Cards/Card-Fields.md: Board Settings / Card is the Admin
// Panel's left menu and right pane, the same popup the opened card's "+ Add"
// opens. On 2026-10-25 each row became one line with both sides' cells and
// order ARROWS - but the lines could follow only one side's order, so an
// arrow changed a number and nothing visibly moved. On 2026-10-27 (the
// maintainer's choice) each section has the two lists again - Show on Card,
// Show on Minicard - each in its own order and reordered by drag and drop,
// as they were from 2026-10-02.
// ── the popup: the opened card's Card Settings ───────────────────────────────
// docs/Features/Cards/Card-Settings-On-Card.md. Deliberate change: Board
// Settings / Card used to be the Admin Panel's left menu and a right pane of
// two lists (Show on Card, Show on Minicard) with column heads. It is now the
// opened card's own template, cardSettingsInline: "Show" radios pick the side
// (Show on Minicard / Show on Card), and every section is a checklist of that
// side's rows, each list in that side's own order and reordered by drag and
// drop - so both sides' orders are still independent, one shown at a time.
test('the sections are the Admin Panel left menu; each pane has a Show on Card and a Show on Minicard list', () => {
assert.match(popup, /\.setting-content\n\s+\.content-body\n\s+\+leftMenu\(settingsMenuItems\)\n\s+\.main-body\n\s+\+paneTitle\(settingsPaneTitle\)/);
assert.match(popup, /\.card-settings-list\.card-settings-list-card\.js-card-field-order-sortable\(data-side="card"\)/);
assert.match(popup, /\.card-settings-list\.card-settings-list-minicard\.js-card-field-order-sortable\(data-side="minicard"\)/);
assert.ok(popup.indexOf('card-settings-list-card') < popup.indexOf('card-settings-list-minicard'), 'the card\'s list first');
assert.match(popup, /each row in sectionCardRows/);
assert.match(popup, /each row in sectionMinicardRows/);
test('Board Settings / Card is cardSettingsInline: Show picks the side, each section a checklist of its rows', () => {
assert.match(popup, /\+cardSettingsInline\(inPopup=true\)/);
// Negative: the old menu-and-pane layout is gone.
for (const gone of ['+leftMenu(settingsMenuItems)', '+paneTitle(settingsPaneTitle)', 'card-settings-list-card',
'card-settings-list-minicard', 'sectionCardRows', 'sectionMinicardRows', 'card-settings-pane-head']) {
assert.ok(!sidebarJade.includes(gone), `negative: ${gone} is gone`);
}
// Both sides are still reachable: the two Show radios, Show on Card first chosen.
assert.match(inlineTpl, /input\.js-card-settings-show-side\(type="radio" name="card-settings-show-side-\{\{#if inPopup\}\}popup\{\{else\}\}card\{\{\/if\}\}" value="minicard"/);
assert.match(inlineTpl, /input\.js-card-settings-show-side\(type="radio" name="card-settings-show-side-\{\{#if inPopup\}\}popup\{\{else\}\}card\{\{\/if\}\}" value="card"/);
assert.match(sidebarJs, /this\.showSide = new ReactiveVar\('card'\);/);
assert.match(inlineTpl, /each section in inlineSections\n\s+\+cardSettingsInlineSection\(section=section card=card side=showSide\)/);
// One sortable list per section, of the side shown.
assert.match(inlineSection, /\.card-settings-inline-items\.js-card-field-order-sortable\(data-side="\{\{side\}\}"\)\n\s+each row in section\.rows/);
// The line is what the handlers read: data-key and data-side on the row class.
assert.strictEqual((popup.match(/\.card-settings-line\.js-card-field-order-row\(class="\{\{#if row\.personal\}\}card-settings-row-personal\{\{\/if\}\} \{\{#if row\.positioned\}\}is-positioned\{\{\/if\}\}" data-key="\{\{row\.key\}\}" data-side="(card|minicard)"\)/g) || []).length, 2);
// The card's list: On this card, Show on Card, Show empty, the field.
const card = popup.slice(popup.indexOf('card-settings-list-card'), popup.indexOf('card-settings-list-minicard'));
const order = ['card-settings-side-head-this', 'card-settings-side-head-card', 'card-settings-side-head-always', 'card-settings-pane-head-label'].map(c => card.indexOf(c));
assert.ok(order.every(i => i !== -1));
assert.deepStrictEqual([...order].sort((x, y) => x - y), order);
// data-side is the ROW's own, not the section's argument: switching Show
// then moves each row (keyed by _id) with its side, instead of relabelling
// the rows where they stand a flush before they reorder.
assert.strictEqual((inlineSection.match(/\.card-settings-line\.card-settings-inline-item\.js-card-field-order-row\(class="\{\{#if row\.personal\}\}card-settings-row-personal\{\{\/if\}\} \{\{#if row\.positioned\}\}is-positioned\{\{\/if\}\}" data-key="\{\{row\.key\}\}" data-side="\{\{row\.side\}\}"\)/g) || []).length, 1);
assert.match(sidebarJs, /return \{ \.\.\.row, _id: row\.key, menu, hasMenu:/, 'each row is keyed, so Blaze moves it rather than reusing it by index');
// A row: the side's checkbox, then the field; On this card and Show empty
// are in its hamburger (the pane's columns of the same name are gone).
assert.match(inlineSection, /\+cardSettingsSideToggle\(row=row\)\n\s+\+cardSettingsRowLabel\(row=row\)\n\s+if row\.hasMenu/);
const toggle = sidebarJade.slice(sidebarJade.indexOf('template(name="cardSettingsSideToggle")')).split('\ntemplate(')[0];
assert.match(toggle, /a\.flex\.card-field-order-toggle\(class="\{\{#if row\.writable\}\}\{\{row\.toggle\}\}\{\{else\}\}is-disabled\{\{\/if\}\} \{\{#if row\.checked\}\}is-checked\{\{\/if\}\} \{\{#if row\.animating\}\}is-animating\{\{\/if\}\}"/);
// The same animated checkbox as "On this card".
@@ -248,8 +263,11 @@ test('the sections are the Admin Panel left menu; each pane has a Show on Card a
assert.match(sidebarJs, /side === 'card' \? CARD_LAYOUT : MINICARD_LAYOUT/);
assert.match(sidebarJs, /side === 'card' \? currentBoard\?\.cardFieldOrder : currentBoard\?\.minicardFieldOrder/);
assert.match(sidebarJs, /rowsForSide\(side, order\)/);
// A menu asking for one side hides the other list.
assert.match(sidebarCss, /\.board-card-settings\.show-card-only \.card-settings-list-minicard,\n\.board-card-settings\.show-minicard-only \.card-settings-list-card \{\n\s+display: none;/);
assert.match(sidebarJs, /const rows = sectionRowsOf\(section\.id, data, side, tpl\.animating\.get\(\)\)/);
// Negative: the one-side views of the old popup (show-card-only /
// show-minicard-only, hiding the other list) are gone with it - Show is the
// one way to pick a side.
assert.ok(!/show-card-only|show-minicard-only/.test(sidebarCss + sidebarJs), 'negative: no CSS-hidden second list');
// Every row of the table is in exactly one section.
const { SECTIONS } = require('../models/lib/cardSettingsSections');
const keys = SECTIONS.flatMap(sec => sec.keys);
@@ -259,22 +277,28 @@ test('the sections are the Admin Panel left menu; each pane has a Show on Card a
test('rows reorder by drag and drop and the keyboard, in the list as shown; a row with no place is not dragged', () => {
const label = sidebarJade.slice(sidebarJade.indexOf('template(name="cardSettingsRowLabel")')).split('\ntemplate(')[0];
assert.match(label, /span\.card-settings-line-label\(class="\{\{#if row\.draggable\}\}js-card-field-order-handle card-field-order-drag-label\{\{\/if\}\}"/);
// Deliberate change (user request): with drag handles on only the handle
// drags, so the label is a handle only while they are off
// (tests/cardSettingsOnCard.test.cjs pins the negative).
assert.match(label, /span\.card-settings-line-label\(class="\{\{#if row\.draggable\}\}\{\{#unless row\.showDragHandles\}\}js-card-field-order-handle card-field-order-drag-label\{\{\/unless\}\}\{\{\/if\}\}"/);
assert.match(label, /if row\.draggable\n\s+if row\.showDragHandles\n\s+a\.flex\.card-field-order-move\.card-field-order-handle\.js-card-field-order-handle\(href="#" draggable="false" role="button" tabindex="0" title="\{\{_ 'drag-to-reorder'\}\}"/);
assert.ok(en['drag-to-reorder']);
// Dragged by a board admin, a row that has a place of its own.
assert.match(sidebarJs, /draggable: writable && built\.positioned,/);
assert.match(sidebarJs, /const positioned = !spec\.after && isMovableKey\(layout, row\.key\);/);
assert.match(sidebarJs, /reorderOnDrop\(this\.\$\(list\), \{\n\s+items: '> \.js-card-field-order-row\.is-positioned', handle: '\.js-card-field-order-handle',/);
// ownListOnly: a row dropped on another section goes back (deliberate,
// user request) - the rows are one list per section now.
assert.match(sidebarJs, /reorderOnDrop\(this\.\$\(list\), \{\n\s+ownListOnly: true,\n\s+items: '> \.js-card-field-order-row\.is-positioned', handle: '\.js-card-field-order-handle',/);
assert.match(sidebarJs, /drop: \(key, keys\) => placeInListOrder\(list\.dataset\.side, key, keys\),/);
// The keyboard: one row up or down IN THE LIST, placed the same way as a drop.
assert.match(sidebarJs, /'keydown \.js-card-field-order-handle'\(evt\) \{\s*const direction = ARROW_KEYS\[evt\.key\];\s*if \(direction\) moveCardSettingsRow\(evt, direction\);/);
assert.match(sidebarJs, /const ARROW_KEYS = \{ ArrowUp: 'up', ArrowDown: 'down' \};/);
const move = sidebarJs.slice(sidebarJs.indexOf('function moveCardSettingsRow'), sidebarJs.indexOf('Template.boardCardSettingsPopup.events'));
const move = sidebarJs.slice(sidebarJs.indexOf('function moveCardSettingsRow'), sidebarJs.indexOf('async function selectCardSettingsColumn'));
assert.ok(move.length > 0 && move.length < 2000, 'moveCardSettingsRow found');
assert.match(move, /placeInListOrder\(list\.dataset\.side, rowEl\.dataset\.key, keys\);/);
// Negative: the arrows and their numbers are gone, and nothing moves by
// the side's whole order behind the list's back.
assert.ok(!/js-card-field-order-up|js-card-field-order-down|card-settings-position|sidePosition/.test(popup + sidebarJs + sidebarCss));
assert.ok(!/js-card-field-order-up|js-card-field-order-down|card-settings-position|sidePosition/.test(settingsUi + sidebarJs + sidebarCss));
assert.ok(!/moveKey\(/.test(sidebarJs), 'negative: no one-step move in the whole order');
});
@@ -289,8 +313,9 @@ test('every locale has the column heading keys', () => {
});
test('no hand-written row is left in the popup (negative: the table is the single source)', () => {
assert.ok(!popup.includes('js-field-has-'), 'no js-field-has-* in the template');
assert.ok(!popup.includes('//div.check-div'), 'no dead commented-out rows');
// The popup is cardSettingsInline now: its templates are read too.
assert.ok(!settingsUi.includes('js-field-has-'), 'no js-field-has-* in the template');
assert.ok(!settingsUi.includes('//div.check-div'), 'no dead commented-out rows');
assert.ok(!sidebarJade.includes('cardFieldOrderRows'), 'the old separate arrow list is gone');
});
@@ -332,7 +357,8 @@ test('a dropped row lands where it was dropped in its list, on its own side only
// Negative: a list of one row has nowhere to go.
assert.strictEqual(dropTargetIndex(undefined, CARD_LAYOUT, 'vote', ['vote']), -1);
// Through the board's setters, the side the list says; nothing written when nothing moved.
const place = sidebarJs.slice(sidebarJs.indexOf('function placeInListOrder('), sidebarJs.indexOf('Template.boardCardSettingsPopup.onRendered'));
const place = sidebarJs.slice(sidebarJs.indexOf('function placeInListOrder('), sidebarJs.indexOf('function moveCardSettingsRow'));
assert.ok(place.length > 0 && place.length < 2000, 'placeInListOrder found');
assert.match(place, /if \(!board \|\| !\(Utils\.canModifyBoard && Utils\.canModifyBoard\(\)\)\) return;/);
assert.match(place, /if \(order\.join\(' '\) === applyLayoutOrder\(stored, layout\)\.join\(' '\)\) return;/);
assert.match(place, /if \(side === 'minicard'\) board\.setMinicardFieldOrder\(order\);\n\s+else board\.setCardFieldOrder\(order\);/);
@@ -439,10 +465,17 @@ test('minicard-only rows sit beside the row they belong with', () => {
'Comment count is a badge with its own place, above the comment preview');
const personal = CARD_SETTINGS_ROWS.filter(r => r.minicard && r.minicard.personal).map(r => r.key);
assert.deepStrictEqual(personal, ['labelTextPersonal', 'listTitle']);
assert.ok(popup.includes('{{#if row.personal}}card-settings-row-personal{{/if}}'), 'personal rows keep the class CSS hides outside the minicard view');
// A line per row in each list (card-settings-line).
assert.ok(sidebarCss.includes('.board-card-settings.show-personal-only .card-settings-line:not(.card-settings-row-personal)'));
assert.match(popup, /\.card-settings-line\.js-card-field-order-row\(class="\{\{#if row\.personal\}\}card-settings-row-personal\{\{\/if\}\}/);
// Deliberate change: there is no personal-only view any more (it hid
// every row but the personal ones from a non-admin). Everybody gets the
// same Card Settings; the board's rows are disabled for who may not change
// the board, a personal row stays usable (`writable`), and Select all /
// none leaves personal rows alone - by the class kept on the row.
assert.ok(inlineSection.includes('{{#if row.personal}}card-settings-row-personal{{/if}}'), 'personal rows keep their class');
assert.match(sidebarJs, /writable: writable \|\| Boolean\(built\.personal\),/);
assert.match(sidebarJs, /\.filter\(cell => !cell\.classList\.contains\('card-settings-row-personal'\)\)/);
assert.ok(!sidebarCss.includes('show-personal-only'), 'negative: no personal-only view hiding rows');
assert.ok(!sidebarJs.includes('personalOnly'), 'negative: no personal-only mode');
assert.match(inlineSection, /\.card-settings-line\.card-settings-inline-item\.js-card-field-order-row\(class="\{\{#if row\.personal\}\}card-settings-row-personal\{\{\/if\}\}/);
});
console.log(`\n${passed} passed`);
+106 -5
View File
@@ -116,12 +116,101 @@ test('top to bottom: Card Settings with its X at the end, Show and its two choic
assert.match(css, /\.card-settings-inline-head \{\s*display: flex;/);
assert.match(css, /\.card-settings-inline-title \{ flex: 1 1 auto;/);
// Radio buttons, one group, Show on Card chosen by default.
assert.equal((inline.match(/input\.js-card-settings-show-side\(type="radio" name="card-settings-show-side"/g) || []).length, 2);
// Each Card Settings its own radio group: the opened card's and Board
// Settings / Card may be open together, and one name made them one group.
assert.equal((inline.match(/input\.js-card-settings-show-side\(type="radio" name="card-settings-show-side-\{\{#if inPopup\}\}popup\{\{else\}\}card\{\{\/if\}\}"/g) || []).length, 2);
assert.doesNotMatch(inline, /name="card-settings-show-side"/, 'negative: no name shared by both');
assert.match(sidebarJs, /this\.showSide = new ReactiveVar\('card'\);/);
assert.match(sidebarJs, /tpl\.showSide\.set\(evt\.currentTarget\.value === 'minicard' \? 'minicard' : 'card'\);/);
assert.match(sidebarJs, /'click \.js-close-card-settings'\(evt\) \{\s*evt\.preventDefault\(\);\s*closeCardSettings\(\);/);
});
// ── Board Settings / Card: the same template ────────────────────────────────
test('Board Settings / Card is the opened card\'s Card Settings, in a popup', () => {
// Deliberate change: it used to be its own layout - the Admin Panel's left
// menu (`+leftMenu(settingsMenuItems)`) and a pane (`.card-settings-pane`)
// of two lists with column heads and per-column Select all / none. Now the
// popup's body is the opened card's template and nothing else, so the two
// cannot drift apart.
const popup = templateOf(sidebarJade, 'boardCardSettingsPopup');
// Its body: up to the next top-level comment (the next template's), its
// own `//-` comment and that comment's continuation lines left out.
const lines = popup.split('\n').slice(1);
const end = lines.findIndex(l => /^\/\/-/.test(l));
const body = lines.slice(0, end === -1 ? undefined : end)
.filter(l => l.trim() && !/^\s*\/\/-/.test(l) && !/^ {4}/.test(l));
assert.deepStrictEqual(body, [' +cardSettingsInline(inPopup=true)'], 'the popup is only cardSettingsInline');
assert.match(cardJade, /\+cardSettingsInline\(card=this\)/, 'the same template as the opened card');
// With inPopup the popup's own header is the title and the X: no second.
assert.match(inline, /unless inPopup\n\s+\.card-settings-inline-head\n\s+h3\.card-settings-inline-title/);
// Negative: nothing of the old layout is left anywhere in sidebar.jade/js.
for (const gone of ['leftMenu(settingsMenuItems)', '.card-settings-pane', 'paneTitle(settingsPaneTitle)',
'cardSettingsSelectButtons', 'js-card-settings-select(', 'card-settings-list-card', 'card-settings-list-minicard',
'card-settings-side-head']) {
assert.ok(!sidebarJade.includes(gone), `negative: ${gone} is gone from sidebar.jade`);
}
for (const gone of ['settingsMenuItems', 'settingsPaneTitle', 'openSettingsSectionOf', "'click .js-card-settings-menu'",
"'click .js-card-settings-select'", 'personalOnly', 'leftMenuData']) {
assert.ok(!sidebarJs.includes(gone), `negative: ${gone} is gone from sidebar.js`);
}
// Whoever opens it gets the same view: no personal-only mode. Who may not
// change the board sees the board's rows disabled and may still set their
// own (Labels text, List title) - a row's `writable`.
assert.match(sidebarJs, /'click \.js-open-board-card-settings'\(event\) \{\n\s+Popup\.open\('boardCardSettings'\)\.call\(\{ currentData: \(\) => \(\{\}\) \}, event\);/);
assert.match(sidebarJs, /writable: writable \|\| Boolean\(built\.personal\),/);
assert.match(templateOf(sidebarJade, 'cardSettingsSideToggle'), /class="\{\{#if row\.writable\}\}\{\{row\.toggle\}\}\{\{else\}\}is-disabled\{\{\/if\}\}/);
});
test('with drag handles on only the handle drags; a row stays in its own section', () => {
// Deliberate change (user request): with drag handles on, the label and
// the section title are not drag handles - only the arrows handle is, so
// the rest of a row scrolls on a touch screen. With them off, the label
// and the title drag.
const label = templateOf(sidebarJade, 'cardSettingsRowLabel');
assert.match(label, /span\.card-settings-line-label\(class="\{\{#if row\.draggable\}\}\{\{#unless row\.showDragHandles\}\}js-card-field-order-handle card-field-order-drag-label\{\{\/unless\}\}\{\{\/if\}\}"/);
assert.match(label, /if row\.showDragHandles\n\s+a\.flex\.card-field-order-move\.card-field-order-handle\.js-card-field-order-handle/);
assert.match(section, /h4\.card-settings-inline-section-title\(class="\{\{#if section\.draggable\}\}\{\{#unless section\.showDragHandles\}\}js-card-settings-section-handle\{\{\/unless\}\}\{\{\/if\}\}"/);
// Negative: the handle class is never outside `unless showDragHandles` on
// the label or the title.
const labelClass = label.match(/span\.card-settings-line-label\(class="([^"]*)"/)[1];
assert.ok(!labelClass.replace(/\{\{#unless row\.showDragHandles\}\}[\s\S]*?\{\{\/unless\}\}/g, '').includes('js-card-field-order-handle'),
'negative: the label is a handle only without drag handles');
const titleClass = section.match(/h4\.card-settings-inline-section-title\(class="([^"]*)"/)[1];
assert.ok(!titleClass.replace(/\{\{#unless section\.showDragHandles\}\}[\s\S]*?\{\{\/unless\}\}/g, '').includes('js-card-settings-section-handle'),
'negative: the title is a handle only without drag handles');
// A row dropped on another section goes back and nothing is written.
const rows = sidebarJs.slice(sidebarJs.indexOf('Template.cardSettingsInlineSection.onRendered('));
assert.match(rows.slice(0, 400), /reorderOnDrop\(this\.\$\(list\), \{\n\s+ownListOnly: true,/);
const fn = sidebarJs.slice(sidebarJs.indexOf('function reorderOnDrop('));
assert.match(fn.slice(0, 200), /function reorderOnDrop\(\$list, \{ items, handle, keyOf, drop, axis = 'y', ownListOnly = false \}\)/);
// Driven, not read: reorderOnDrop from sidebar.js against a stand-in
// sortable that fires jQuery UI 1.14's events in its real order. On release
// jQuery UI's _clear() runs "beforeStop", then the delayed "out" of the list
// the item was over, then "stop" (node_modules/jquery-ui/ui/widgets/sortable.js)
// - so a flag read in stop always says "outside" and every drop in its own
// list would be thrown away. The decision must be taken before that "out".
const src = fn.slice(0, fn.indexOf('\n}\n') + 2);
const reorderOnDrop = new Function(`${src}\nreturn reorderOnDrop;`)();
const run = (events, ownListOnly) => {
let opts;
const dropped = [];
const $list = { length: 1, sortable(o) { if (typeof o === 'object') opts = o; }, find: () => ({ toArray: () => [{ k: 'b' }, { k: 'a' }] }) };
reorderOnDrop($list, { items: '> .x', handle: '.h', keyOf: el => el.k, drop: (key, keys) => dropped.push([key, keys]), ownListOnly });
for (const e of events) opts[e]({}, { item: [{ k: 'a' }] });
return dropped;
};
// Dropped in its own list: over, release -> beforeStop, delayed out, stop.
assert.deepStrictEqual(run(['start', 'over', 'beforeStop', 'out', 'stop'], true), [['a', ['b', 'a']]], 'a drop in its own list is written');
// Negative: dragged out onto another section and released there.
assert.deepStrictEqual(run(['start', 'over', 'out', 'beforeStop', 'stop'], true), [], 'negative: a drop outside its list writes nothing');
// Without ownListOnly (the sections' list) a drop is always written.
assert.deepStrictEqual(run(['start', 'over', 'out', 'beforeStop', 'stop'], false).length, 1);
// Negative: the sections' own sortable is not limited (it is one list).
const sectionsSort = sidebarJs.slice(sidebarJs.indexOf("reorderOnDrop(this.$('.js-card-settings-sections-sortable')"));
assert.ok(!/^[^}]*ownListOnly/.test(sectionsSort), 'negative: the section list does not pass ownListOnly');
});
test('a section is a checklist: a title, its count, its hamburger, its rows as items', () => {
assert.match(inline, /\+cardSettingsInlineSection\(section=section card=card side=showSide\)/);
const head = section.indexOf('.card-settings-inline-section-head');
@@ -133,7 +222,7 @@ test('a section is a checklist: a title, its count, its hamburger, its rows as i
// An item's checkbox is the chosen side's: the popup's own side cell,
// under data-side = the side shown, so the popup's handlers set it.
const item = section.slice(items);
assert.match(item, /\.js-card-field-order-row\([^)]*data-key="\{\{row\.key\}\}" data-side="\{\{side\}\}"\)\n\s+\+cardSettingsSideToggle\(row=row\)\n\s+\+cardSettingsRowLabel\(row=row\)\n\s+if row\.hasMenu\n\s+a\.card-settings-inline-menu\.js-open-card-settings-item-menu/);
assert.match(item, /\.js-card-field-order-row\([^)]*data-key="\{\{row\.key\}\}" data-side="\{\{row\.side\}\}"\)\n\s+\+cardSettingsSideToggle\(row=row\)\n\s+\+cardSettingsRowLabel\(row=row\)\n\s+if row\.hasMenu\n\s+a\.card-settings-inline-menu\.js-open-card-settings-item-menu/);
assert.match(sidebarJs, /const rows = sectionRowsOf\(section\.id, data, side, tpl\.animating\.get\(\)\)/);
// Negative: On this card and Show empty are not the item's checkbox.
assert.ok(!/js-card-add-field|js-card-always-show/.test(section), 'On this card and Show empty are in the hamburger');
@@ -174,7 +263,12 @@ test('the hamburgers: a section\'s selects, actions and Card\'s board options; a
assert.match(general, /if sectionIsGeneral[\s\S]*js-show-empty-card-fields[\s\S]*js-ask-move-reason/);
assert.match(sectionMenu, /if sectionIsGeneral\n\s+\+boardDragSettings\(section="card"\)/);
// The selects run the popup's own Select all / Select none.
assert.match(sidebarJs, /await selectCardSettingsColumn\(tpl, inlineSectionEl\(data\.sectionId\), column, value === 'all', keys\);/);
assert.match(sidebarJs, /await selectCardSettingsColumn\(tpl, inlineSectionEl\(data\.sectionId, data\.inPopup\), column, value === 'all', keys\);/);
// The opened card's Card Settings and Board Settings / Card may both be
// open: Select all acts on the one its hamburger was opened from.
assert.match(sidebarJs, /inPopup: Boolean\(tpl\.data && tpl\.data\.inPopup\)/);
assert.match(sidebarJs, /const which = inPopup \? '\.card-settings-in-popup' : ':not\(\.card-settings-in-popup\)';/);
assert.doesNotMatch(sidebarJs, /document\.querySelector\(`\.js-card-settings-inline \.js-card-settings-section/, 'negative: no lookup that takes whichever Card Settings comes first');
// A row's: On this card and Show empty, as the popup's cells.
assert.match(itemMenu, /if menu\.thisCard[\s\S]*a\.flex\.card-add-field\.js-card-add-field\(data-key="\{\{key\}\}"/);
assert.match(itemMenu, /if menu\.always[\s\S]*a\.flex\.card-always-show\.js-card-always-show\(data-key="\{\{key\}\}"/);
@@ -185,11 +279,18 @@ test('the hamburgers: a section\'s selects, actions and Card\'s board options; a
test('negative: one set of handlers - every Card Settings template uses the popup\'s', () => {
assert.match(sidebarJs, /const cardSettingsEvents = \{/);
assert.match(sidebarJs, /Template\.boardCardSettingsPopup\.events\(cardSettingsEvents\);/);
// Deliberate change: Board Settings / Card is now the SAME template as the
// opened card's Card Settings - boardCardSettingsPopup only renders
// `+cardSettingsInline(inPopup=true)`, whose own registration below carries
// the handlers. Registering them on the popup as well would run every click
// twice (the event bubbles from the inline to the popup), so the popup must
// register none (negative).
assert.ok(!/Template\.boardCardSettingsPopup\.(events|helpers|onCreated|onRendered)\(/.test(sidebarJs),
'negative: boardCardSettingsPopup registers no events/helpers of its own');
assert.match(sidebarJs, /Template\.cardSettingsInline\.events\(\{\s*\.\.\.cardSettingsEvents,/);
assert.match(sidebarJs, /Template\.cardSettingsSectionPopup\.events\(\{\s*\.\.\.cardSettingsEvents,/);
assert.match(sidebarJs, /Template\.cardSettingsItemPopup\.events\(cardSettingsEvents\);/);
for (const name of ['boardCardSettingsPopup', 'cardSettingsSectionPopup', 'cardSettingsItemPopup']) {
for (const name of ['cardSettingsSectionPopup', 'cardSettingsItemPopup']) {
assert.ok(sidebarJs.includes(`Template.${name}.onCreated(setUpCardSettings);`), name);
}
assert.match(sidebarJs, /function setUpCardSettingsInline\(\) \{\s*setUpCardSettings\.call\(this\);/);
+59 -38
View File
@@ -1,9 +1,16 @@
'use strict';
// Card Settings (Board Settings / Card, and the opened card's + Add): every
// pane's field table has Select all / Select none under each column's
// heading; headings and the cells under them start at the same x; the popup is
// wide enough that no row's label is cut. docs/Features/Cards/Card-Fields.md
// Card Settings (Board Settings / Card, and the opened card's Card Settings -
// one template, cardSettingsInline): every section's hamburger has Select all
// / Select none of each column (the side shown, On this card, Show empty);
// a row's checkbox starts at its line's start; the popup is wide enough that
// no row's label is cut. docs/Features/Cards/Card-Settings-On-Card.md
//
// Deliberate change: Board Settings / Card used to be its own menu-and-pane
// layout with Select all / none under each column HEADING
// (cardSettingsSelectButtons, 'click .js-card-settings-select'). It is now
// the opened card's template, so the selects are the section hamburger's
// (cardSettingsSectionPopup, 'click .js-card-settings-inline-select').
// Run: node tests/cardSettingsSelectAllAligned.test.cjs
const assert = require('assert');
@@ -19,6 +26,7 @@ console.log('cardSettingsSelectAllAligned:');
const jade = read('client/components/sidebar/sidebar.jade');
const popup = jade.slice(jade.indexOf('template(name="boardCardSettingsPopup")')).split('\ntemplate(')[0];
const sectionMenu = jade.slice(jade.indexOf('template(name="cardSettingsSectionPopup")')).split('\ntemplate(')[0];
const js = read('client/components/sidebar/sidebar.js');
const css = read('client/components/sidebar/sidebar.css');
// The work of Select all / Select none is one function, shared by the popup's
@@ -27,23 +35,30 @@ const css = read('client/components/sidebar/sidebar.css');
// handler only gathers the keys of its pane.
const selectFn = js.slice(js.indexOf('async function selectCardSettingsColumn('));
const handler = selectFn.slice(0, selectFn.indexOf('\n}\n'));
const clickHandler = js.slice(js.indexOf("async 'click .js-card-settings-select'")).split('\n },\n')[0];
const clickAt = js.indexOf("async 'click .js-card-settings-inline-select'");
assert.ok(clickAt !== -1, 'the section hamburger\'s select handler');
const clickHandler = js.slice(clickAt).split('\n },\n')[0];
test('Select all and Select none under every column heading, for who may change it', () => {
const head = popup.slice(popup.indexOf('.card-settings-pane-head'), popup.indexOf('each row in sectionSettingsRows'));
for (const column of ['minicard', 'card', 'always']) {
assert.match(head, new RegExp(`if canModifyBoard\\n\\s+\\+cardSettingsSelectButtons\\(column="${column}"\\)`), column);
test('Select all and Select none for every column, in the section\'s hamburger, for who may change it', () => {
// The side shown (Show on Minicard / Show on Card) for who may change the
// board, On this card for who may change the card (only with a card, only
// on the card side), Show empty for a board admin while Show empty fields
// is off.
for (const [gate, column] of [['maySelectSide', '{{side}}'], ['maySelectThisCard', 'this'], ['maySelectAlways', 'always']]) {
assert.match(sectionMenu, new RegExp(`if ${gate}\\n\\s+li\\.card-settings-section-menu-select\\n\\s+span [^\\n]+\\n\\s+span\\.board-settings-column-actions\\.card-settings-select\\n\\s+button\\.js-card-settings-inline-select\\(type="button" data-column="${column.replace(/[{}]/g, '\\$&')}" data-value="all"\\) \\{\\{_ 'select-all'\\}\\}\\n\\s+button\\.js-card-settings-inline-select\\(type="button" data-column="${column.replace(/[{}]/g, '\\$&')}" data-value="none"\\) \\{\\{_ 'select-none'\\}\\}`), gate);
}
// On this card is drawn only for who may change the card (settingsCardId).
assert.match(head, /if settingsCardId\n\s+span\.card-settings-side-head\.card-settings-side-head-this\n\s+span \{\{_ 'card-settings-this-card'\}\}\n\s+\+cardSettingsSelectButtons\(column="this"\)/);
assert.strictEqual((head.match(/\+cardSettingsSelectButtons/g) || []).length, 4, 'one pair per column');
const buttons = jade.slice(jade.indexOf('template(name="cardSettingsSelectButtons")')).split('\ntemplate(')[0];
assert.strictEqual((sectionMenu.match(/data-value="all"/g) || []).length, 3, 'one pair per column');
assert.match(js, /maySelectSide\(\) \{ return Boolean\(Utils\.canModifyBoard && Utils\.canModifyBoard\(\)\); \},/);
assert.match(js, /maySelectThisCard\(\) \{\n\s+const card = settingsCard\(\);\n\s+return this\.side === 'card' && Boolean\(card && Utils\.canModifyCard\(card\)\);/);
// Negative: the popup's own column buttons are gone, with their template
// and handler, so there is one way to select.
assert.ok(!jade.includes('template(name="cardSettingsSelectButtons")') && !jade.includes('+cardSettingsSelectButtons'));
assert.ok(!/js-card-settings-select\(/.test(jade) && !js.includes("'click .js-card-settings-select'"));
const buttons = sectionMenu;
// The Draggable table's buttons: real buttons in its container, so the same
// white text on the theme's colour (forms.css button,
// boardSettingsColumnActions.css) - and keyboard use comes with them.
assert.match(buttons, /span\.board-settings-column-actions\.card-settings-select\n/);
assert.match(buttons, /button\.js-card-settings-select\(type="button" data-column="\{\{column\}\}" data-value="all"\) \{\{_ 'select-all'\}\}/);
assert.match(buttons, /button\.js-card-settings-select\(type="button" data-column="\{\{column\}\}" data-value="none"\) \{\{_ 'select-none'\}\}/);
assert.match(read('client/components/main/boardDragSettings.jade') + read('client/components/main/boardSettingsColumnActions.jade'), /\.board-settings-column-actions\(data-section=section data-column=column\)\n\s+button\.js-set-settings-column\(type="button"/, 'the Draggable buttons are the same kind');
// Negative: no link-styled select (the first version) and no own colour.
assert.ok(!/card-settings-select-button|a\.[\w.-]*js-card-settings-select/.test(jade));
@@ -66,10 +81,12 @@ test('the handler: each column as its own checkboxes would set it, gated the sam
// The board's columns: a board admin only.
assert.ok(handler.indexOf('if (!(Utils.canModifyBoard && Utils.canModifyBoard())) return;') < handler.indexOf("column === 'always'"));
assert.match(handler, /await board\.setCardFieldAlwaysShown\(keys, on\);/);
// The popup's pane gives the rows it draws: On this card's and Show empty's.
assert.match(clickHandler, /column === 'this' \? keysOf\('\.js-card-add-field'\)/);
assert.match(clickHandler, /column === 'always' \? keysOf\('\.js-card-always-show'\)/);
assert.match(clickHandler, /await selectCardSettingsColumn\(tpl, pane, column, value === 'all', keys\);/);
// The section hamburger gives its section's rows: On this card's and Show
// empty's from the row table, the side's from the section's checklist.
assert.match(clickHandler, /const rows = sectionRowsOf\(data\.sectionId, data, 'card', null\);/);
assert.match(clickHandler, /column === 'this' \? rows\.filter\(r => r\.perCard\)\.map\(r => r\.key\)/);
assert.match(clickHandler, /column === 'always' \? rows\.filter\(r => r\.alwaysShowable\)\.map\(r => r\.key\)/);
assert.match(clickHandler, /await selectCardSettingsColumn\(tpl, inlineSectionEl\(data\.sectionId, data\.inPopup\), column, value === 'all', keys\);/);
// Negative: a personal row, a disabled cell and one already as asked are left alone.
assert.match(handler, /\.filter\(cell => !cell\.classList\.contains\('card-settings-row-personal'\)\)/);
assert.match(handler, /\.filter\(toggle => toggle && !toggle\.classList\.contains\('is-disabled'\) && isOn\(toggle\) !== on\);/);
@@ -80,7 +97,9 @@ test('the handler: each column as its own checkboxes would set it, gated the sam
});
test('Ask for a reason when moving cards: the same checkbox, in the same place, as Show empty fields', () => {
const general = popup.slice(popup.indexOf('if openSettingsSectionIsGeneral'));
// Deliberate change: under the Card section's hamburger now, not the pane.
const general = sectionMenu.slice(sectionMenu.indexOf('if sectionIsGeneral'));
assert.ok(sectionMenu.includes('if sectionIsGeneral'));
const showEmpty = general.match(/div\.check-div\.card-settings-board-option\.card-settings-show-empty-fields\n\s+a\.flex\.card-show-empty-fields\(class="\{\{#if canModifyBoard\}\}js-show-empty-card-fields\{\{else\}\}is-disabled\{\{\/if\}\} \{\{#if isAnimating 'showEmptyCardFields'\}\}is-animating\{\{\/if\}\}" role="checkbox" aria-checked="\{\{#if showEmptyCardFields\}\}true\{\{else\}\}false\{\{\/if\}\}"\)\n\s+\.materialCheckBox\(class="\{\{#if showEmptyCardFields\}\}is-checked\{\{\/if\}\}"\)/);
const ask = general.match(/div\.check-div\.card-settings-board-option\.card-settings-ask-move-reason\n\s+a\.flex\.card-ask-move-reason\(class="\{\{#if canModifyBoard\}\}js-ask-move-reason\{\{else\}\}is-disabled\{\{\/if\}\} \{\{#if isAnimating 'askForMoveReason'\}\}is-animating\{\{\/if\}\}" role="checkbox" aria-checked="\{\{#if askForMoveReason\}\}true\{\{else\}\}false\{\{\/if\}\}"\)\n\s+\.materialCheckBox\(class="\{\{#if askForMoveReason\}\}is-checked\{\{\/if\}\}"\)/);
assert.ok(showEmpty && ask, 'both: a.flex, materialCheckBox, role and aria-checked');
@@ -89,10 +108,12 @@ test('Ask for a reason when moving cards: the same checkbox, in the same place,
assert.ok(!/js-ask-move-reason[\s\S]{0,300}fa-square-o/.test(general));
// The same place: one class gives both the same margin, nothing else differs.
assert.match(css, /\.card-settings-board-option \{ margin-block-end: 6px; \}/);
assert.match(css, /\.card-settings-sections \.card-show-empty-fields\.is-animating \.materialCheckBox,\n\.card-settings-sections \.card-ask-move-reason\.is-animating \.materialCheckBox \{ transition: 0\.2s; \}/);
// (The pane's `.card-settings-sections ... is-animating` rule went with
// the pane: both are in the hamburger popup now, the same markup.)
assert.ok(!css.includes('.card-settings-sections'), 'negative: no pane-only rule left');
// Reactive from the board, written by a board admin, animating only when clicked.
assert.match(js, /askForMoveReason\(\) \{ return ReactiveCache\.getBoard\(Session\.get\('currentBoard'\)\)\?\.askForMoveReason === true; \},/);
const click = js.slice(js.indexOf("async 'click .js-ask-move-reason'"), js.indexOf("'click .js-card-field-order-up'"));
const click = js.slice(js.indexOf("async 'click .js-ask-move-reason'"), js.indexOf("async 'click .js-ask-move-reason'") + 600);
assert.match(click, /if \(!board \|\| !\(Utils\.canModifyBoard && Utils\.canModifyBoard\(\)\)\) return;\n\s+tpl\.animating\.set\('askForMoveReason'\);/);
assert.match(click, /await board\.setAskForMoveReason\(board\.askForMoveReason !== true\);/);
assert.ok(!/toggleClass/.test(click), 'negative: no hand-toggled classes');
@@ -105,28 +126,28 @@ test('Show empty: Select all and Select none set the whole list at once', () =>
assert.deepStrictEqual(f.alwaysShownAfter({ cardFieldsAlwaysShown: ['labels'] }, 'dueDate', true), ['dueDate', 'labels'], 'one key still works');
});
test('a heading and its cells start at the same x', () => {
// The bug: the heading line had the smaller font, and its em columns were
// narrower than the rows' - each heading drifted left of its column.
const headRule = css.slice(css.indexOf('.card-settings-pane-head {'), css.indexOf('}', css.indexOf('.card-settings-pane-head {')));
assert.ok(!/font-size/.test(headRule), 'negative: no font-size on the heading line itself');
assert.match(css, /\.card-settings-pane-head > span \{\n\s+font-size: calc\(12px \* var\(--wekan-ui-font-scale, 1\)\);/);
// Every item starts at its column's start edge.
// (The card's list: Show on Card, Show empty, the field.)
assert.match(css, /grid-template-columns: 9em 8em minmax\(10em, 1fr\);\n\s+column-gap: 14px;\n\s+align-items: center;\n[\s\S]{0,200}justify-items: start;\n\s+text-align: start;/);
test('a row\'s cells start at its line\'s start; a select\'s label and buttons share a line', () => {
// Deliberate change: there are no column headings to line up any more
// (the pane's .card-settings-pane-head went with it). What stays is that
// nothing in a row is centred or right-aligned, and the hamburger's select
// rows put the column's name and its two buttons on one line.
assert.ok(!css.includes('.card-settings-pane-head'), 'negative: no column heads left');
const line = css.slice(css.indexOf('\n.card-settings-line {'), css.indexOf('}', css.indexOf('\n.card-settings-line {')));
assert.match(line, /display: grid;\n\s+align-items: center;\n\s+justify-items: start;\n\s+text-align: start;/);
assert.match(css, /\.card-settings-side \{\n\s+display: inline-flex;\n\s+align-items: center;\n\s+justify-content: flex-start;\n\s+justify-self: start;/);
assert.match(css, /\.card-settings-side > a\.flex \{\n\s+margin: 0;/);
// Negative: nothing centres or right-aligns a cell of the table.
// (The position number keeps text-align: end inside its own min-width, so
// 9 and 19 line up - that is within a cell, not the cell's place.)
const table = css.slice(css.indexOf('.card-settings-pane-head,\n.card-settings-line {'), css.indexOf('/* One side of the same popup'))
.replace(/\.card-settings-position \{[^}]*\}/, '');
const table = css.slice(css.indexOf('\n.card-settings-line {'), css.indexOf('.card-settings-select {'));
assert.ok(table.length > 100, 'the row rules found');
assert.ok(!/(justify-content|justify-self|justify-items|text-align):\s*(center|end|right|flex-end)/.test(table), 'negative: no centred or right-aligned cell');
assert.match(read('client/components/cards/cardAddFields.css'), /\.card-settings-section-menu \.card-settings-section-menu-select \{\n\s+display: flex;\n\s+flex-wrap: wrap;\n\s+align-items: center;/);
});
test('wide enough for every label where the window is', () => {
assert.match(read('client/lib/popupOffset.js'), /const CARD_SETTINGS_MAX_WIDTH = 1200;/);
assert.match(read('client/components/main/popup.css'), /\.pop-over\[data-popup='boardCardSettingsPopup'\] \{[^}]*width: min\(96vw, 1200px\) !important;/);
// Deliberate change: 600px, about an opened card - Board Settings / Card is
// the opened card's one column of checklists now, not a menu and a pane of
// four checkbox columns (1200px). tests/declutterCard6796.test.cjs.
assert.match(read('client/lib/popupOffset.js'), /const CARD_SETTINGS_MAX_WIDTH = 600;/);
assert.match(read('client/components/main/popup.css'), /\.pop-over\[data-popup='boardCardSettingsPopup'\] \{[^}]*width: min\(96vw, 600px\) !important;/);
// A label longer than its column wraps in it instead of being cut.
assert.match(css, /\.card-settings-line-label \{[^}]*min-width: 0;[^}]*overflow-wrap: anywhere;/);
});
+40 -27
View File
@@ -153,12 +153,22 @@ test('the opened card: "Card Settings" under the cover, the per-card currentBoar
}
});
test('the popup is Board Settings / Card: sections on the left, Show on Minicard, Show on Card and On this card on the right', () => {
test('the popup is Board Settings / Card: the opened card\'s Card Settings - Show on Minicard, Show on Card, On this card', () => {
const jade = read('client/components/sidebar/sidebar.jade');
const popup = jade.slice(jade.indexOf('template(name="boardCardSettingsPopup")')).split('\ntemplate(')[0];
assert.match(popup, /\+leftMenu\(settingsMenuItems\)\n\s+\.main-body\n\s+\+paneTitle\(settingsPaneTitle\)/);
assert.match(popup, /if settingsCardId\n\s+if row\.perCard\n\s+span\.card-settings-side\.card-settings-side-this\n\s+a\.flex\.card-add-field\.js-card-add-field\(data-key="\{\{row\.key\}\}" role="checkbox"/);
const tplOf = name => jade.slice(jade.indexOf(`template(name="${name}")`)).split('\ntemplate(')[0];
// Deliberate change: Board Settings / Card is now the SAME template as the
// opened card's Card Settings (`+cardSettingsInline(inPopup=true)`), not
// the Admin Panel's menu and pane. Show on Minicard / Show on Card are the
// Show radios; On this card is in a row's hamburger, only with a card.
assert.match(tplOf('boardCardSettingsPopup'), /\+cardSettingsInline\(inPopup=true\)/);
assert.ok(!jade.includes('+leftMenu(settingsMenuItems)'), 'negative: the left menu is gone');
const inline = tplOf('cardSettingsInline');
assert.ok(inline.includes("span {{_ 'show-on-minicard'}}") && inline.includes("span {{_ 'show-on-card'}}"));
assert.match(tplOf('cardSettingsItemPopup'), /if menu\.thisCard\n\s+div\.check-div\.card-settings-board-option\n\s+a\.flex\.card-add-field\.js-card-add-field\(data-key="\{\{key\}\}" role="checkbox"/);
const js = read('client/components/sidebar/sidebar.js');
// On this card only with a card that may be changed (none in Board Settings).
assert.match(js, /perCard: side === 'card' && Boolean\(thisCard\) && cardShownFields\.FIELD_KEYS\.includes\(built\.key\),/);
assert.match(js, /thisCard: Boolean\(row\.perCard\),/);
// #6806: with the card's board, for its Show empty fields; the data from
// shownFieldsOf, a linked card's being its source's.
assert.match(js, /const next = cardShownFields\.cardFieldsAfter\(view, key, !cardShownFields\.isShown\(key, view, counts, board\), counts, board\);\n\s+await Cards\.updateAsync\(card\._id, \{ \$set: next \}\);/);
@@ -221,23 +231,25 @@ test('the cover is on by default, and a new drawing is the cover', () => {
assert.match(drawing, /\.\.\.\(isNew \? coverOf\(pngAttachmentId\) : \{\}\)/, 'an edit leaves the cover');
});
test('the popup: wide and high enough, moved and maximized, no tick animates on opening a pane', () => {
test('the popup: high enough, moved and maximized, no tick animates on opening it', () => {
const popupCss = read('client/components/main/popup.css');
assert.match(popupCss, /\.pop-over\[data-popup='boardCardSettingsPopup'\] \.content-wrapper,\n\s+\.pop-over\[data-popup='boardCardSettingsPopup'\] \.content-container \{\n\s+max-height: none;/, 'room for every menu entry');
const css = read('client/components/sidebar/sidebar.css');
// Only a clicked checkbox animates: none by default, each kind in the one
// rule that turns it on while it is-animating. Each kind is required by
// name, not the rule's exact list - a new checkbox (Show empty, Show empty
// fields, Ask for a reason) is added to it without rewriting this.
assert.match(css, /\.card-settings-sections \.materialCheckBox \{ transition: none; \}/);
const on = css.slice(css.indexOf('.card-settings-sections .card-add-field.is-animating'), css.indexOf('{ transition: 0.2s; }'));
for (const cls of ['card-add-field', 'card-field-order-toggle', 'card-always-show', 'card-show-empty-fields', 'card-ask-move-reason']) {
assert.ok(on.includes(`.card-settings-sections .${cls}.is-animating .materialCheckBox`), cls);
}
assert.match(popupCss, /\.pop-over\[data-popup='boardCardSettingsPopup'\] \.content-wrapper,\n\s+\.pop-over\[data-popup='boardCardSettingsPopup'\] \.content-container \{\n\s+max-height: none;/, 'room for every section');
// Deliberate change: Board Settings / Card is now cardSettingsInline, so
// the rule is the inline's (cardAddFields.css), not the old pane's
// `.card-settings-sections`: only a clicked checkbox animates, none by
// default. Its other checkboxes (On this card, Show empty, Show empty
// fields, Ask for a reason) are in the hamburger popups, drawn when one
// opens rather than re-drawn under the list.
const css = read('client/components/cards/cardAddFields.css');
assert.match(css, /\.card-settings-inline \.materialCheckBox \{ transition: none; \}/);
assert.match(css, /\.card-settings-inline \.card-field-order-toggle\.is-animating \.materialCheckBox \{ transition: 0\.2s; \}/);
assert.ok(!read('client/components/sidebar/sidebar.css').includes('.card-settings-sections'), 'negative: the pane\'s rules are gone');
// A board-wide checkbox animates when clicked too, and only that one.
assert.match(read('client/components/sidebar/sidebar.js'), /'click \.card-field-order-toggle'\(evt, tpl\) \{\n\s+const cell = evt\.currentTarget\.closest\('\.js-card-field-order-row'\);\n\s+if \(cell\) tpl\.animating\.set\(`\$\{cell\.dataset\.key\}:\$\{cell\.dataset\.side\}`\);/);
const js = read('client/components/sidebar/sidebar.js');
assert.match(js, /tpl\.animating\.set\(null\);\n\s+tpl\.settingsSection\.set\(evt\.currentTarget\.dataset\.id\);/, 'opening a pane animates nothing');
// Switching Show (what replaced opening a pane) animates nothing.
assert.match(js, /'change \.js-card-settings-show-side'\(evt, tpl\) \{\n\s+tpl\.animating\.set\(null\);/, 'switching the side animates nothing');
assert.ok(!/tpl\.settingsSection\.set/.test(js), 'negative: no pane to open any more');
assert.match(js, /tpl\.animating\.set\(key\);/, 'the clicked one does');
const popup = read('client/components/main/popup.js');
assert.match(popup, /MOVABLE_POPUPS = new Set\(\[[^\]]*'boardCardSettingsPopup'/);
@@ -254,18 +266,19 @@ test('a maximized card has its comments section in the right column', () => {
assert.match(read('client/components/cards/cardDetails.css'), /\.card-details-right \.card-details-comments \.card-details-section-rule \{\n\s+display: none;/);
});
test('the pane lines up: fixed side columns on every line, and an inline form keeps its floats', () => {
test('a row lines up: its checkbox, its name, its hamburger', () => {
// Deliberate change: the pane's fixed side columns (7em/9em/8em grids, the
// has-card and minicard list variants) went with the pane - Board Settings
// / Card is cardSettingsInline now. Each row is one grid of three tracks,
// the name taking the rest, in both places.
const css = read('client/components/sidebar/sidebar.css');
// Each line is its own grid: sized by content, no two lines would agree.
// #6806 added the Show empty column (8em) before the field.
// Since 2026-10-27 two lists: the card's (On this card from a card, Show on
// Card, Show empty, the field) and the minicard's (Show on Minicard, the field).
assert.match(css, /grid-template-columns: 9em 8em minmax\(10em, 1fr\);/);
assert.match(css, /\.board-card-settings\.has-card \.card-settings-list-card > \.card-settings-line \{\n\s+grid-template-columns: 7em 9em 8em minmax\(10em, 1fr\);/);
assert.match(css, /\.card-settings-list-minicard > \.card-settings-line \{\n\s+grid-template-columns: 9em minmax\(10em, 1fr\);/);
const inlineCss = read('client/components/cards/cardAddFields.css');
assert.match(inlineCss, /\.card-settings-inline \.card-settings-line \{\n\s+grid-template-columns: auto minmax\(0, 1fr\) auto;/);
assert.match(css, /\n\.card-settings-line \{\n\s+display: grid;/);
assert.doesNotMatch(css, /\.card-settings-line \{[^}]*max-content/, 'negative: no content-sized side column');
// Save floats left and Delete right in Change spent time and the others.
assert.match(css, /\.card-settings-inline-action \{[^}]*display: flow-root;/);
assert.ok(!/has-card|card-settings-list-(card|minicard)|grid-template-columns: 9em/.test(css), 'negative: the pane columns are gone');
// Negative: no in-place action form is left whose floats would need keeping.
assert.ok(!css.includes('.card-settings-inline-action'));
});
test('Select all / Unselect all are centred under Draggable', () => {
+24 -14
View File
@@ -15,9 +15,8 @@
// small file button instead of a 144x96 box with "txt" in it; and two new
// Board Settings / Card / Show on Card rows, Attachments: Preview and
// Attachments: Details (uploader and date), turn the rest off.
// - Board Settings / Card is a centred panel as wide (up to 1800px) and tall as
// the window, and each list spreads into as many columns as fit, read top to
// bottom, so on a 1920x1080 screen nothing is scrolled.
// - Board Settings / Card is a centred panel as tall as the window. It is now
// the opened card's Card Settings template (cardSettingsInline), 600px wide.
// Run: node tests/declutterCard6796.test.cjs
const assert = require('assert');
@@ -152,29 +151,40 @@ test('Checklists: the bottom controls share a line, and the bottom "+" waits for
const offsetSrc = read('client/lib/popupOffset.js');
const offset = vm.runInNewContext(`(() => { ${offsetSrc.replace(/^export \{[^}]*\};?\s*$/m, '')}\n return { computePopupOffset, CARD_SETTINGS_MAX_WIDTH }; })()`);
test('the popup is a centred panel as wide (up to 1200px) and tall as the window', () => {
// 1800px fitted two long lists side by side; the Admin Panel's menu and pane
// (docs/Features/Cards/Card-Fields.md) need a row's label and its two sides.
test('the popup is a centred panel as wide (up to 600px) and tall as the window', () => {
// Deliberate change: Board Settings / Card is now the SAME template as the
// opened card's Card Settings (sidebar.jade boardCardSettingsPopup is only
// `+cardSettingsInline(inPopup=true)`) - one column of checklists, not the
// Admin Panel's menu and a pane of four checkbox columns - so it is about
// as wide as an opened card, 600px rather than 1200px. Still centred and
// as tall as the window (#6796), so it is read with little scrolling.
const base = { popupName: 'boardCardSettingsPopup', viewportHeight: 1080, openerTopVp: 40, openerBottomVp: 60,
openerLeftVp: 1700, scrollTop: 0, scrollLeft: 0 };
const wide = offset.computePopupOffset({ ...base, viewportWidth: 1920 });
// 1200px since the Show empty column: the menu, four checkbox columns and
// the whole label of a row, nothing clipped.
assert.strictEqual(offset.CARD_SETTINGS_MAX_WIDTH, 1200);
assert.strictEqual(wide.left, 360);
assert.strictEqual(offset.CARD_SETTINGS_MAX_WIDTH, 600);
assert.strictEqual(wide.left, (1920 - 600) / 2, 'centred, whatever the opener');
assert.strictEqual(wide.top, 12);
assert.strictEqual(wide.maxHeight, 1056);
const narrow = offset.computePopupOffset({ ...base, viewportWidth: 900, viewportHeight: 768 });
assert.ok(Math.abs(narrow.left - (900 - 900 * 0.96) / 2) < 0.01);
const narrowWidth = Math.min(600, 900 * 0.96);
assert.ok(Math.abs(narrow.left - (900 - narrowWidth) / 2) < 0.01);
assert.strictEqual(narrow.maxHeight, 768 - 24);
// A phone is still the full-screen sheet.
assert.deepStrictEqual({ ...offset.computePopupOffset({ ...base, viewportWidth: 600 }) }, { left: 0, top: 0 });
// The CSS says the same width.
assert.match(popupCss, /\.pop-over\[data-popup='boardCardSettingsPopup'\] \{[^}]*width: min\(96vw, 1200px\) !important;[^}]*max-height: calc\(100dvh - 24px\) !important;/);
assert.match(popupCss, /\.pop-over\[data-popup='boardCardSettingsPopup'\] \{[^}]*width: min\(96vw, 600px\) !important;[^}]*max-height: calc\(100dvh - 24px\) !important;/);
assert.ok(!/width: min\(96vw, 1200px\)/.test(popupCss), 'negative: the old 1200px pane width is gone');
});
test('each row is one line: its label, then the minicard cell and the card cell', () => {
assert.match(sidebarCss, /\.card-settings-pane-head,\n\.card-settings-line \{\n\s+display: grid;/);
test('each row is one line: its checkbox, its name and its hamburger', () => {
// Deliberate change: the pane's column heads (.card-settings-pane-head) are
// gone with the pane; a row is the checklist item of cardSettingsInline,
// the same on the opened card and in Board Settings / Card.
assert.match(sidebarCss, /\n\.card-settings-line \{\n\s+display: grid;/);
assert.match(read('client/components/cards/cardAddFields.css'),
/\.card-settings-inline \.card-settings-line \{\s*grid-template-columns: auto minmax\(0, 1fr\) auto;/);
assert.ok(!sidebarCss.includes('.card-field-order-column-minicard'), 'the column layout is gone (negative)');
assert.ok(!sidebarCss.includes('.card-settings-pane'), 'the pane layout is gone (negative)');
});
console.log(`\n${passed} passed`);
@@ -41,11 +41,12 @@ test.describe('#3823 legacy board creator visibility', () => {
await boardPage.locator('.board-sidebar .js-open-board-menu').click();
await boardPage.locator('.js-pop-over').waitFor();
await boardPage.locator('.js-pop-over .js-open-board-card-settings').click();
// Card Settings opens on its first section, Card; Creator is a row of the
// Members section (models/lib/cardSettingsSections.js), so open it first.
await boardPage.locator('.js-pop-over .js-card-settings-menu[data-id="members"]').click();
// Board Settings / Card is the opened card's Card Settings (2026-10-28):
// Creator is a row of the Members section (models/lib/cardSettingsSections.js),
// and its minicard checkbox is drawn with Show on Minicard chosen.
await boardPage.locator('.js-pop-over .js-card-settings-show-side[value="minicard"]').check();
const creatorSetting = boardPage.locator(
'.js-pop-over .js-field-has-creator-on-minicard',
'.js-pop-over .js-card-settings-section[data-section="members"] .js-field-has-creator-on-minicard',
);
await expect(creatorSetting).toBeVisible();
await expect(creatorSetting).not.toHaveClass(/is-checked/);
@@ -140,20 +140,23 @@ test('F14 V01 V07 A06: scaled headers fit and narrow settings stack without iner
const header = el.querySelector('.header').getBoundingClientRect();
return el.querySelector('.header-title').getBoundingClientRect().bottom <= header.bottom + 1;
})).toBe(true);
// docs/Features/Cards/Card-Fields.md: the rows are grouped in sections, in
// a Show on Card and a Show on Minicard list. A row with no place of its
// own (it modifies another) is not dragged, and the popup stays open.
await popup.locator('.js-card-settings-menu[data-id="labels"]').click();
await expect(popup.locator('.js-card-field-order-row[data-key="labelText"][data-side="card"]')).not.toHaveClass(/is-positioned/);
await expect(popup.locator('.js-card-field-order-row[data-key="labelText"][data-side="card"] .js-card-field-order-handle')).toHaveCount(0);
// docs/Features/Cards/Card-Fields.md: the rows are grouped in sections,
// their checkboxes the side the "Show" radio chooses (Board Settings / Card
// is the opened card's Card Settings since 2026-10-28). A row with no place
// of its own (it modifies another) is not dragged, and the popup stays open.
const section = id => popup.locator(`.js-card-settings-section[data-section="${id}"]`);
const labelText = section('labels').locator('.js-card-field-order-row[data-key="labelText"][data-side="card"]');
await expect(labelText).toBeVisible();
await expect(labelText).not.toHaveClass(/is-positioned/);
await expect(labelText.locator('.js-card-field-order-handle')).toHaveCount(0);
await expect(popup).toBeVisible();
// These rows were inert on the minicard; since every Board Settings / Card
// row can be shown on the card and the minicard (79b4467546), each has a
// working toggle on both sides.
for (const [section, key] of [['members', 'requestedBy'], ['members', 'assignedBy'], ['description', 'descriptionTitle'], ['attachments', 'attachments']]) {
await popup.locator(`.js-card-settings-menu[data-id="${section}"]`).click();
for (const side of ['minicard', 'card']) {
await expect(popup.locator(`.js-card-field-order-row[data-key="${key}"][data-side="${side}"] .card-field-order-toggle`)).toHaveCount(1);
for (const side of ['minicard', 'card']) {
await popup.locator(`.js-card-settings-show-side[value="${side}"]`).check();
for (const [id, key] of [['members', 'requestedBy'], ['members', 'assignedBy'], ['description', 'descriptionTitle'], ['attachments', 'attachments']]) {
await expect(section(id).locator(`.js-card-field-order-row[data-key="${key}"][data-side="${side}"] .card-field-order-toggle`)).toHaveCount(1);
}
}
await page.setViewportSize({ width: 375, height: 900 });
@@ -15,15 +15,16 @@ const setting = () => db.findOne('settings', {}, { _id: 1, cardFieldStates: 1 })
const restore = saved => (saved.cardFieldStates
? db.updateOne('settings', { _id: saved._id }, { $set: { cardFieldStates: saved.cardFieldStates } })
: db.updateOne('settings', { _id: saved._id }, { $unset: { cardFieldStates: '' } }));
// Card Settings opens on its first section, Card; Flowtime and Pomodoro are
// rows of the Time section (models/lib/cardSettingsSections.js), so the pane
// is switched to it - otherwise "no Pomodoro row" would hold for any board.
// Board Settings / Card shows every section at once (cardSettingsInline);
// Flowtime and Pomodoro are rows of the Time section
// (models/lib/cardSettingsSections.js), so the Time section is waited for -
// otherwise "no Pomodoro row" would hold for a popup not drawn yet.
const openCardSettings = async page => {
await page.evaluate(() => {
Popup.close();
Popup.open('boardCardSettings')({ currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
Popup.open('boardCardSettings').call({ currentData: () => ({}) }, { currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
});
await page.locator('.js-pop-over .js-card-settings-menu[data-id="time"]').click();
await expect(page.locator('.js-pop-over .js-card-settings-section[data-section="time"]')).toBeVisible();
};
async function tickPomodoro(page) {
@@ -68,7 +69,8 @@ test.describe('Admin card field visibility', () => {
await expect(minicard).toBeVisible();
await expect(minicard.locator('.minicard-pomodoro')).toHaveCount(0);
await openCardSettings(page);
await expect(page.locator('.js-card-field-order-row[data-key="flowtime"]').first()).toBeAttached();
const time = page.locator('.js-pop-over .js-card-settings-section[data-section="time"]');
await expect(time.locator('.js-card-field-order-row[data-key="flowtime"]')).toBeVisible();
await expect(page.locator('.js-card-field-order-row[data-key="pomodoro"]')).toHaveCount(0);
await page.evaluate(() => Popup.close());
await bp.clickCard(listId, 'Pomodoro Card');
@@ -87,7 +89,7 @@ test.describe('Admin card field visibility', () => {
await openBoard(page, seeded.boardId, seeded.slug);
await expect(bp.minicard(listId, 'Pomodoro Card').locator('.minicard-pomodoro')).toBeVisible({ timeout: 15000 });
await openCardSettings(page);
await expect(page.locator('.js-card-field-order-row[data-key="pomodoro"]').first()).toBeAttached();
await expect(page.locator('.js-pop-over .js-card-settings-section[data-section="time"] .js-card-field-order-row[data-key="pomodoro"]')).toBeVisible();
await page.evaluate(() => Popup.close());
await bp.clickCard(listId, 'Pomodoro Card');
await cp.waitForOpen();
@@ -7,15 +7,33 @@ const valueOf = (doc, field) => field.split('.').reduce((value, key) => value?.[
async function openSettings(page, section) {
await page.evaluate(section => {
Popup.close();
Popup.open(`board${section}Settings`)({currentTarget:document.body, target:document.body, preventDefault() {}, stopPropagation() {}});
const evt = {currentTarget:document.body, target:document.body, preventDefault() {}, stopPropagation() {}};
// Board Settings / Card is opened as the cog menu does: with no card.
if (section === 'Card') Popup.open('boardCardSettings').call({currentData: () => ({})}, evt);
else Popup.open(`board${section}Settings`)(evt);
}, section);
}
// Board Settings / Card is the opened card's Card Settings since 2026-10-28
// (cardSettingsInline): the Draggable table, with its column actions, is in
// the Card section's hamburger, as on the opened card.
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"]');
await expect(menu).toBeVisible();
return menu;
}
// Swimlane and List show their table in the popup; Card in Card's hamburger.
async function openColumns(page, section) {
await openSettings(page, section);
if (section === 'Card') await openCardSectionMenu(page);
}
for (const section of ['Swimlane','List','Card']) test(`${section} column actions set all, unset all and persist without changing other columns`, async ({boardPage:page, board, user}) => {
const other = db.seedBoard({ownerId:user.id});
const otherBefore = db.findOne('boards',{_id:other.boardId});
const profileBefore = db.findOne('users',{_id:user.id}).profile;
try {
await openSettings(page, section);
await openColumns(page, section);
// Card: only Draggable has a select-all; Show on Minicard and Show on Card
// are checkboxes in the sections, with none under the first entry
// (docs/Features/Cards/Card-Fields.md).
@@ -40,7 +58,7 @@ for (const section of ['Swimlane','List','Card']) test(`${section} column action
const after = db.findOne('boards',{_id:board.boardId});
for (const key of Object.keys(before).filter(key => /^(allows|showLabelText|labelsAbove|listWidthResizeLocked|swimlaneHeightResizeLocked|sameWidthForAllLists|.*FieldOrder)/.test(key) && !fields.includes(key))) expect(after[key],key).toEqual(before[key]);
await page.reload();
await openSettings(page, section);
await openColumns(page, section);
const saved = db.findOne('boards',{_id:board.boardId});
expect(fields.every(field => valueOf(saved, field) === true)).toBe(true);
}
@@ -64,46 +82,50 @@ test('column updates reject foreign boards, non-admin members and invalid column
expect(db.findOne('boards',{_id:board.boardId}).allowsLabels).toEqual(before.allowsLabels);
await page.reload();
await openSettings(page,'Card');
// The pane first: a count of 0 taken before it rendered passed in Chromium
// without looking at anything. Then only the board-admin column actions
// (boardSettingsColumnActions, data-column) - Show on Card's Select all /
// Select none (cardSettingsSelectButtons) share the class and are a
// member's, like the checkboxes under them (canModifyBoard).
await expect(page.locator('.pop-over[data-popup="boardCardSettingsPopup"] .card-settings-pane-head').first()).toBeVisible();
// The sections first: a count of 0 taken before they rendered passed in
// Chromium without looking at anything. Then, in Card's hamburger where
// the Draggable table is, no board-admin column actions
// (boardSettingsColumnActions, data-column) for a non-admin member.
await expect(page.locator('.pop-over[data-popup="boardCardSettingsPopup"] .js-card-settings-section').first()).toBeVisible();
const menu = await openCardSectionMenu(page);
await expect(menu.locator('.card-settings-section-menu')).toBeVisible();
await expect(page.locator('.board-settings-column-actions[data-column]')).toHaveCount(0);
} finally { db.cleanup({boardIds:[other.boardId]}); }
});
test('Card settings are the Admin Panel left menu and pane: Draggable in the first entry, each section a line per row', async ({ boardPage: page, board }) => {
// docs/Features/Cards/Card-Fields.md (2026-10-25): it was three columns
// (Draggable, Show on Minicard, Show on Card); now a left menu of sections and
// a pane, the same popup the opened card's "+ Add" opens.
test('Card settings are the opened card\'s Card Settings: Draggable in Card\'s hamburger, each section a line per row', async ({ boardPage: page, board }) => {
// docs/Features/Cards/Card-Fields.md: it was three columns (Draggable, Show
// on Minicard, Show on Card), then the Admin Panel's left menu and pane
// (2026-10-25); since 2026-10-28 it is the opened card's Card Settings
// template (cardSettingsInline) - Show, then every section as a checklist.
await expect(page.locator(`#js-list-${board.listIds[0]}`)).toBeVisible();
for (const width of [1280, 800, 375]) {
await page.setViewportSize({ width, height: 900 });
await openSettings(page, 'Card');
const popup = page.locator('.pop-over[data-popup="boardCardSettingsPopup"]');
await expect(popup).toBeVisible();
await expect(popup.locator('.setting-content .content-body .side-menu')).toBeVisible();
await expect(popup.locator('.board-drag-settings')).toBeVisible();
await popup.locator('.js-card-settings-menu[data-id="date"]').click();
await expect(popup.locator('.js-card-settings-inline')).toBeVisible();
await expect(popup.locator('.side-menu')).toHaveCount(0);
// Draggable is not in the list itself: it is in Card's hamburger.
await expect(popup.locator('.board-drag-settings')).toHaveCount(0);
// Two lists per section now (Show on Card, then Show on Minicard under it,
// each dragged in its own order - docs/Features/Board-Sidebar/Board-Settings/Card.md),
// so a row is a line in each list, each with its own side's checkbox.
const lineOf = side => popup.locator(`.card-settings-line[data-key="dueDate"][data-side="${side}"]`);
// A row is one line per Show side, its checkbox that side's.
const line = popup.locator('.js-card-settings-section[data-section="date"] .card-settings-line[data-key="dueDate"]');
for (const side of ['card', 'minicard']) {
await expect(lineOf(side)).toBeVisible();
await expect(lineOf(side).locator(`.card-settings-side-${side}`)).toBeVisible();
await popup.locator(`.js-card-settings-show-side[value="${side}"]`).check();
await expect(line).toHaveAttribute('data-side', side);
await expect(line).toHaveCount(1);
await expect(line.locator(`.card-settings-side-${side}`)).toBeVisible();
}
const [mini, card] = await Promise.all(['minicard', 'card'].map(side => lineOf(side).boundingBox()));
expect(mini.y).toBeGreaterThan(card.y);
expect(await popup.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
const menu = await openCardSectionMenu(page);
await expect(menu.locator('.board-drag-settings')).toBeVisible();
}
});
test('Card settings have no select-all for Show on Minicard or Show on Card (negative)', async ({ boardPage: page }) => {
await openSettings(page, 'Card');
// In the Draggable table of Card's hamburger: Show on Minicard / Show on
// Card are each section's own Select all / Select none, not a table column.
await openColumns(page, 'Card');
await expect(page.locator('.board-settings-column-actions[data-section="card"][data-column="draggable"]')).toBeVisible();
await expect(page.locator('.board-settings-column-actions[data-section="card"][data-column="minicard"], .board-settings-column-actions[data-section="card"][data-column="card"]')).toHaveCount(0);
});
+234 -21
View File
@@ -4,7 +4,7 @@
// has data in, plus its description, attachments and comments; "Card
// Settings" under the cover (it was "+ Add") adds the rest - right on the
// card, its sections as checklists (docs/Features/Cards/Card-Settings-On-Card.md).
// Board Settings / Card still opens the same settings as a popup.
// Board Settings / Card is the same template, cardSettingsInline, in a popup.
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
@@ -16,7 +16,8 @@ const settingsPopup = page => page.locator('.js-pop-over[data-popup="boardCardSe
async function openBoardCardSettings(page) {
await page.evaluate(() => {
Popup.close();
Popup.open('boardCardSettings')({ currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
// As the cog menu's entry does: Card Settings with no card.
Popup.open('boardCardSettings').call({ currentData: () => ({}) }, { currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
});
await expect(settingsPopup(page)).toBeVisible();
}
@@ -386,6 +387,23 @@ test('Card\'s hamburger: Ask for a reason lines up with Show empty fields, and t
});
// ── Board Settings / Card: the popup ────────────────────────────────────────
// Since 2026-10-28 the popup is the opened card's Card Settings itself
// (cardSettingsInline, inPopup=true): Show, the sections as checklists, the
// rest in their hamburgers. Its old left menu and right pane are gone.
// The rows the template draws, section by section, for the chosen Show side.
async function sectionsAndRows(scope) {
return scope.locator('.js-card-settings-section').evaluateAll(els => els.map(el => ({
section: el.dataset.section,
keys: [...el.querySelectorAll('.js-card-field-order-row')].map(r => `${r.dataset.key}:${r.dataset.side}`),
})));
}
// "List title" is a card's own row (cardSettingsRows.js needsCard): drawn only
// on an opened card, which Board Settings / Card has none of.
const CARD_ONLY_KEYS = ['listTitle'];
const withoutCardOnly = sections => sections.map(s => ({
section: s.section, keys: s.keys.filter(k => !CARD_ONLY_KEYS.includes(k.split(':')[0])),
}));
test('Board Settings / Card is still the popup: it moves by its title, maximizes beside its close button, shows the labels', async ({ boardPage: page }) => {
await openBoardCardSettings(page);
@@ -396,6 +414,8 @@ test('Board Settings / Card is still the popup: it moves by its title, maximizes
const popupBox = await popup.boundingBox();
expect(labelBox.width).toBeGreaterThan(30);
expect(labelBox.x + labelBox.width).toBeLessThanOrEqual(popupBox.x + popupBox.width + 1);
// As wide as Card Settings needs (600px), not the old 1200px left menu + pane.
expect(popupBox.width).toBeLessThanOrEqual(620);
const max = popup.locator('.js-popup-maximize');
await expect(max).toBeVisible();
expect((await max.boundingBox()).x).toBeLessThan((await popup.locator('.js-close-pop-over').boundingBox()).x);
@@ -411,29 +431,222 @@ test('Board Settings / Card is still the popup: it moves by its title, maximizes
await expect(popup).toHaveClass(/pop-over--maximized/);
await popup.locator('.js-popup-minimize').click();
await expect(popup).not.toHaveClass(/pop-over--maximized/);
// Negative: with no card, no On this card column.
await expect(popup.locator('.card-settings-side-head-this')).toHaveCount(0);
// Negative: with no card, no On this card - neither as a checkbox in the
// list nor in a row's hamburger, which still has Show empty.
await expect(popup.locator('.js-card-add-field')).toHaveCount(0);
const menu = await openItemMenu(page, popup, 'dueDate');
await expect(menu.locator('.js-card-always-show[data-key="dueDate"]')).toHaveCount(1);
await expect(menu.locator('.js-card-add-field')).toHaveCount(0);
});
test('Board Settings / Card: headings aligned with their cells, labels whole', async ({ boardPage: page }) => {
// Was "headings aligned with their cells": the pane's column headings are
// gone with the pane, so what is left to pin is that every label of either
// Show side is whole inside the popup and nothing scrolls sideways.
test('Board Settings / Card: labels whole on both Show sides, no sideways scroll', async ({ boardPage: page }) => {
await page.setViewportSize({ width: 1400, height: 1000 });
await openBoardCardSettings(page);
const popup = settingsPopup(page);
await popup.locator('.js-card-settings-menu[data-id="date"]').click();
for (const side of ['minicard', 'card', 'always']) {
const head = await popup.locator(`.card-settings-side-head-${side}`).boundingBox();
const cell = await popup.locator(`.card-settings-line .card-settings-side-${side}`).first().boundingBox();
expect(Math.abs(head.x - cell.x)).toBeLessThan(2);
}
for (const list of ['card', 'minicard']) {
const scope = popup.locator(`.card-settings-list-${list}`);
const headLabel = await scope.locator('.card-settings-pane-head-label').boundingBox();
const rowLabel = await scope.locator('.card-settings-line-label').first().boundingBox();
expect(Math.abs(headLabel.x - rowLabel.x)).toBeLessThan(2);
}
const pane = await popup.locator('.main-body').boundingBox();
for (const label of await popup.locator('.card-settings-line-label').all()) {
const box = await label.boundingBox();
expect(box.x + box.width).toBeLessThanOrEqual(pane.x + pane.width + 1);
for (const side of ['card', 'minicard']) {
await popup.locator(`.js-card-settings-show-side[value="${side}"]`).check();
await expect(itemOf(popup, 'dueDate')).toHaveAttribute('data-side', side);
const box = await popup.locator('.js-card-settings-inline').boundingBox();
for (const label of await popup.locator('.card-settings-line-label').all()) {
const l = await label.boundingBox();
if (!l) continue; // a collapsed section's row
expect(l.x).toBeGreaterThanOrEqual(box.x - 1);
expect(l.x + l.width).toBeLessThanOrEqual(box.x + box.width + 1);
}
expect(await popup.locator('.content-wrapper').evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
}
});
test('Board Settings / Card and the opened card\'s Card Settings are the same template: same sections, rows and settings', async ({ boardPage: page, board }) => {
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsDueDate: true, allowsDueDateOnMinicard: false } });
// The popup: no title and X of its own - the popup's header is both.
await openBoardCardSettings(page);
const popup = settingsPopup(page);
await expect(popup.locator('.js-card-settings-inline')).toHaveCount(1);
await expect(popup.locator('.card-settings-inline-head')).toHaveCount(0);
await expect(popup.locator('.js-close-card-settings')).toHaveCount(0);
await expect(popup.locator('.js-card-settings-show-side[value="card"]')).toBeChecked();
// A checkbox ticked here is the board's: Due on the minicard.
await popup.locator('.js-card-settings-show-side[value="minicard"]').check();
const toggle = itemOf(popup, 'dueDate').locator('.card-field-order-toggle');
await expect(toggle).toHaveAttribute('aria-checked', 'false');
await toggle.click();
await expect.poll(() => db.findOne('boards', { _id: board.boardId }).allowsDueDateOnMinicard).toBe(true);
await expect(toggle).toHaveAttribute('aria-checked', 'true');
const fromPopup = {};
for (const side of ['card', 'minicard']) {
await popup.locator(`.js-card-settings-show-side[value="${side}"]`).check();
await expect(itemOf(popup, 'dueDate')).toHaveAttribute('data-side', side);
fromPopup[side] = await sectionsAndRows(popup);
}
await closePopup(page);
// The opened card: its own title and X, then the same sections and rows.
const details = await openCard(page, board);
const inline = await openCardSettings(details);
await expect(inline.locator('.card-settings-inline-head')).toHaveCount(1);
await expect(inline.locator('.js-close-card-settings')).toHaveCount(1);
const fromCard = {};
for (const side of ['card', 'minicard']) {
await inline.locator(`.js-card-settings-show-side[value="${side}"]`).check();
await expect(itemOf(inline, 'dueDate')).toHaveAttribute('data-side', side);
fromCard[side] = await sectionsAndRows(inline);
}
for (const side of ['card', 'minicard']) {
expect(fromPopup[side].length).toBeGreaterThan(5);
// Same sections, in the same order, with the same rows in the same order -
// apart from the card's own List title row, drawn only with a card.
expect(withoutCardOnly(fromCard[side])).toEqual(withoutCardOnly(fromPopup[side]));
}
// What was ticked in the popup reads back ticked on the card's Show on Minicard.
await expect(itemOf(inline, 'dueDate')).toHaveAttribute('data-side', 'minicard');
await expect(itemOf(inline, 'dueDate').locator('.card-field-order-toggle')).toHaveAttribute('aria-checked', 'true');
expect(db.findOne('boards', { _id: board.boardId }).allowsDueDate).toBe(true);
});
// Both open at once: the opened card's Card Settings and Board Settings / Card.
// A section hamburger acts on the Card Settings it was opened from (its
// data carries inPopup), and the board setting it writes reads back in both.
test('the card\'s Card Settings and Board Settings / Card open together: Select none in the popup is the board\'s', async ({ boardPage: page, board }) => {
db.updateOne('boards', { _id: board.boardId }, { $set: { allowsReceivedDate: true, allowsStartDate: true, allowsDueDate: true, allowsEndDate: true } });
const details = await openCard(page, board);
const inline = await openCardSettings(details);
await openBoardCardSettings(page);
const popup = settingsPopup(page);
const popupInline = popup.locator('.js-card-settings-inline');
// Two Card Settings, each with its own Show: neither radio group takes the
// other's choice.
await expect(inline.locator('.js-card-settings-show-side[value="card"]')).toBeChecked();
await expect(popupInline.locator('.js-card-settings-show-side[value="card"]')).toBeChecked();
const date = sectionOf(popupInline, 'date');
await expect(date.locator('.card-field-order-toggle[aria-checked="true"]')).not.toHaveCount(0);
const menu = await openSectionMenu(page, popupInline, 'date');
await menu.locator('.js-card-settings-inline-select[data-column="card"][data-value="none"]').click();
await expect.poll(() => {
const b = db.findOne('boards', { _id: board.boardId });
return [b.allowsReceivedDate, b.allowsStartDate, b.allowsDueDate, b.allowsEndDate];
}).toEqual([false, false, false, false]);
// Negative: another section is untouched.
expect(db.findOne('boards', { _id: board.boardId }).allowsMembers).not.toBe(false);
await closePopup(page);
// The card's view reads them unticked, and so does the popup's, reopened.
for (const key of ['receivedDate', 'startDate', 'dueDate', 'endDate']) {
await expect(itemOf(sectionOf(inline, 'date'), key).locator('.card-field-order-toggle')).toHaveAttribute('aria-checked', 'false');
}
await openBoardCardSettings(page);
for (const key of ['receivedDate', 'startDate', 'dueDate', 'endDate']) {
await expect(itemOf(sectionOf(popup, 'date'), key).locator('.card-field-order-toggle')).toHaveAttribute('aria-checked', 'false');
}
});
test('Board Settings / Card has no left menu and no pane any more (negative)', async ({ boardPage: page }) => {
await openBoardCardSettings(page);
const popup = settingsPopup(page);
await expect(popup.locator('.js-card-settings-section').first()).toBeVisible();
await expect(popup.locator('.js-card-settings-menu')).toHaveCount(0);
await expect(popup.locator('.card-settings-pane')).toHaveCount(0);
await expect(popup.locator('.card-settings-list-card, .card-settings-list-minicard')).toHaveCount(0);
await expect(popup.locator('.js-card-settings-select')).toHaveCount(0);
await expect(popup.locator('.board-card-settings, .side-menu')).toHaveCount(0);
});
// ── Dragging a row: by its handle, by its label, and only within its section ─
const setHandles = (userId, on) => db.updateOne('users', { _id: userId }, { $set: { 'profile.showDesktopDragHandles': on } });
const cardOrderOf = board => (db.findOne('boards', { _id: board.boardId }).cardFieldOrder || []).join(' ');
// Both ends on screen: the target scrolled to the top, the source under it.
async function dragTo(page, source, target) {
await target.evaluate(el => el.scrollIntoView({ block: 'start' }));
const from = await source.boundingBox();
const to = await target.boundingBox();
expect(to.y).toBeGreaterThanOrEqual(0);
expect(from.y + from.height).toBeLessThanOrEqual(page.viewportSize().height);
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
await page.mouse.down();
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + 8, { steps: 3 });
await page.mouse.move(to.x + to.width / 2, to.y + 2, { steps: 12 });
await page.mouse.up();
}
// A drop that must write nothing: given the time a write would take.
async function expectNoWrite(page, read, before) {
await page.waitForTimeout(1500);
expect(read()).toBe(before);
}
test('drag handles on: a row is dragged by its handle only, not by its label', async ({ boardPage: page, board, user }) => {
setHandles(user.id, true);
try {
await page.reload();
const details = await openCard(page, board);
const inline = await openCardSettings(details);
const date = sectionOf(inline, 'date');
const due = itemOf(date, 'dueDate');
await expect(due.locator('a.js-card-field-order-handle')).toBeVisible();
// The label is not a handle then: dragging it moves nothing.
await expect(due.locator('.card-settings-line-label.js-card-field-order-handle')).toHaveCount(0);
const before = cardOrderOf(board);
await dragTo(page, due.locator('.card-settings-line-label'), itemOf(date, 'receivedDate'));
await expectNoWrite(page, () => cardOrderOf(board), before);
// The handle does.
await dragTo(page, due.locator('a.js-card-field-order-handle'), itemOf(date, 'receivedDate'));
await expect.poll(() => {
const order = db.findOne('boards', { _id: board.boardId }).cardFieldOrder || [];
return order.indexOf('dueDate') !== -1 && order.indexOf('dueDate') < order.indexOf('receivedDate');
}).toBe(true);
} finally {
setHandles(user.id, false);
db.updateOne('boards', { _id: board.boardId }, { $unset: { cardFieldOrder: '' } });
}
});
test('drag handles off: the row\'s label is its handle', async ({ boardPage: page, board, user }) => {
setHandles(user.id, false);
try {
await page.reload();
const details = await openCard(page, board);
const inline = await openCardSettings(details);
const date = sectionOf(inline, 'date');
const due = itemOf(date, 'dueDate');
// Negative: no separate handle is drawn.
await expect(due.locator('a.js-card-field-order-handle')).toHaveCount(0);
await expect(due.locator('.card-settings-line-label.js-card-field-order-handle')).toHaveCount(1);
await dragTo(page, due.locator('.card-settings-line-label'), itemOf(date, 'receivedDate'));
await expect.poll(() => {
const order = db.findOne('boards', { _id: board.boardId }).cardFieldOrder || [];
return order.indexOf('dueDate') !== -1 && order.indexOf('dueDate') < order.indexOf('receivedDate');
}).toBe(true);
} finally {
db.updateOne('boards', { _id: board.boardId }, { $unset: { cardFieldOrder: '' } });
}
});
test('a row dropped on another section goes back to its own, and nothing is written', async ({ boardPage: page, board, user }) => {
setHandles(user.id, false);
try {
await page.reload();
const details = await openCard(page, board);
const inline = await openCardSettings(details);
const date = sectionOf(inline, 'date');
const members = sectionOf(inline, 'members');
const before = cardOrderOf(board);
// Creator (Members) dragged onto Due (Date), above it on screen.
await dragTo(page, itemOf(members, 'creator').locator('.card-settings-line-label'), itemOf(date, 'dueDate'));
await expectNoWrite(page, () => cardOrderOf(board), before);
await expect(itemOf(members, 'creator')).toHaveCount(1);
await expect(itemOf(date, 'creator')).toHaveCount(0);
// And a Date row onto the Card section above it.
const general = sectionOf(inline, 'card');
await dragTo(page, itemOf(date, 'endDate').locator('.card-settings-line-label'), itemOf(general, 'collapse'));
await expectNoWrite(page, () => cardOrderOf(board), before);
await expect(itemOf(date, 'endDate')).toHaveCount(1);
await expect(itemOf(general, 'endDate')).toHaveCount(0);
// A drop within its own section still writes: the drag itself works.
await dragTo(page, itemOf(date, 'dueDate').locator('.card-settings-line-label'), itemOf(date, 'receivedDate'));
await expect.poll(() => cardOrderOf(board)).not.toBe(before);
} finally {
db.updateOne('boards', { _id: board.boardId }, { $unset: { cardFieldOrder: '' } });
}
});
@@ -11,35 +11,43 @@ const CardPage = require('../pages/CardPage');
const { CARD_SETTINGS_ROWS } = require('../../../models/lib/cardSettingsRows');
const { SECTIONS } = require('../../../models/lib/cardSettingsSections');
test('both columns list every row of the table', async ({ boardPage: page }) => {
test('both Show sides list every row of the table', async ({ boardPage: page }) => {
await page.evaluate(() => {
Popup.close();
Popup.open('boardCardSettings')({ currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
Popup.open('boardCardSettings').call({ currentData: () => ({}) }, { currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
});
// The popup is a left menu of sections and a pane of the open section's rows
// (models/lib/cardSettingsSections.js, docs/Features/Cards/Card-Fields.md),
// so "every row" is every section's pane: each section lists its own rows,
// in both lists, and together they are the whole table.
const seen = { card: [], minicard: [] };
for (const section of SECTIONS) {
await page.locator(`.js-pop-over .js-card-settings-menu[data-id="${section.id}"]`).click();
for (const side of ['card', 'minicard']) {
// List title is the CARD's own row, only offered from a card's own menu;
const popup = page.locator('.js-pop-over[data-popup="boardCardSettingsPopup"]');
await expect(popup.locator('.js-card-settings-inline')).toBeVisible();
// Board Settings / Card is the opened card's Card Settings since 2026-10-28
// (cardSettingsInline): every section is a checklist of its rows, and the
// "Show" radio chooses which side - card or minicard - the rows are of. So
// "every row" is every section's checklist, once per Show side; each section
// lists its own rows, and together they are the whole table.
for (const side of ['card', 'minicard']) {
await popup.locator(`.js-card-settings-show-side[value="${side}"]`).check();
const seen = [];
for (const section of SECTIONS) {
// List title is the CARD's own row, only offered on an opened card;
// and a row with no minicard side (Drawing, Action items, attachment
// previews...) is listed only on the card side, as rowsForSide does.
const expected = section.keys.filter(key => {
const row = CARD_SETTINGS_ROWS.find(r => r.key === key);
return row && row[side] && !row[side].needsCard;
});
const rows = page.locator(`.js-pop-over .js-card-field-order-row[data-side="${side}"]`);
await expect.poll(async () => (await rows.evaluateAll(els => els.map(r => r.dataset.key))).sort(), { message: `${section.id} ${side}` })
.toEqual([...expected].sort());
seen[side].push(...expected);
const scope = popup.locator(`.js-card-settings-section[data-section="${section.id}"]`);
// A section with no row on this side is left out (Card, holding what is
// not a field, stays).
if (!expected.length && section.id !== 'card') {
await expect(scope).toHaveCount(0);
continue;
}
const rows = scope.locator('.js-card-field-order-row');
await expect.poll(async () => (await rows.evaluateAll(els => els.map(r => `${r.dataset.key}:${r.dataset.side}`))).sort(), { message: `${section.id} ${side}` })
.toEqual(expected.map(key => `${key}:${side}`).sort());
seen.push(...expected);
}
}
for (const side of ['card', 'minicard']) {
const expected = CARD_SETTINGS_ROWS.filter(row => row[side] && !row[side].needsCard).map(row => row.key);
expect(seen[side].sort()).toEqual(expected.sort());
expect(seen.sort()).toEqual(expected.sort());
}
});
+21 -11
View File
@@ -11,13 +11,22 @@ async function settings(page) {
await page.locator('.js-pop-over .js-open-board-card-settings').click();
return page.locator('.js-pop-over');
}
// Card Settings opens on its first section, Card (Collapse is there); a row of
// another section - Labels above title, Labels - is in that section's pane
// (models/lib/cardSettingsSections.js), so its menu entry is opened first.
// Board Settings / Card is the opened card's Card Settings since 2026-10-28
// (cardSettingsInline): every section at once, a row's checkbox being the
// side the "Show" radio chooses. A minicard toggle (Collapse, Labels above
// title) is only drawn with Show on Minicard, so the radio is set to the side
// whose row carries the toggle, inside that row's section
// (models/lib/cardSettingsSections.js).
async function toggle(page, selector, section = 'card') {
const popup = await settings(page);
if (section !== 'card') await popup.locator(`.js-card-settings-menu[data-id="${section}"]`).click();
await popup.locator(selector).click();
const scope = popup.locator(`.js-card-settings-section[data-section="${section}"]`);
await expect(scope).toBeVisible();
for (const side of ['card', 'minicard']) {
await popup.locator(`.js-card-settings-show-side[value="${side}"]`).check();
await expect(scope.locator('.js-card-field-order-row').first()).toHaveAttribute('data-side', side);
if (await scope.locator(selector).count()) break;
}
await scope.locator(selector).click();
await popup.locator('.js-close-pop-over').click();
}
@@ -132,11 +141,12 @@ test('clean boards: smaller font presets reduce checklist and settings spacing',
await expect(cp.root.locator('.checklist-title')).toHaveCSS('padding-top', '8px');
const popup = await settings(page);
// A section's rows (docs/Features/Cards/Card-Fields.md): one line each.
await popup.locator('.js-card-settings-menu[data-id="date"]').click();
await expect(popup.locator('.card-settings-line').first()).toHaveCSS('padding-top', '3.2px');
const date = popup.locator('.js-card-settings-section[data-section="date"]');
await expect(date.locator('.card-settings-line').first()).toHaveCSS('padding-top', '3.2px');
// Firefox rounds computed font sizes to layout units (9.59375px for 9.6px).
// The heading TEXT is the smaller font (sidebar.css), not its line: on the
// line, the em-sized columns shrank and the headings drifted off their cells.
await expect.poll(() => popup.locator('.card-settings-pane-head > span').first()
.evaluate(el => parseFloat(getComputedStyle(el).fontSize))).toBeCloseTo(9.6, 1);
// The pane's column headings (12px * 0.8) went with the pane (2026-10-28);
// the smaller font is still pinned on what heads the rows now, the
// section's title (16px * the 0.8 scale, client/components/cards/cardAddFields.css).
await expect.poll(() => date.locator('.card-settings-inline-section-title')
.evaluate(el => parseFloat(getComputedStyle(el).fontSize))).toBeCloseTo(12.8, 1);
});
@@ -86,11 +86,13 @@ test('Board Settings / Card: the attachment rows are in the Attachments section,
await page.locator('.js-toggle-page-sidebar').first().click();
await page.locator('.board-sidebar .js-open-board-menu').click();
await page.locator('.js-pop-over .js-open-board-card-settings').click();
const form = page.locator('.js-pop-over .board-card-settings');
// Board Settings / Card is the opened card's Card Settings since
// 2026-10-28 (cardSettingsInline): every section at once, Show on Card chosen.
const form = page.locator('.js-pop-over .js-card-settings-inline');
await form.waitFor();
await form.locator('.js-card-settings-menu[data-id="attachments"]').click();
const attachments = form.locator('.js-card-settings-section[data-section="attachments"]');
for (const [key, field] of [['attachmentPreviews', 'allowsAttachmentPreviewsOnCard'], ['attachmentDetails', 'allowsAttachmentDetailsOnCard']]) {
const cell = form.locator(`.js-card-field-order-row[data-key="${key}"][data-side="card"]`);
const cell = attachments.locator(`.js-card-field-order-row[data-key="${key}"][data-side="card"]`);
await expect(cell).toBeVisible();
await expect(cell.locator('.card-field-order-toggle')).toHaveClass(/is-checked/);
await cell.locator('.card-field-order-toggle').click();
@@ -110,10 +112,9 @@ test('Card Settings: the keyboard moves a field on its own side, one row of its
await page.locator('.js-toggle-page-sidebar').first().click();
await page.locator('.board-sidebar .js-open-board-menu').click();
await page.locator('.js-pop-over .js-open-board-card-settings').click();
const form = page.locator('.js-pop-over .board-card-settings');
const form = page.locator('.js-pop-over .js-card-settings-inline');
await form.waitFor();
await form.locator('.js-card-settings-menu[data-id="date"]').click();
const due = form.locator('.js-card-field-order-row[data-key="dueDate"][data-side="card"]');
const due = form.locator('.js-card-settings-section[data-section="date"]').locator('.js-card-field-order-row[data-key="dueDate"][data-side="card"]');
const before = cardOrder(board);
// Up on the focused name: one row up in the Show on Card list.
await due.locator('.card-settings-line-label').focus();
@@ -124,8 +125,7 @@ test('Card Settings: the keyboard moves a field on its own side, one row of its
// The minicard's order is not touched by the card's list.
expect(db.findOne('boards', { _id: board.boardId }).minicardFieldOrder).toBeFalsy();
// Negative: a modifier row is not dragged.
await form.locator('.js-card-settings-menu[data-id="checklists"]').click();
await expect(form.locator('.js-card-field-order-row[data-key="checklistDueDate"][data-side="card"] .js-card-field-order-handle')).toHaveCount(0);
await expect(form.locator('.js-card-settings-section[data-section="checklists"] .js-card-field-order-row[data-key="checklistDueDate"][data-side="card"] .js-card-field-order-handle')).toHaveCount(0);
} finally {
db.updateOne('boards', { _id: board.boardId }, { $unset: { cardFieldOrder: 1 } });
}
+26 -11
View File
@@ -104,7 +104,7 @@ test('Board Settings reveals Scrum minicard fields and preserves independent car
await expect(page.locator('.minicard .scrum-metadata')).toHaveCount(0);
// The issue type is a minicard badge on every board now, not a Scrum toggle.
await expect(page.locator('.minicard .minicard-issue-type',{hasText:'Story'})).toHaveCount(1);
await page.evaluate(()=>{Popup.close();const opener=document.body;Popup.open('boardCardSettings')({currentTarget:opener,target:opener,preventDefault(){},stopPropagation(){}});});
await page.evaluate(()=>{Popup.close();const opener=document.body;Popup.open('boardCardSettings').call({currentData:()=>({})},{currentTarget:opener,target:opener,preventDefault(){},stopPropagation(){}});});
const popup=page.locator('.pop-over[data-popup="boardCardSettingsPopup"]');
await expect(popup).toBeVisible();
// Every Scrum field is a row of the Scrum settings section (2026-10-25,
@@ -112,21 +112,36 @@ test('Board Settings reveals Scrum minicard fields and preserves independent car
// before it - the section's title says that once; the old separate Scrum
// checkbox list is gone.
await expect(popup.locator('input.js-scrum-visibility')).toHaveCount(0);
await popup.locator('.js-card-settings-menu[data-id="scrum"]').click();
// Each row is drawn twice since 2026-10-27, once in the Show on Card list and
// once in the Show on Minicard list (data-side), so a label is read per side.
// Board Settings / Card is the opened card's Card Settings since 2026-10-28:
// every section at once, its rows the side the "Show" radio chooses, so a
// label is read per side by switching Show.
const scrum=popup.locator('.js-card-settings-section[data-section="scrum"]');
// Switching Show changes every row's side and order in ONE render: the rows
// were once drawn in the old side's order and reordered a flush later, and a
// click in between hit the neighbouring row (Work item type instead of
// Backlog rank). Right after the radio is checked the rows already hold the
// new side and the order they keep.
const order=()=>scrum.locator('.js-card-field-order-row').evaluateAll(els=>els.map(el=>`${el.dataset.key}:${el.dataset.side}`));
const show=async side=>{
await popup.locator(`.js-card-settings-show-side[value="${side}"]`).check();
await expect(scrum.locator(`.js-card-field-order-row[data-side="${side}"]`).first()).toBeAttached();
const first=await order();
expect(first.every(k=>k.endsWith(`:${side}`))).toBe(true);
await page.waitForTimeout(300);
expect(await order()).toEqual(first);
};
for(const side of ['card','minicard']){
await expect(popup.locator(`.card-settings-line[data-side="${side}"][data-key="scrumBacklogRank"] .card-settings-line-label`)).toHaveText('Backlog rank');
await expect(popup.locator(`.card-settings-line[data-side="${side}"][data-key="scrumSprint"] .card-settings-line-label`)).toHaveText('Sprint');
await show(side);
await expect(scrum.locator(`.card-settings-line[data-side="${side}"][data-key="scrumBacklogRank"] .card-settings-line-label`)).toHaveText('Backlog rank');
await expect(scrum.locator(`.card-settings-line[data-side="${side}"][data-key="scrumSprint"] .card-settings-line-label`)).toHaveText('Sprint');
await expect(scrum.locator(`.js-card-field-order-row[data-side="${side}"][data-key="scrumBacklogRank"] .card-field-order-toggle`)).toHaveCount(1);
}
for(const side of ['card','minicard']){
await expect(popup.locator(`.js-card-field-order-row[data-side="${side}"][data-key="scrumBacklogRank"] .card-field-order-toggle`)).toHaveCount(1);
}
await popup.locator('.js-card-field-order-row[data-side="minicard"][data-key="scrumBacklogRank"] .card-field-order-toggle').click();
await show('minicard');
await scrum.locator('.js-card-field-order-row[data-side="minicard"][data-key="scrumBacklogRank"] .card-field-order-toggle').click();
await expect.poll(()=>db.findOne('boards',{_id:board.boardId}).scrum?.visibility?.minicardBacklogRank).toBe(true);
expect(db.findOne('boards',{_id:board.boardId}).scrum?.visibility?.cardBacklogRank).not.toBe(true);
// The minicard's work item type row is the badge's own switch, on by default.
const issueType=popup.locator('.js-card-field-order-row[data-side="minicard"][data-key="scrumIssueType"] .card-field-order-toggle');
const issueType=scrum.locator('.js-card-field-order-row[data-side="minicard"][data-key="scrumIssueType"] .card-field-order-toggle');
await expect(issueType).toHaveClass(/is-checked/);
await issueType.click();
await expect.poll(()=>db.findOne('boards',{_id:board.boardId}).allowsIssueTypeOnMinicard).toBe(false);
@@ -61,16 +61,24 @@ test('Southern Sotho card linking names the destination without claiming the cur
test('Southern Sotho card settings identify data fields', async ({ boardPage: page }) => {
await page.evaluate(async () => {
await Meteor.callAsync('setLanguage', 'st');
Popup.open('boardCardSettings')({ currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
Popup.open('boardCardSettings').call({ currentData: () => ({}) }, { currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
});
// The label column's heading of a section pane (docs/Features/Cards/Card-Fields.md).
// A pane has two lists since 2026-10-27, Show on Card and Show on Minicard;
// the heading text is in the card list's head, the minicard list's head
// keeps an empty cell so the columns line up.
await page.locator('.js-pop-over .js-card-settings-menu[data-id="date"]').click();
const heading = page.locator('.js-pop-over .card-settings-list[data-side="card"] .card-settings-pane-head-label');
await expect(heading).toHaveText('Tatellano ya dikarolo tsa karete');
await expect(heading).not.toContainText('masimo');
// Board Settings / Card is the opened card's Card Settings since 2026-10-28
// (cardSettingsInline): the pane's "Card field order" heading went with the
// pane. What names the card's data fields now is "Show" and its two sides,
// the sections' titles, and Card's hamburger's "Show empty fields" - each
// in Southern Sotho, and none calling a data field "masimo" (farm fields).
const popup = page.locator('.js-pop-over[data-popup="boardCardSettingsPopup"]');
await expect(popup.locator('.card-settings-inline-show-label')).toHaveText('Bontsha');
const sides = popup.locator('.card-settings-inline-show-option');
await expect(sides.nth(0)).toHaveText('Bontsha kareteng e nyenyane');
await expect(sides.nth(1)).toHaveText('Bontsha kareteng');
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 showEmpty = page.locator('.js-pop-over[data-popup="cardSettingsSectionPopup"] .js-show-empty-card-fields');
await expect(showEmpty).toBeVisible();
await expect(showEmpty).not.toContainText('masimo');
await page.evaluate(() => Popup.close());
});
@@ -2,7 +2,8 @@
// Board Settings / Board View and Board Settings / Card reorder by drag and
// drop (2026-10-02; Card's arrows of 2026-10-25 back to drag and drop on
// 2026-10-27, a Show on Card and a Show on Minicard list per section). With
// 2026-10-27; since 2026-10-28 the opened card's Card Settings, a "Show"
// radio choosing the card's or the minicard's side of each section). With
// drag handles on, the handle is what is dragged, else the row's icon and
// label. The Up and Down keys on the focused handle or label move a row one
// step in its list.
@@ -30,28 +31,35 @@ async function dragOnto(page, source, target) {
await page.mouse.up();
}
test('Board Settings / Card reorders each list by drag and drop and by the arrow keys', async ({ boardPage: page, board, user }) => {
test('Board Settings / Card reorders each side by drag and drop and by the arrow keys', async ({ boardPage: page, board, user }) => {
try {
setHandles(user.id, false);
await openPopup(page, 'boardCardSettings');
await page.locator('.js-card-settings-menu[data-id="date"]').click();
const row = (key, side = 'card') => page.locator(`.card-settings-list-${side} .js-card-field-order-row[data-key="${key}"]`);
await page.reload();
await page.evaluate(() => {
Popup.close();
Popup.open('boardCardSettings').call({ currentData: () => ({}) }, { currentTarget: document.body, target: document.body, preventDefault() {}, stopPropagation() {} });
});
// Board Settings / Card is the opened card's Card Settings since
// 2026-10-28 (cardSettingsInline): each section a checklist, its rows the
// side the "Show" radio chooses, dragged within their section.
const popup = page.locator('.js-pop-over[data-popup="boardCardSettingsPopup"]');
const section = id => popup.locator(`.js-card-settings-section[data-section="${id}"]`);
const row = key => popup.locator(`.js-card-field-order-row[data-key="${key}"]`);
const show = side => popup.locator(`.js-card-settings-show-side[value="${side}"]`).check();
await expect(row('dueDate')).toBeVisible();
// Negative: the head rows sit at a fixed place, so they are not dragged.
await page.locator('.js-card-settings-menu[data-id="card"]').click();
await expect(row('cardNumber').locator('.js-card-field-order-handle')).toHaveCount(0);
await page.locator('.js-card-settings-menu[data-id="date"]').click();
// Drag Due by its name onto Received: first in the list, and in the card's order.
await expect(section('card').locator('.js-card-field-order-row[data-key="cardNumber"] .js-card-field-order-handle')).toHaveCount(0);
// Drag Due by its name onto Received: first in the section, and in the card's order.
await dragOnto(page, row('dueDate').locator('.card-settings-line-label'), row('receivedDate'));
await expect.poll(() => {
const order = db.findOne('boards', { _id: board.boardId }).cardFieldOrder || [];
return order.indexOf('dueDate') < order.indexOf('receivedDate');
return order.indexOf('dueDate') !== -1 && order.indexOf('dueDate') < order.indexOf('receivedDate');
}).toBe(true);
// First of the rows that HAVE a place: the Date section's card list opens
// with Mark as complete, a head row of the card's title bar drawn at a
// fixed place (models/lib/cardFieldOrder.js), never moved by the order.
await expect(page.locator('.card-settings-list-card .js-card-field-order-row.is-positioned').first()).toHaveAttribute('data-key', 'dueDate');
// The minicard's list is its own order: untouched.
// First of the rows that HAVE a place: the Date section opens with Mark as
// complete, a head row of the card's title bar drawn at a fixed place
// (models/lib/cardFieldOrder.js), never moved by the order.
await expect(section('date').locator('.js-card-field-order-row.is-positioned').first()).toHaveAttribute('data-key', 'dueDate');
// The minicard's order is its own: untouched.
expect(db.findOne('boards', { _id: board.boardId }).minicardFieldOrder).toBeFalsy();
// The keyboard: Down on the focused name moves the row one row back.
await row('dueDate').locator('.card-settings-line-label').focus();
@@ -60,13 +68,15 @@ test('Board Settings / Card reorders each list by drag and drop and by the arrow
const order = db.findOne('boards', { _id: board.boardId }).cardFieldOrder || [];
return order.indexOf('dueDate') > order.indexOf('receivedDate');
}).toBe(true);
// The minicard's list, by the handle when drag handles are on.
// The minicard's order, by the handle when drag handles are on.
setHandles(user.id, true);
await expect(row('endDate', 'minicard').locator('a.js-card-field-order-handle')).toBeVisible();
await dragOnto(page, row('endDate', 'minicard').locator('a.js-card-field-order-handle'), row('receivedDate', 'minicard'));
await show('minicard');
await expect(row('endDate')).toHaveAttribute('data-side', 'minicard');
await expect(row('endDate').locator('a.js-card-field-order-handle')).toBeVisible();
await dragOnto(page, row('endDate').locator('a.js-card-field-order-handle'), row('receivedDate'));
await expect.poll(() => {
const order = db.findOne('boards', { _id: board.boardId }).minicardFieldOrder || [];
return order.indexOf('endDate') < order.indexOf('receivedDate');
return order.indexOf('endDate') !== -1 && order.indexOf('endDate') < order.indexOf('receivedDate');
}).toBe(true);
// Negative: the arrows are gone.
await expect(page.locator('.js-card-field-order-up, .js-card-field-order-down')).toHaveCount(0);