Card Settings: a Show on Card and a Show on Minicard list per section, reordered by drag and drop again.

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, and the
number beside an arrow was the field's place among ALL of that side's
fields: an arrow often changed the number and nothing visibly moved,
the minicard's arrows never moved a line, and an arrow that did nothing
was not disabled. Drag and drop, which the arrows had replaced, had been
there since 2026-10-02.

Each section has the two lists again, each in its own order and
reordered by drag and drop: Show on Card - with On this card and Show
empty beside each row - and Show on Minicard. A row is dragged by its
handle (drag handles on) or its icon or name; Up and Down on a focused
one move it one row in the list. A dropped row goes right after the row
now above it in the side's whole order, through placeKey, so a pinned
header stays first and a section moves as one. The arrows and their
numbers are gone.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-10-27 10:05:01 +02:00
1 parent 8d61402f5b
commit 8224852de1
13 files changed
+434 -303

No files matched your search

+35 -28
View File
@@ -171,12 +171,13 @@
.card-settings-pane-head,
.card-settings-line {
display: grid;
/* Show on Minicard, Show on Card, Show empty (#6806), the field;
opened from a card, On this card first. 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 9em 8em minmax(10em, 1fr);
/* 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,
@@ -184,9 +185,25 @@
justify-items: start;
text-align: start;
}
.board-card-settings.has-card .card-settings-pane-head,
.board-card-settings.has-card .card-settings-line {
grid-template-columns: 7em 9em 9em 8em minmax(10em, 1fr);
.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;
}
/* What a row is dragged by: its handle (drag handles on), its icon or name. */
.card-field-order-drag-label {
cursor: grab;
}
.card-settings-line.ui-sortable-helper {
background: #fff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.card-settings-pane-head {
font-weight: bold;
@@ -239,12 +256,6 @@
.card-settings-select button {
white-space: nowrap;
}
.card-settings-position {
min-width: 1.6em;
text-align: end;
color: #888;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
}
.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. */
@@ -282,24 +293,20 @@
.board-card-settings.show-empty-fields .card-settings-side-head-always {
display: none;
}
.board-card-settings.show-empty-fields .card-settings-pane-head,
.board-card-settings.show-empty-fields .card-settings-line {
grid-template-columns: 9em 9em minmax(10em, 1fr);
.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-pane-head,
.board-card-settings.show-empty-fields.has-card .card-settings-line {
grid-template-columns: 7em 9em 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 cells, so there is still ONE set of rows and one set of handlers. */
.board-card-settings.show-card-only .card-settings-side-minicard,
.board-card-settings.show-card-only .card-settings-side-head-minicard,
.board-card-settings.show-minicard-only .card-settings-side-card,
.board-card-settings.show-minicard-only .card-settings-side-head-card,
.board-card-settings.show-minicard-only .card-settings-side-always,
.board-card-settings.show-minicard-only .card-settings-side-head-always {
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;
}
+90 -76
View File
@@ -348,84 +348,98 @@ template(name="boardCardSettingsPopup")
a.js-card-settings-action(data-action="{{action.id}}")
i.fa(class=action.icon aria-hidden="true")
| {{action.title}}
//- Reading order: On this card (opened from a card), Show on
Minicard, Show on Card, then the field - mirrored in a
right-to-left language by the grid itself.
//- Under each heading, Select all / Select none for that column's
rows of this section - for who may change what it sets.
.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-minicard
span {{_ 'show-on-minicard'}}
if canModifyBoard
+cardSettingsSelectButtons(column="minicard")
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 sectionSettingsRows
.card-settings-line(class="{{#if row.personal}}card-settings-row-personal{{/if}}" data-key="{{row.key}}")
//- 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).
//- 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
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
each cell in row.sides
if cell.present
span.card-settings-side.js-card-field-order-row(class="card-settings-side-{{cell.side}} {{#if cell.personal}}card-settings-row-personal{{/if}}" data-key="{{row.key}}" data-side="{{cell.side}}")
//- The same animated checkbox as On this card; only the
one just clicked animates.
a.flex.card-field-order-toggle(class="{{#if cell.writable}}{{cell.toggle}}{{else}}is-disabled{{/if}} {{#if cell.checked}}is-checked{{/if}} {{#if cell.animating}}is-animating{{/if}}" title="{{row.title}}" role="checkbox" aria-checked="{{#if cell.checked}}true{{else}}false{{/if}}")
.materialCheckBox(class="{{#if cell.checked}}is-checked{{/if}}")
//- The order of this side: up and down, as the Up and
Down keys do on a focused arrow. A row that only
modifies another (`after`) has no place of its own.
if canModifyBoard
if cell.positioned
span.card-settings-position {{cell.position}}
a.card-field-order-move.js-card-field-order-up(href="#" role="button" title="{{_ 'card-field-order-move-up'}}" aria-label="{{_ 'card-field-order-move-up'}}")
i.fa.fa-arrow-up
a.card-field-order-move.js-card-field-order-down(href="#" role="button" title="{{_ 'card-field-order-move-down'}}" aria-label="{{_ 'card-field-order-move-down'}}")
i.fa.fa-arrow-down
else
span.card-settings-side.card-settings-side-empty(class="card-settings-side-{{cell.side}}")
//- #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
span.card-settings-line-label
each icon in row.icons
i.fa(class=icon aria-hidden="true")
| {{row.title}}
//- The user's own override of the board's "Labels text"
default, tri-state: following the board or overriding it,
with a way back to following it.
if row.labelTextOverride
if row.labelTextOverridden
|  
a(class=row.labelTextResetClass title="{{_ 'label-text-use-board-default'}}")
| ({{_ 'label-text-overridden'}})
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
|  ({{_ 'label-text-follows-board'}})
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)
//- One side's checkbox of a row of Card Settings: the board's Show on Card or
Show on Minicard, through the handler the row names (cardSettingsRows.js).
The same animated checkbox as On this card; only the one clicked animates.
template(name="cardSettingsSideToggle")
span.card-settings-side(class="card-settings-side-{{row.side}}")
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}}" title="{{row.title}}" role="checkbox" aria-checked="{{#if row.checked}}true{{else}}false{{/if}}")
.materialCheckBox(class="{{#if row.checked}}is-checked{{/if}}")
//- 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.
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}}")
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'}}")
i.fa.fa-arrows
each icon in row.icons
i.fa(class=icon aria-hidden="true")
| {{row.title}}
//- The user's own override of the board's "Labels text" default,
tri-state: following the board or overriding it, with a way back to
following it.
if row.labelTextOverride
if row.labelTextOverridden
|  
a(class=row.labelTextResetClass title="{{_ 'label-text-use-board-default'}}")
| ({{_ 'label-text-overridden'}})
else
|  ({{_ 'label-text-follows-board'}})
//- Select all / Select none of one column of Card Settings' pane
+105 -73
View File
@@ -17,7 +17,6 @@ const {
CARD_LAYOUT,
MINICARD_LAYOUT,
applyLayoutOrder,
moveKey,
placeKey,
isMovableKey,
} = require('/models/lib/cardFieldOrder');
@@ -2126,14 +2125,44 @@ function settingsMenuOf(data) {
return settingsSectionsFor(data).map(s => ({ id: s.id, icon: s.icon, labelKey: s.labelKey }));
}
// The position of `key` among a side's fields, 1-based, as the order arrows
// move it (models/lib/cardFieldOrder.js).
function sidePosition(board, side, key) {
const layout = side === 'card' ? CARD_LAYOUT : MINICARD_LAYOUT;
const order = applyLayoutOrder(side === 'card' ? board?.cardFieldOrder : board?.minicardFieldOrder, layout)
.filter(k => isMovableKey(layout, k));
const at = order.indexOf(key);
return at === -1 ? '' : String(at + 1);
function sectionSideRows(tpl, data, side) {
const id = openSettingsSectionOf(tpl, data);
const section = cardSettingsSections.SECTIONS.find(s => s.id === id);
const keys = new Set(section ? section.keys : []);
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
const thisCard = settingsCard();
const counts = thisCard ? cardFieldCounts(thisCard) : {};
const writable = Boolean(board && Utils.canModifyBoard && Utils.canModifyBoard());
const animatingKey = tpl.animating.get();
const showDragHandles = Utils.showDragHandles();
const rowOf = key => CARD_SETTINGS_ROWS.find(r => r.key === key);
return buildCardSettingsRows(side, data)
.filter(built => keys.has(built.key))
.map(built => ({
key: built.key,
side,
icons: built.icons,
title: cardSettingsSections.labelInSection(rowOf(built.key), section).map(k => TAPi18n.__(k)).join(rowOf(built.key).labelSeparator || ' '),
toggle: built.toggle,
checked: built.checked,
personal: Boolean(built.personal),
// The board's rows for a board admin; a personal row for its owner.
writable: writable || Boolean(built.personal),
animating: animatingKey === `${built.key}:${side}`,
positioned: built.positioned,
// Dragged: a row with a place of its own, by a board admin.
draggable: writable && built.positioned,
showDragHandles,
labelTextOverride: built.labelTextOverride,
labelTextOverridden: built.labelTextOverridden,
labelTextResetClass: built.labelTextResetClass,
perCard: side === 'card' && Boolean(thisCard) && cardShownFields.FIELD_KEYS.includes(built.key),
shownOnThisCard: side === 'card' && Boolean(thisCard) && cardShownFields.isShown(built.key, thisCard, counts, board),
// #6806: the board shows this field on every card, empty or not.
alwaysShowable: side === 'card' && cardShownFields.FIELD_KEYS.includes(built.key),
alwaysShown: cardShownFields.boardAlwaysShows(board, built.key),
animatingAlways: animatingKey === `${built.key}:always`,
}));
}
const boardCardSettingsHelpers = {
@@ -2183,55 +2212,17 @@ const boardCardSettingsHelpers = {
title: action.label.map(k => TAPi18n.__(k)).join(action.labelSeparator || ' '),
}));
},
// The open section's rows: one line each, with its minicard and card cell
// (from the same builder the two lists used, so the checkbox, its handler
// class and its default are what they were), in the board's card order.
sectionSettingsRows() {
const data = this;
const id = openSettingsSectionOf(Template.instance(), data);
const section = cardSettingsSections.SECTIONS.find(s => s.id === id);
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
const bySide = side => new Map(buildCardSettingsRows(side, data).map(r => [r.key, r]));
const minicard = bySide('minicard');
const card = bySide('card');
const cardOrder = applyLayoutOrder(board?.cardFieldOrder, CARD_LAYOUT);
const thisCard = settingsCard();
const counts = thisCard ? cardFieldCounts(thisCard) : {};
const writable = Boolean(board && Utils.canModifyBoard && Utils.canModifyBoard());
const animatingKey = Template.instance().animating.get();
const cellOf = (side, built) => (built ? {
present: true, side, toggle: built.toggle, checked: built.checked, positioned: built.positioned,
personal: built.personal, writable: writable || built.personal,
animating: animatingKey === `${built.key}:${side}`,
position: built.positioned ? sidePosition(board, side, built.key) : '',
} : { present: false, side });
return cardSettingsSections.rowsOfSection(id)
.filter(row => minicard.has(row.key) || card.has(row.key))
.sort((a, b) => {
const ia = cardOrder.indexOf(a.key);
const ib = cardOrder.indexOf(b.key);
return (ia === -1 ? 1e9 : ia) - (ib === -1 ? 1e9 : ib);
})
.map(row => {
const built = card.get(row.key) || minicard.get(row.key);
return {
key: row.key,
icons: row.icons,
title: cardSettingsSections.labelInSection(row, section).map(k => TAPi18n.__(k)).join(row.labelSeparator || ' '),
personal: Boolean(built.personal),
labelTextOverride: built.labelTextOverride,
labelTextOverridden: built.labelTextOverridden,
labelTextResetClass: built.labelTextResetClass,
sides: [cellOf('minicard', minicard.get(row.key)), cellOf('card', card.get(row.key))],
perCard: Boolean(thisCard) && cardShownFields.FIELD_KEYS.includes(row.key),
shownOnThisCard: Boolean(thisCard) && cardShownFields.isShown(row.key, thisCard, counts, board),
// #6806: the board shows this field on every card, empty or not.
alwaysShowable: cardShownFields.FIELD_KEYS.includes(row.key),
alwaysShown: cardShownFields.boardAlwaysShows(board, row.key),
animatingAlways: animatingKey === `${row.key}:always`,
};
});
},
// 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; },
@@ -2717,20 +2708,60 @@ Template.boardCardSettingsPopup.helpers(boardCardSettingsHelpers);
// click rather than taken from the row, so two quick clicks each move from
// where the previous one actually left it; and the move goes through the
// board's setter, which normalises it. models/lib/cardFieldOrder.js
// Each list of Card Settings - Show on Card, Show on Minicard - reorders by
// drag and drop. A list shows one section's rows, so where the row was
// dropped is turned into a place among ALL the side's fields that have one:
// right after the row now above it (or right before the row now below it, at
// the top). placeKey walks the same steps one at a time, so the layout's rules
// - a pinned header first, a section moving as one - still hold
// (models/lib/cardFieldOrder.js), and the field lands as near as they allow.
function dropTargetIndex(stored, layout, key, listKeys) {
const movable = applyLayoutOrder(stored, layout).filter(k => isMovableKey(layout, k) && k !== key);
const keys = listKeys.filter(k => isMovableKey(layout, k));
const at = keys.indexOf(key);
if (at > 0) return movable.indexOf(keys[at - 1]) + 1;
if (keys.length > 1) return movable.indexOf(keys[1]);
return -1;
}
// `keys`: the list's rows in their new order, `key` the one that moved.
function placeInListOrder(side, key, keys) {
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
if (!board || !(Utils.canModifyBoard && Utils.canModifyBoard())) return;
const layout = side === 'minicard' ? MINICARD_LAYOUT : CARD_LAYOUT;
const stored = side === 'minicard' ? board.minicardFieldOrder : board.cardFieldOrder;
const target = dropTargetIndex(stored, layout, key, keys);
if (target < 0) return;
const order = placeKey(stored, key, target, layout);
if (order.join(' ') === applyLayoutOrder(stored, layout).join(' ')) return;
if (side === 'minicard') board.setMinicardFieldOrder(order);
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) {
evt.preventDefault();
if (evt.currentTarget.classList.contains('is-disabled')) return;
const rowEl = evt.currentTarget.closest('.js-card-field-order-row');
if (!rowEl) return;
const { key, side } = rowEl.dataset;
const boardId = Session.get('currentBoard');
const currentBoard = ReactiveCache.getBoard(boardId);
if (!currentBoard || !key) return;
if (side === 'minicard') {
currentBoard.setMinicardFieldOrder(moveKey(currentBoard.minicardFieldOrder, key, direction, MINICARD_LAYOUT));
} else {
currentBoard.setCardFieldOrder(moveKey(currentBoard.cardFieldOrder, key, direction, CARD_LAYOUT));
}
const list = rowEl && rowEl.closest('.js-card-field-order-sortable');
if (!rowEl || !list || !rowEl.classList.contains('is-positioned')) return;
const keys = [...list.querySelectorAll(':scope > .js-card-field-order-row.is-positioned')].map(el => el.dataset.key);
const at = keys.indexOf(rowEl.dataset.key);
const to = direction === 'up' ? at - 1 : at + 1;
if (at < 0 || to < 0 || to >= keys.length) return;
keys.splice(at, 1);
keys.splice(to, 0, rowEl.dataset.key);
placeInListOrder(list.dataset.side, rowEl.dataset.key, keys);
}
Template.boardCardSettingsPopup.events({
@@ -2747,7 +2778,8 @@ Template.boardCardSettingsPopup.events({
}
},
'click .js-card-field-order-up'(evt) { moveCardSettingsRow(evt, 'up'); },
// A row is dragged by its handle or its name: a click on them does nothing.
'click .js-card-field-order-handle'(evt) { evt.preventDefault(); },
// A card action of the open section, for the card the popup was opened from.
'click .js-card-settings-action'(evt) {
evt.preventDefault();
@@ -2772,7 +2804,6 @@ Template.boardCardSettingsPopup.events({
const cell = evt.currentTarget.closest('.js-card-field-order-row');
if (cell) tpl.animating.set(`${cell.dataset.key}:${cell.dataset.side}`);
},
'click .js-card-field-order-down'(evt) { moveCardSettingsRow(evt, 'down'); },
'click .js-card-settings-menu'(evt, tpl) {
evt.preventDefault();
tpl.animating.set(null);
@@ -2878,8 +2909,9 @@ Template.boardCardSettingsPopup.events({
console.error(error);
}
},
// The keyboard: Up and Down on a focused order arrow move the row one step.
'keydown .js-card-field-order-up, keydown .js-card-field-order-down'(evt) {
// The keyboard way to drag: Up and Down on a focused handle or name move
// the row one step in its list's order.
'keydown .js-card-field-order-handle'(evt) {
const direction = ARROW_KEYS[evt.key];
if (direction) moveCardSettingsRow(evt, direction);
},
@@ -14,9 +14,9 @@ card** checkbox beside each field
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 row is one line with its **Show on
Minicard** and **Show on Card** checkbox, its position on each side and the
**up and down arrows** that move it there. The first menu entry, **Card**,
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
@@ -28,15 +28,22 @@ its pane ([Card fields](../../Cards/Card-Fields.md)).
an opened card. The left menu lists the sections; the right pane shows the
open section's rows:
| Label | Show on Minicard | Show on Card | Show empty | On this card |
| --- | --- | --- | --- | --- |
| the field, with the icon and name the card uses | checkbox, position, ▲ ▼ | checkbox, position, ▲ ▼ | checkbox: every card shows the field, empty or not - by default the description, attachments and comments ([#6806](https://github.com/wekan/wekan/issues/6806)) | opened from a card only |
**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 |
| part | what it does |
| --- | --- |
| checkbox | whether that side shows the field at all - the same board toggles as before (`allowsLabels`, `allowsLabelsOnMinicard`, ...) |
| position | where the field is on that side, counted among the fields that move |
| ▲ ▼ | move the field earlier or later **on that side only** |
| 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 |
Under each column's heading, **Select all** and **Select none** set that
column for every row of the open section at once.
@@ -50,13 +57,15 @@ the pane, as it does in the Admin Panel.
1. Open a board you are an admin of, click the sidebar's hamburger menu,
then **Board Settings → Card** - or click **+ Add** on an opened card.
2. Pick a section in the left menu.
3. In a row's **Show on Card** cell, click ▲ or ▼ to move the field on the
opened card; in its **Show on Minicard** cell, to move it on the minicard.
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.
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) has no arrows on that side.
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).
## What moves, and how
@@ -83,21 +92,22 @@ Subtasks, Checklist count, Sort number), Description text, the comment
preview, List name, Swimlane name. Inside the dates line and the badge strip
the fields reorder among themselves too.
The arrows follow three rules:
Moving a field follows three rules:
1. **A field moves within its section.** ▼ on *Start* puts it after *Due*.
2. **At the edge of its section, a field moves the whole section.** ▲ on
the first row of Dates lifts the Dates section above the section before
it; ▼ on its last row drops it below the next one.
1. **A field moves within its section.** *Start* dropped below *Due* goes
after it.
2. **Leaving its section, a field takes the whole section along.** The
first row of Dates dragged up lifts the Dates section above the section
before it.
3. **A section's header stays first.** Labels, Members, Sort number and
Description title head their sections (the fold caret is theirs), so
their arrows always move the section, and no other field goes above
dragging them always moves the section, and no other field goes above
them.
A few minicard rows have no element of their own there - *Labels text* (how
labels are drawn), *List title* (the per-card switch under *Show lists*),
*Requested by*, *Assigned by*, *Description title*, *Attachments* - so they
sit under the row they modify with their arrows disabled.
sit under the row they modify and are not dragged.
## The default order
@@ -146,8 +156,8 @@ All are on by default except the minicard's forecast.
## Prerequisites
- Only a board admin can change these settings; the arrows are shown only
to an admin. Who may write them is decided where it is for every other
- Only a board admin can change these settings; only an admin can drag a
row. Who may write them is decided where it is for every other
board setting: `Boards.allow`'s update rule in
`server/permissions/boards.js`.
- A board that never touched the order shows exactly what it always has.
+8 -8
View File
@@ -47,12 +47,12 @@ right.
own rows. Then Date, Time, Members,
Labels, Description, Checklists, Subtasks, Attachments, Custom Fields,
Voting, Planning Poker, Scrum settings and Comments.
- The **right pane** lists every row of the open section, one line each: its
icon and name, then a **Show on Minicard** cell and a **Show on Card** cell -
each the board-wide checkbox of that side, the field's position on that side
and its **up and down arrows** (the Up and Down keys on a focused arrow do the
same; a row that only modifies another has none).
- A **Show empty** cell, for every per-card field: ticked, every card of the
- 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
([#6806](https://github.com/wekan/wekan/issues/6806)). By default the
description, attachments and comments are ticked.
@@ -111,7 +111,7 @@ empty fields brings the columns back as they were. It is saved to the board
(`showEmptyCardFields`), for a board admin.
**+ Add** is shown to whoever may change the card. Its board-wide checkboxes
and arrows work for a board admin; for anybody else they are shown disabled,
and dragging work for a board admin; for anybody else they are shown disabled,
and only **On this card** and their own rows (Labels text, List title) change.
Below **+ Add** 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.
@@ -206,7 +206,7 @@ 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.js` | Its sections, rows, checkboxes, order arrows and On this card |
| `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/cards/cardAddFields.js` | "+ Add", and the card's own `currentBoard` |
| `client/components/cards/cardDetails.jade` | The "+ Add" button under the cover, the section hamburgers |
+13 -15
View File
@@ -252,26 +252,24 @@ test('Board Settings / Card is the Admin Panel left menu and pane, with Show on
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 now, with Select all / Select none under
// it (tests/cardSettingsSelectAllAligned.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 && minicard < card, 'Show on Minicard, then Show on Card');
// 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(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 cells', () => {
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-side-minicard,\n'
+ '.board-card-settings.show-card-only .card-settings-side-head-minicard,\n'
+ '.board-card-settings.show-minicard-only .card-settings-side-card,\n'
+ '.board-card-settings.show-minicard-only .card-settings-side-head-card,\n'
// #6806: Show empty is about the opened card, so the minicard's
// own view hides it as well.
+ '.board-card-settings.show-minicard-only .card-settings-side-always,\n'
+ '.board-card-settings.show-minicard-only .card-settings-side-head-always {'),
'show-card-only hides the minicard cells and show-minicard-only the card cells');
'.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');
});
console.log(`\nboardSettingsSwimlaneListCard: ${passed} tests passed`);
+9 -6
View File
@@ -127,10 +127,11 @@ test('Board Settings / Card and + Add: a Show empty column, for every per-card f
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 js = read('client/components/sidebar/sidebar.js');
assert.match(js, /alwaysShowable: cardShownFields\.FIELD_KEYS\.includes\(row\.key\),/, 'the default four too');
assert.match(js, /alwaysShown: cardShownFields\.boardAlwaysShows\(board, row\.key\),/);
// 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\),/);
assert.match(js, /'click \.js-card-always-show'\(evt, tpl\) \{[\s\S]{0,400}if \(!\(Utils\.canModifyBoard && Utils\.canModifyBoard\(\)\)\) return;[\s\S]{0,200}board\.setCardFieldAlwaysShown\(key, !cardShownFields\.boardAlwaysShows\(board, key\)\)/);
assert.match(js, /cardShownFields\.isShown\(row\.key, thisCard, counts, board\)/);
assert.match(js, /cardShownFields\.isShown\(built\.key, thisCard, counts, board\)/);
assert.match(js, /cardShownFields\.cardFieldsAfter\(card, key, !cardShownFields\.isShown\(key, card, counts, board\), counts, board\)/);
const add = read('client/components/cards/cardAddFields.js');
assert.match(add, /f\.isShown\(key, card, cardFieldCounts\(card\), Utils\.getCurrentBoard\(\)\)/);
@@ -138,7 +139,8 @@ 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\)\)/);
assert.match(read('client/components/sidebar/sidebar.css'), /\.board-card-settings\.show-minicard-only \.card-settings-side-always,\n\.board-card-settings\.show-minicard-only \.card-settings-side-head-always \{/);
// 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 \{/);
});
test('negative: every caller of isShown and cardFieldsAfter passes the board', () => {
@@ -207,8 +209,9 @@ test('Show empty fields: saved to the board, under the Card pane\'s title; on, t
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.
assert.match(css, /\.board-card-settings\.show-empty-fields \.card-settings-line \{\n\s+grid-template-columns: 9em 9em minmax\(10em, 1fr\);/);
assert.match(css, /\.board-card-settings\.show-empty-fields\.has-card \.card-settings-line \{\n\s+grid-template-columns: 7em 9em 9em minmax\(10em, 1fr\);/);
// (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.
assert.ok(!/setShowEmptyCardFields[\s\S]{0,300}cardFieldsAlwaysShown/.test(boards));
});
+94 -49
View File
@@ -215,33 +215,41 @@ test('every row has a checkbox helper, a click handler, existing label keys and
}
});
// ── the popup: sections on the left, one line per row on the right ─────────
// docs/Features/Cards/Card-Fields.md (2026-10-25): Board Settings / Card is the
// Admin Panel's left menu and right pane, the same popup the opened card's
// "+ Add" opens. It used to be one "Card field order" heading over two long
// lists reordered by drag and drop; a section shows a few rows at a time, so
// each row has the order ARROWS of each side instead (the maintainer's choice),
// and the Up and Down keys on a focused arrow do the same.
// ── 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.
test('the sections are the Admin Panel left menu, the rows a line each with a minicard and a card cell', () => {
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, /each row in sectionSettingsRows/);
assert.match(popup, /each cell in row\.sides/);
// The cell is what the handlers read: data-key and data-side on the row class.
assert.match(popup, /span\.card-settings-side\.js-card-field-order-row\(class="card-settings-side-\{\{cell\.side\}\}[^"]*" data-key="\{\{row\.key\}\}" data-side="\{\{cell\.side\}\}"\)/);
assert.match(popup, /a\.flex\.card-field-order-toggle\(class="\{\{#if cell\.writable\}\}\{\{cell\.toggle\}\}\{\{else\}\}is-disabled\{\{\/if\}\} \{\{#if cell\.checked\}\}is-checked\{\{\/if\}\} \{\{#if cell\.animating\}\}is-animating\{\{\/if\}\}"/);
// The same animated checkbox as "On this card".
assert.match(popup, /a\.flex\.card-field-order-toggle\([^\n]*\n\s+\.materialCheckBox\(class="\{\{#if cell\.checked\}\}is-checked\{\{\/if\}\}"\)/);
// Reading order: On this card, Show on Minicard, Show on Card, the field.
const order = ['card-settings-side-head-this', 'card-settings-side-head-minicard', 'card-settings-side-head-card', 'card-settings-pane-head-label'].map(c => popup.indexOf(c));
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/);
// 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);
assert.ok(popup.indexOf('each cell in row.sides') < popup.indexOf('span.card-settings-line-label'), 'the cells come before the field');
assert.ok(popup.indexOf("{{_ 'show-on-minicard'}}") < popup.indexOf("{{_ 'show-on-card'}}"), 'Show on Minicard reads before Show on Card');
// Both sides come from the one builder the two lists used.
assert.match(sidebarJs, /const bySide = side => new Map\(buildCardSettingsRows\(side, data\)\.map\(r => \[r\.key, r\]\)\);/);
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".
assert.match(toggle, /a\.flex\.card-field-order-toggle\([^\n]*\n\s+\.materialCheckBox\(class="\{\{#if row\.checked\}\}is-checked\{\{\/if\}\}"\)/);
// Each list from the one builder the two lists always used, in its side's order.
assert.match(sidebarJs, /return buildCardSettingsRows\(side, data\)\n\s+\.filter\(built => keys\.has\(built\.key\)\)/);
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;/);
// Every row of the table is in exactly one section.
const { SECTIONS } = require('../models/lib/cardSettingsSections');
const keys = SECTIONS.flatMap(sec => sec.keys);
@@ -249,18 +257,25 @@ test('the sections are the Admin Panel left menu, the rows a line each with a mi
assert.strictEqual(new Set(keys).size, keys.length);
});
test('each side cell has its order arrows, keyboard-reachable and titled; a modifier row has none', () => {
assert.match(popup, /if cell\.positioned\n\s+span\.card-settings-position \{\{cell\.position\}\}\n\s+a\.card-field-order-move\.js-card-field-order-up\(href="#" role="button" title="\{\{_ 'card-field-order-move-up'\}\}"/);
assert.match(popup, /a\.card-field-order-move\.js-card-field-order-down\(href="#" role="button" title="\{\{_ 'card-field-order-move-down'\}\}"/);
assert.ok(en['card-field-order-move-up'] && en['card-field-order-move-down']);
assert.match(sidebarJs, /'click \.js-card-field-order-up'\(evt\) \{ moveCardSettingsRow\(evt, 'up'\); \}/);
assert.match(sidebarJs, /'click \.js-card-field-order-down'\(evt\) \{ moveCardSettingsRow\(evt, 'down'\); \}/);
assert.match(sidebarJs, /'keydown \.js-card-field-order-up, keydown \.js-card-field-order-down'\(evt\) \{\s*const direction = ARROW_KEYS\[evt\.key\];\s*if \(direction\) moveCardSettingsRow\(evt, direction\);/);
assert.match(sidebarJs, /const ARROW_KEYS = \{ ArrowUp: 'up', ArrowDown: 'down' \};/);
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\}\}"/);
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\);/);
// Negative: the drag and drop of the two long lists is gone with them.
assert.ok(!popup.includes('js-card-field-order-sortable'), 'no sortable list left');
assert.ok(!sidebarJs.includes("'> .js-card-field-order-row.is-positioned'"), 'no sortable setup left');
assert.match(sidebarJs, /reorderOnDrop\(this\.\$\(list\), \{\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'));
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(!/moveKey\(/.test(sidebarJs), 'negative: no one-step move in the whole order');
});
test('every locale has the column heading keys', () => {
@@ -279,19 +294,49 @@ test('no hand-written row is left in the popup (negative: the table is the singl
assert.ok(!sidebarJade.includes('cardFieldOrderRows'), 'the old separate arrow list is gone');
});
// ── the arrows move ONE side, through the board's admin-only setters ────────
// ── a drop moves ONE side, through the board's admin-only setters ──────────
test('an order arrow moves the field on its own side only, through setCardFieldOrder / setMinicardFieldOrder', () => {
const fn = sidebarJs.slice(sidebarJs.indexOf('function moveCardSettingsRow('), sidebarJs.indexOf('Template.boardCardSettingsPopup.events('));
assert.match(fn, /const \{ key, side \} = rowEl\.dataset/);
assert.match(fn, /if \(side === 'minicard'\) \{\s*currentBoard\.setMinicardFieldOrder\(moveKey\(currentBoard\.minicardFieldOrder, key, direction, MINICARD_LAYOUT\)\)/);
assert.match(fn, /currentBoard\.setCardFieldOrder\(moveKey\(currentBoard\.cardFieldOrder, key, direction, CARD_LAYOUT\)\)/);
// The drop's placement, from sidebar.js itself, run against the real layout.
const fieldOrder = require('../models/lib/cardFieldOrder');
const dropTargetIndex = (() => {
const src = sidebarJs.slice(sidebarJs.indexOf('function dropTargetIndex('), sidebarJs.indexOf('// `keys`: the list\'s rows in their new order'));
return new Function('applyLayoutOrder', 'isMovableKey', `${src}\nreturn dropTargetIndex;`)(fieldOrder.applyLayoutOrder, fieldOrder.isMovableKey);
})();
// What a list shows after a drop: the side's new order, filtered to the list.
const dropInto = (stored, layout, key, wanted) => {
const order = fieldOrder.placeKey(stored, key, dropTargetIndex(stored, layout, key, wanted), layout);
return order.filter(k => wanted.includes(k));
};
test('a dropped row lands where it was dropped in its list, on its own side only', () => {
const { CARD_LAYOUT, MINICARD_LAYOUT } = fieldOrder;
// Date: End dropped first.
assert.deepStrictEqual(dropInto(undefined, CARD_LAYOUT, 'endDate', ['endDate', 'receivedDate', 'startDate', 'dueDate']),
['endDate', 'receivedDate', 'startDate', 'dueDate']);
// Time: its three fields share a card section with two of Card's rows -
// Pomodoro dropped first goes before Spent time, not before those two.
const time = dropInto(undefined, CARD_LAYOUT, 'pomodoro', ['pomodoro', 'spentTime', 'flowtime']);
assert.deepStrictEqual(time, ['pomodoro', 'spentTime', 'flowtime']);
assert.deepStrictEqual(fieldOrder.orderedCardFieldsOf(fieldOrder.placeKey(undefined, 'pomodoro',
dropTargetIndex(undefined, CARD_LAYOUT, 'pomodoro', ['pomodoro', 'spentTime', 'flowtime']), CARD_LAYOUT), 'sort').slice(0, 2),
['cardSortingByNumber', 'showLists'], 'the rows of other sections keep their place');
// A dropped last row goes last.
assert.deepStrictEqual(dropInto(undefined, CARD_LAYOUT, 'receivedDate', ['startDate', 'dueDate', 'endDate', 'receivedDate']),
['startDate', 'dueDate', 'endDate', 'receivedDate']);
// The minicard's own order, the card's untouched.
const mini = dropInto(undefined, MINICARD_LAYOUT, 'endDate', ['endDate', 'receivedDate', 'startDate', 'dueDate']);
assert.deepStrictEqual(mini, ['endDate', 'receivedDate', 'startDate', 'dueDate']);
// Negative: a pinned header stays first - Stickers dropped above Labels does not pass it.
const labels = fieldOrder.placeKey(undefined, 'stickers', dropTargetIndex(undefined, CARD_LAYOUT, 'stickers', ['stickers', 'labels', 'location']), CARD_LAYOUT);
assert.strictEqual(fieldOrder.orderedCardFieldsOf(labels, 'labels')[0], 'labels');
// 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'));
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\);/);
assert.ok(!/\$set: \{ (cardFieldOrder|minicardFieldOrder): /.test(sidebarJs), 'the popup never writes the order itself (negative)');
// placeKey keeps the layout's rules: a pinned header stays first.
const { placeKey, CARD_LAYOUT, orderedCardFieldsOf } = require('../models/lib/cardFieldOrder');
assert.deepStrictEqual(orderedCardFieldsOf(placeKey(undefined, 'stickers', 0, CARD_LAYOUT), 'labels')[0], 'labels');
const order = placeKey(undefined, 'endDate', 0, CARD_LAYOUT);
assert.deepStrictEqual(orderedCardFieldsOf(order, 'dates')[0], 'endDate', 'a field dropped first leads its section');
});
test('the setters normalise what they store, the schema holds both orders, and only a board admin may write them', () => {
@@ -302,10 +347,10 @@ test('the setters normalise what they store, the schema holds both orders, and o
assert.match(boardsModel, /cardFieldOrder: \{[\s\S]*?type: Array,\s*optional: true/);
// Boards.update is allowed to a board admin (or site admin) and nobody else.
assert.match(permissions, /Boards\.allow\(\{[\s\S]*?update: allowIsBoardAdminOrSiteAdmin/);
// Only an admin has the order arrows, and only an admin's board checkboxes
// are live: anybody else gets them disabled (their own rows excepted).
assert.ok(/if canModifyBoard\n\s+if cell\.positioned\n/.test(popup));
assert.match(sidebarJs, /writable: writable \|\| built\.personal,/);
// Only an admin drags a row, and only an admin's board checkboxes are
// live: anybody else gets them disabled (their own rows excepted).
assert.match(sidebarJs, /draggable: writable && built\.positioned,/);
assert.match(sidebarJs, /writable: writable \|\| Boolean\(built\.personal\),/);
});
// ── order: the layouts' defaults ARE the templates' order ───────────────────
@@ -393,9 +438,9 @@ test('minicard-only rows sit beside the row they belong with', () => {
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');
// One LINE per row now (card-settings-line), holding both sides' cells.
// 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\(class="\{\{#if row\.personal\}\}card-settings-row-personal\{\{\/if\}\}"/);
assert.match(popup, /\.card-settings-line\.js-card-field-order-row\(class="\{\{#if row\.personal\}\}card-settings-row-personal\{\{\/if\}\}/);
});
console.log(`\n${passed} passed`);
+2 -1
View File
@@ -101,7 +101,8 @@ test('a heading and its cells start at the same x', () => {
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.
assert.match(css, /grid-template-columns: 9em 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;/);
// (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;/);
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.
+5 -2
View File
@@ -249,8 +249,11 @@ test('the pane lines up: fixed side columns on every line, and an inline form ke
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.
assert.match(css, /grid-template-columns: 9em 9em 8em minmax\(10em, 1fr\);/);
assert.match(css, /\.board-card-settings\.has-card \.card-settings-line \{\n\s+grid-template-columns: 7em 9em 9em 8em minmax\(10em, 1fr\);/);
// 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\);/);
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;/);
@@ -140,11 +140,12 @@ 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,
// each line with its minicard and its card cell. A row with no place of its
// own (it modifies another) has no order arrows, and the popup stays open.
// 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"] .js-card-field-order-up')).toHaveCount(0);
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);
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
@@ -104,7 +104,7 @@ test('Board Settings / Card: the attachment rows are in the Attachments section,
// arrow do the same; a row that only modifies another has none.
const cardOrder = board => db.findOne('boards', { _id: board.boardId }).cardFieldOrder || [];
test('Card Settings: the order arrows move a field on its own side', async ({ boardPage: page, board }) => {
test('Card Settings: the keyboard moves a field on its own side, one row of its list', async ({ boardPage: page, board }) => {
try {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.locator('.js-toggle-page-sidebar').first().click();
@@ -115,15 +115,17 @@ test('Card Settings: the order arrows move a field on its own side', async ({ bo
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 before = cardOrder(board);
await due.locator('.js-card-field-order-up').click();
// Up on the focused name: one row up in the Show on Card list.
await due.locator('.card-settings-line-label').focus();
await page.keyboard.press('ArrowUp');
await expect.poll(() => cardOrder(board).join(' ')).not.toBe(before.join(' '));
const order = cardOrder(board);
expect(order.indexOf('dueDate')).toBeLessThan(order.indexOf('startDate'));
// The minicard's order is not touched by the card's arrow.
// 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 has no arrows.
// 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-up')).toHaveCount(0);
await expect(form.locator('.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 } });
}
@@ -1,9 +1,11 @@
'use strict';
// Board Settings / Board View reorders by drag and drop (2026-10-02); Board
// Settings / Card by order arrows since its rows are in sections (2026-10-25). With drag handles on, the handle where the up/down arrows
// were is what is dragged; with them off, the row's icon and label are. The
// Up and Down keys on the focused handle or label move a row one step.
// 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
// 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.
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
@@ -28,33 +30,46 @@ async function dragOnto(page, source, target) {
await page.mouse.up();
}
test('Board Settings / Card reorders by its order arrows and the arrow keys', async ({ boardPage: page, board }) => {
// docs/Features/Cards/Card-Fields.md (2026-10-25): with the rows in sections,
// each side of a row has up and down arrows instead of a drag.
test('Board Settings / Card reorders each list 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 cell = key => page.locator(`.js-card-field-order-row[data-key="${key}"][data-side="card"]`);
await expect(cell('dueDate')).toBeVisible();
// Negative: the head rows sit at a fixed place, so they have no arrows.
const row = (key, side = 'card') => page.locator(`.card-settings-list-${side} .js-card-field-order-row[data-key="${key}"]`);
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(cell('cardNumber').locator('.js-card-field-order-up')).toHaveCount(0);
await expect(row('cardNumber').locator('.js-card-field-order-handle')).toHaveCount(0);
await page.locator('.js-card-settings-menu[data-id="date"]').click();
await cell('dueDate').locator('.js-card-field-order-up').click();
await cell('dueDate').locator('.js-card-field-order-up').click();
// Drag Due by its name onto Received: first in the list, 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');
}).toBe(true);
// The keyboard: Down on the focused arrow moves the row one step back.
await cell('dueDate').locator('.js-card-field-order-down').focus();
await expect(page.locator('.card-settings-list-card .js-card-field-order-row').first()).toHaveAttribute('data-key', 'dueDate');
// The minicard's list is its own order: 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();
await page.keyboard.press('ArrowDown');
await expect.poll(() => {
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.
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 expect.poll(() => {
const order = db.findOne('boards', { _id: board.boardId }).minicardFieldOrder || [];
return 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);
} finally {
db.updateOne('boards', { _id: board.boardId }, { $unset: { cardFieldOrder: '' } });
setHandles(user._id, false);
db.updateOne('boards', { _id: board.boardId }, { $unset: { cardFieldOrder: '', minicardFieldOrder: '' } });
}
});