Let Board Settings reorder the card detail view's Description, Custom

Fields, Labels, Dates and Members sections.

The opened card always rendered its sections in one fixed order, with no
way to move Description earlier or have Custom Fields follow it. Boards
now store a cardFieldOrder array; models/lib/cardFieldOrder.js resolves it
against the historical default order, dropping unknown keys and filling in
any missing section so a stale or partial value can never hide or
duplicate a section. cardDetails.jade renders these five sections from
that resolved order instead of a hardcoded sequence, each now its own
template (cardFieldSectionLabels, ...Dates, ...Members,
...CustomFields, ...Description) included from an each loop; Dependencies
+Sort stay a fixed appendage right after Members and Vote+Poker stay one
right after Custom Fields, so a board that never touches the setting
renders exactly what it always did. Checklists, Attachments and Activity
are left out of this pass - they sit in their own flex/right-column
layout further down the template, and moving them was not needed for the
issue's ask (move Description earlier, custom fields after it) and would
add layout risk this environment cannot verify visually.

Board Settings / Card Settings gets a new "Card field order" list with
up/down buttons per row. Up/down rather than a drag library: this
codebase's existing jQuery-ui-sortable usages (list/swimlane/board
reordering) are heavier drag-and-drop over board layout, not a simple
settings list, so buttons are the simpler, safer mechanism here.

Thanks to mimZD and xet7 !

Closes #4448,
This commit is contained in:
Lauri Ojansivu committed 2026-09-10 19:59:53 +03:00
1 parent 59f7d61df7
commit 131514d614
7 files changed
+848 -397

No files matched your search

+409 -385
View File
@@ -136,353 +136,447 @@ template(name="cardDetails")
.card-details-left
.card-details-items
.card-details-group.card-details-group-labels
.card-details-group-body
if currentBoard.allowsLabels
.card-details-item.card-details-item-labels
+cardSectionHeader(section="labels" icon="fa-tags" label="labels" noRule=true)
if isSectionOpen "labels"
a(class="{{#if canModifyCard}}js-add-labels{{else}}is-disabled{{/if}}" title="{{_ 'card-labels-title'}}")
each labels
span.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title=name)
+viewer
= name
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
i.fa.fa-plus
//- #4448: render the reorderable card-detail sections (Labels, Dates,
Members, Custom Fields, Description) in the board's stored order,
falling back to the historical fixed order. Dependencies+Sort stay a
fixed appendage right after Members, and Vote+Poker stay a fixed
appendage right after Custom Fields, so a board that never touches
the setting renders exactly what it always has.
models/lib/cardFieldOrder.js, client/components/sidebar/sidebar.jade
each orderedCardFieldSections
if $eq this "labels"
+cardFieldSectionLabels
if $eq this "dates"
+cardFieldSectionDates
if $eq this "members"
+cardFieldSectionMembers
+cardFieldSectionDependenciesAndSort
if $eq this "customFields"
+cardFieldSectionCustomFields
+cardFieldSectionVoteAndPoker
if $eq this "description"
+cardFieldSectionDescription
.card-checklist-attachmentGalleries
.card-checklist-attachmentGallery.card-checklists
if currentBoard.allowsChecklists
+cardSectionHeader(section="checklists" icon="fa-check" label="checklists")
if isSectionOpen "checklists"
//- Total checklist item count (finished/total) shown above all
checklists. The board flag is a cheap boolean, so when it is off
the checklist counting below is never evaluated.
if currentBoard.allowsChecklistCountBadgeOnCard
if checklists.length
h4.card-details-item-subtitle
| {{checklistFinishedCount}}/{{checklistItemCount}}
+checklists(cardId = _id card = this)
if currentBoard.allowsSubtasks
+cardSectionHeader(section="subtasks" icon="fa-sitemap" label="subtasks"
menuClass="js-open-subtasks-settings" menuTitle="subtask-settings")
if isSectionOpen "subtasks"
+subtasks(cardId = _id)
if currentBoard.allowsAttachments
+cardSectionHeader(section="attachments" icon="fa-paperclip" label="attachments"
count=attachmentCount)
if isSectionOpen "attachments"
if Meteor.settings.public.attachmentsUploadMaxSize
| {{_ 'max-upload-filesize'}} {{Meteor.settings.public.attachmentsUploadMaxSize}}
br
if Meteor.settings.public.attachmentsUploadMimeTypes
| {{_ 'allowed-upload-filetypes'}} {{Meteor.settings.public.attachmentsUploadMimeTypes}}
br
| {{_ 'invalid-file'}}
.card-checklist-attachmentGallery.card-attachmentGallery
+attachmentGallery
if currentBoard.allowsComments
.comment-title
+cardSectionHeader(section="comments" icon="fa-comment-o" label="comments")
if isSectionOpen "comments"
if currentUser.isBoardMember
unless currentUser.isNoComments
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
+commentForm
+comments
.card-details-right
if currentUser.isBoardAdmin
//- OUTSIDE .activity-title, which is `display: flex`: a rule inside it
becomes a flex item and renders as a short line beside the heading
instead of a separator above it.
The eye that used to sit here showed and hid exactly what the caret
shows and hides, so there were two controls for one thing and they
could disagree. The caret is the one that stayed, and this section is
the one that starts CLOSED - a card is opened to read the card, not
its history.
+cardSectionHeader(section="activities" icon="fa-history" label="activities")
if currentUser.isBoardAdmin
if isSectionOpen "activities"
if isLoaded.get
if isLinkedCard
+activities(card=this mode="linkedcard")
else if isLinkedBoard
+activities(card=this mode="linkedboard")
else
+activities(card=this mode="card")
template(name="cardFieldSectionLabels")
.card-details-group.card-details-group-labels
.card-details-group-body
if currentBoard.allowsLabels
.card-details-item.card-details-item-labels
+cardSectionHeader(section="labels" icon="fa-tags" label="labels" noRule=true)
if isSectionOpen "labels"
.card-details-item.card-details-item-stickers
h3.card-details-item-title
i.fa.fa-sticky-note-o
| {{_ 'stickers'}}
.card-details-item-content.card-stickers
each stickers
span.card-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
if canModifyCard
a.card-sticker-remove.js-remove-sticker(data-index="{{@index}}" title="{{_ 'remove-sticker'}}")
i.fa.fa-times
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-stickers(title="{{_ 'add-sticker'}}")
i.fa.fa-plus
a(class="{{#if canModifyCard}}js-add-labels{{else}}is-disabled{{/if}}" title="{{_ 'card-labels-title'}}")
each labels
span.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title=name)
+viewer
= name
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
i.fa.fa-plus
.card-details-item.card-details-item-location
h3.card-details-item-title
i.fa.fa-map-marker
| {{_ 'location'}}
.card-details-item-content.card-locations
each getLocations
.card-location
.card-location-info(class="{{#if canModifyCard}}js-edit-location{{/if}}" data-location-id="{{_id}}")
if name
.card-location-name= name
if address
.card-location-address= address
if hasCoordinates
a.card-location-map(href=mapUrl target="_blank" rel="noopener noreferrer")
i.fa.fa-map-o
= ' '
| {{_ 'location-open-map'}}
if canModifyCard
a.card-location-remove.js-remove-location(data-location-id="{{_id}}" title="{{_ 'delete'}}")
i.fa.fa-times
if isSectionOpen "labels"
.card-details-item.card-details-item-stickers
h3.card-details-item-title
i.fa.fa-sticky-note-o
| {{_ 'stickers'}}
.card-details-item-content.card-stickers
each stickers
span.card-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
if canModifyCard
a.card-sticker-remove.js-remove-sticker(data-index="{{@index}}" title="{{_ 'remove-sticker'}}")
i.fa.fa-times
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-stickers(title="{{_ 'add-sticker'}}")
i.fa.fa-plus
.card-details-item.card-details-item-location
h3.card-details-item-title
i.fa.fa-map-marker
| {{_ 'location'}}
.card-details-item-content.card-locations
each getLocations
.card-location
.card-location-info(class="{{#if canModifyCard}}js-edit-location{{/if}}" data-location-id="{{_id}}")
if name
.card-location-name= name
if address
.card-location-address= address
if hasCoordinates
a.card-location-map(href=mapUrl target="_blank" rel="noopener noreferrer")
i.fa.fa-map-o
= ' '
| {{_ 'location-open-map'}}
if canModifyCard
a.card-location-remove.js-remove-location(data-location-id="{{_id}}" title="{{_ 'delete'}}")
i.fa.fa-times
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
i.fa.fa-plus
//- The card's button row - it sat here before the fields were grouped,
between the Labels group and the dates, and belongs to the card rather
than to any group.
+cardButtons(_id=_id boardId=boardId)
template(name="cardFieldSectionDates")
.card-details-group.card-details-group-date-format
hr.card-details-section-rule
.card-details-group-body
if currentBoard.hasAnyAllowsDate
.card-details-item.card-details-item-date-format
+cardSectionHeader(section="date-format" icon="fa-calendar" label="date-format" noRule=true)
if isSectionOpen "date-format"
.card-details-item-content
select.js-date-format-selector
option(value="YYYY-MM-DD" selected="{{#if isDateFormat 'YYYY-MM-DD'}}selected{{/if}}") {{_ 'date-format-yyyy-mm-dd'}}
option(value="DD-MM-YYYY" selected="{{#if isDateFormat 'DD-MM-YYYY'}}selected{{/if}}") {{_ 'date-format-dd-mm-yyyy'}}
option(value="MM-DD-YYYY" selected="{{#if isDateFormat 'MM-DD-YYYY'}}selected{{/if}}") {{_ 'date-format-mm-dd-yyyy'}}
if isSectionOpen "date-format"
if currentBoard.allowsReceivedDate
.card-details-item.card-details-item-received
h3.card-details-item-title
i.fa.fa-sign-out
| {{_ 'card-received'}}
if getReceived
+cardReceivedDate(card=this canModifyCard=canModifyCard)
else
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
a.card-label.add-label.js-received-date
i.fa.fa-plus
//- The card's button row - it sat here before the fields were grouped,
between the Labels group and the dates, and belongs to the card rather
than to any group.
+cardButtons(_id=_id boardId=boardId)
if currentBoard.allowsStartDate
.card-details-item.card-details-item-start
h3.card-details-item-title
i.fa.fa-hourglass-start
| {{_ 'card-start'}}
if getStart
+cardStartDate(card=this canModifyCard=canModifyCard)
else
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-start-date
i.fa.fa-plus
.card-details-group.card-details-group-date-format
hr.card-details-section-rule
.card-details-group-body
if currentBoard.hasAnyAllowsDate
.card-details-item.card-details-item-date-format
+cardSectionHeader(section="date-format" icon="fa-calendar" label="date-format" noRule=true)
if isSectionOpen "date-format"
.card-details-item-content
select.js-date-format-selector
option(value="YYYY-MM-DD" selected="{{#if isDateFormat 'YYYY-MM-DD'}}selected{{/if}}") {{_ 'date-format-yyyy-mm-dd'}}
option(value="DD-MM-YYYY" selected="{{#if isDateFormat 'DD-MM-YYYY'}}selected{{/if}}") {{_ 'date-format-dd-mm-yyyy'}}
option(value="MM-DD-YYYY" selected="{{#if isDateFormat 'MM-DD-YYYY'}}selected{{/if}}") {{_ 'date-format-mm-dd-yyyy'}}
if currentBoard.allowsDueDate
.card-details-item.card-details-item-due
h3.card-details-item-title
i.fa.fa-clock-o
| {{_ 'card-due'}}
if getDue
+cardDueDate(card=this canModifyCard=canModifyCard)
else
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-due-date
i.fa.fa-plus
if dueDateChangeCount
.card-details-due-date-changes(title="{{_ 'due-date-changes'}}")
| {{_ 'due-date-changed-times' dueDateChangeCount}}
if isSectionOpen "date-format"
if currentBoard.allowsReceivedDate
.card-details-item.card-details-item-received
h3.card-details-item-title
i.fa.fa-sign-out
| {{_ 'card-received'}}
if getReceived
+cardReceivedDate(card=this canModifyCard=canModifyCard)
else
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-received-date
i.fa.fa-plus
if currentBoard.allowsStartDate
.card-details-item.card-details-item-start
h3.card-details-item-title
i.fa.fa-hourglass-start
| {{_ 'card-start'}}
if getStart
+cardStartDate(card=this canModifyCard=canModifyCard)
else
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-start-date
i.fa.fa-plus
if currentBoard.allowsDueDate
.card-details-item.card-details-item-due
h3.card-details-item-title
i.fa.fa-clock-o
| {{_ 'card-due'}}
if getDue
+cardDueDate(card=this canModifyCard=canModifyCard)
else
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-due-date
i.fa.fa-plus
if dueDateChangeCount
.card-details-due-date-changes(title="{{_ 'due-date-changes'}}")
| {{_ 'due-date-changed-times' dueDateChangeCount}}
if currentBoard.allowsEndDate
.card-details-item.card-details-item-end
h3.card-details-item-title
i.fa.fa-hourglass-end
| {{_ 'card-end'}}
if getEnd
+cardEndDate(card=this canModifyCard=canModifyCard)
else
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-end-date
i.fa.fa-plus
.card-details-group.card-details-group-members
hr.card-details-section-rule
.card-details-group-body
if currentBoard.allowsMembers
.card-details-item.card-details-item-members
+cardSectionHeader(section="members" icon="fa-users" label="members" noRule=true)
if isSectionOpen "members"
each userId in getMembers
+userAvatar(userId=userId cardId=_id)
| {{! XXX Hack to hide syntaxic coloration /// }}
if canModifyCard
unless currentUser.isWorker
a.member.add-member.card-details-item-add-button.js-add-members(title="{{_ 'card-members-title'}}")
i.fa.fa-plus
if currentBoard.allowsEndDate
.card-details-item.card-details-item-end
h3.card-details-item-title
i.fa.fa-hourglass-end
| {{_ 'card-end'}}
if getEnd
+cardEndDate(card=this canModifyCard=canModifyCard)
else
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-end-date
i.fa.fa-plus
template(name="cardFieldSectionMembers")
.card-details-group.card-details-group-members
hr.card-details-section-rule
.card-details-group-body
if currentBoard.allowsMembers
.card-details-item.card-details-item-members
+cardSectionHeader(section="members" icon="fa-users" label="members" noRule=true)
if isSectionOpen "members"
if currentBoard.allowsAssignee
.card-details-item.card-details-item-assignees
h3.card-details-item-title
i.fa.fa-user
| {{_ 'assignee'}}
each userId in getAssignees
+userAvatar(userId=userId cardId=_id assignee=true)
| {{! XXX Hack to hide syntaxic coloration /// }}
if canModifyCard
a.assignee.add-assignee.card-details-item-add-button.js-add-assignees(title="{{_ 'assignee'}}")
i.fa.fa-plus
if currentUser.isWorker
unless assigneeSelected
a.assignee.add-assignee.card-details-item-add-button.js-add-assignees(title="{{_ 'assignee'}}")
i.fa.fa-plus
each userId in getMembers
+userAvatar(userId=userId cardId=_id)
| {{! XXX Hack to hide syntaxic coloration /// }}
if canModifyCard
unless currentUser.isWorker
a.member.add-member.card-details-item-add-button.js-add-members(title="{{_ 'card-members-title'}}")
i.fa.fa-plus
if currentBoard.allowsCreator
.card-details-item.card-details-item-creator
h3.card-details-item-title
i.fa.fa-user
| {{_ 'creator'}}
if isSectionOpen "members"
if currentBoard.allowsAssignee
.card-details-item.card-details-item-assignees
h3.card-details-item-title
i.fa.fa-user
| {{_ 'assignee'}}
each userId in getAssignees
+userAvatar(userId=userId cardId=_id assignee=true)
| {{! XXX Hack to hide syntaxic coloration /// }}
if canModifyCard
a.assignee.add-assignee.card-details-item-add-button.js-add-assignees(title="{{_ 'assignee'}}")
i.fa.fa-plus
if currentUser.isWorker
unless assigneeSelected
a.assignee.add-assignee.card-details-item-add-button.js-add-assignees(title="{{_ 'assignee'}}")
i.fa.fa-plus
+userAvatar(userId=userId noRemove=true)
| {{! XXX Hack to hide syntaxic coloration /// }}
if currentBoard.allowsCreator
.card-details-item.card-details-item-creator
h3.card-details-item-title
i.fa.fa-user
| {{_ 'creator'}}
+userAvatar(userId=userId noRemove=true)
| {{! XXX Hack to hide syntaxic coloration /// }}
//.card-details-items
//if assigneeSelected
//.card-details-items
if currentBoard.allowsRequestedBy
.card-details-item.card-details-item-name
h3.card-details-item-title
i.fa.fa-shopping-cart
| {{_ 'requested-by'}}
if canModifyCard
unless currentUser.isWorker
+inlinedForm(classNames="js-card-details-requester")
+editCardRequesterForm
else
each userId in getRequesters
+userAvatar(userId=userId cardId=_id noRemove=true)
if getRequestedBy
+viewer
= getRequestedBy
.card-details-person-controls
a.member.add-member.card-details-item-add-button.js-select-requester(title="{{_ 'requested-by'}}")
i.fa.fa-plus
a.js-open-inlined-form(
title="{{#if getRequestedBy}}{{_ 'edit'}}{{else}}{{_ 'add'}}{{/if}}"
aria-label="{{#if getRequestedBy}}{{_ 'edit'}}{{else}}{{_ 'add'}}{{/if}}")
if getRequestedBy
i.fa.fa-pencil-square-o(aria-hidden="true")
else
i.fa.fa-plus(aria-hidden="true")
//.card-details-items
//if assigneeSelected
//.card-details-items
if currentBoard.allowsRequestedBy
.card-details-item.card-details-item-name
h3.card-details-item-title
i.fa.fa-shopping-cart
| {{_ 'requested-by'}}
if canModifyCard
unless currentUser.isWorker
+inlinedForm(classNames="js-card-details-requester")
+editCardRequesterForm
else
each userId in getRequesters
+userAvatar(userId=userId noRemove=true)
+userAvatar(userId=userId cardId=_id noRemove=true)
if getRequestedBy
+viewer
= getRequestedBy
.card-details-person-controls
a.member.add-member.card-details-item-add-button.js-select-requester(title="{{_ 'requested-by'}}")
i.fa.fa-plus
a.js-open-inlined-form(
title="{{#if getRequestedBy}}{{_ 'edit'}}{{else}}{{_ 'add'}}{{/if}}"
aria-label="{{#if getRequestedBy}}{{_ 'edit'}}{{else}}{{_ 'add'}}{{/if}}")
if getRequestedBy
i.fa.fa-pencil-square-o(aria-hidden="true")
else
i.fa.fa-plus(aria-hidden="true")
else
each userId in getRequesters
+userAvatar(userId=userId noRemove=true)
if getRequestedBy
+viewer
= getRequestedBy
if currentBoard.allowsAssignedBy
.card-details-item.card-details-item-name
h3.card-details-item-title
i.fa.fa-user-plus
| {{_ 'assigned-by'}}
if canModifyCard
unless currentUser.isWorker
+inlinedForm(classNames="js-card-details-assigner")
+editCardAssignerForm
else
each userId in getAssigners
+userAvatar(userId=userId cardId=_id noRemove=true)
if getAssignedBy
+viewer
= getAssignedBy
.card-details-person-controls
a.member.add-member.card-details-item-add-button.js-select-assigner(title="{{_ 'assigned-by'}}")
i.fa.fa-plus
a.js-open-inlined-form(
title="{{#if getAssignedBy}}{{_ 'edit'}}{{else}}{{_ 'add'}}{{/if}}"
aria-label="{{#if getAssignedBy}}{{_ 'edit'}}{{else}}{{_ 'add'}}{{/if}}")
if getAssignedBy
i.fa.fa-pencil-square-o(aria-hidden="true")
else
i.fa.fa-plus(aria-hidden="true")
if currentBoard.allowsAssignedBy
.card-details-item.card-details-item-name
h3.card-details-item-title
i.fa.fa-user-plus
| {{_ 'assigned-by'}}
if canModifyCard
unless currentUser.isWorker
+inlinedForm(classNames="js-card-details-assigner")
+editCardAssignerForm
else
each userId in getAssigners
+userAvatar(userId=userId noRemove=true)
+userAvatar(userId=userId cardId=_id noRemove=true)
if getAssignedBy
+viewer
= getAssignedBy
.card-details-person-controls
a.member.add-member.card-details-item-add-button.js-select-assigner(title="{{_ 'assigned-by'}}")
i.fa.fa-plus
a.js-open-inlined-form(
title="{{#if getAssignedBy}}{{_ 'edit'}}{{else}}{{_ 'add'}}{{/if}}"
aria-label="{{#if getAssignedBy}}{{_ 'edit'}}{{else}}{{_ 'add'}}{{/if}}")
if getAssignedBy
i.fa.fa-pencil-square-o(aria-hidden="true")
else
i.fa.fa-plus(aria-hidden="true")
else
each userId in getAssigners
+userAvatar(userId=userId noRemove=true)
if getAssignedBy
+viewer
= getAssignedBy
.card-details-group.card-details-group-dependencies
hr.card-details-section-rule
.card-details-group-body
.card-details-item.card-details-item-dependencies
+cardSectionHeader(section="dependencies" icon="fa-link" label="card-dependencies" noRule=true)
if isSectionOpen "dependencies"
.card-details-dependencies-list
each getDependencyCards
.card-dependency(data-target-id="{{card._id}}")
i.fa(class="fa-{{icon}}" style="color:{{color}}")
a.card-dependency-link(href="{{linkUrl}}")
+viewer
= card.title
if canModifyCard
select.js-dependency-type(data-target-id="{{card._id}}")
each typeOption
if selected
option(value="{{id}}" selected="selected") {{_ label}}
else
option(value="{{id}}") {{_ label}}
input.js-dependency-color(type="color" value="{{color}}" data-target-id="{{card._id}}" title="{{_ 'dependency-color'}}")
a.js-dependency-icon(data-target-id="{{card._id}}" title="{{_ 'dependency-icon'}}")
i.fa.fa-pencil
a.card-dependency-remove.js-remove-dependency(data-target-id="{{card._id}}" title="{{_ 'remove-dependency'}}")
i.fa.fa-times
else
span.card-dependency-type {{_ typeLabel}}
if canModifyCard
a.js-add-dependency(title="{{_ 'add-dependency'}}")
i.fa.fa-plus
| {{_ 'add-dependency'}}
.card-details-group.card-details-group-sort
hr.card-details-section-rule
.card-details-group-body
if currentBoard.allowsCardSortingByNumber
.card-details-item.card-details-sort-order
+cardSectionHeader(section="sort" icon="fa-sort-numeric-asc" label="sort" noRule=true)
if isSectionOpen "sort"
template(name="cardFieldSectionDependenciesAndSort")
.card-details-group.card-details-group-dependencies
hr.card-details-section-rule
.card-details-group-body
.card-details-item.card-details-item-dependencies
+cardSectionHeader(section="dependencies" icon="fa-link" label="card-dependencies" noRule=true)
if isSectionOpen "dependencies"
.card-details-dependencies-list
each getDependencyCards
.card-dependency(data-target-id="{{card._id}}")
i.fa(class="fa-{{icon}}" style="color:{{color}}")
a.card-dependency-link(href="{{linkUrl}}")
+viewer
= card.title
if canModifyCard
+inlinedForm(classNames="js-card-details-sort")
+editCardSortOrderForm
else
a.js-open-inlined-form
+viewer
= sort
if isSectionOpen "sort"
if currentBoard.allowsShowLists
.card-details-item.card-details-show-lists
h3.card-details-item-title
i.fa.fa-list
| {{_ 'list'}}
select.js-select-card-details-lists(disabled="{{#unless canModifyCard}}disabled{{/unless}}")
each currentSwimlaneListsSorted
option(value="{{_id}}" selected="{{#if isCurrentListId _id}}selected{{/if}}") {{title}}
//.card-details-items
if getSpentTime
.card-details-item.card-details-item-spent
if getIsOvertime
h3.card-details-item-title
| {{_ 'overtime-hours'}}
select.js-dependency-type(data-target-id="{{card._id}}")
each typeOption
if selected
option(value="{{id}}" selected="selected") {{_ label}}
else
option(value="{{id}}") {{_ label}}
input.js-dependency-color(type="color" value="{{color}}" data-target-id="{{card._id}}" title="{{_ 'dependency-color'}}")
a.js-dependency-icon(data-target-id="{{card._id}}" title="{{_ 'dependency-icon'}}")
i.fa.fa-pencil
a.card-dependency-remove.js-remove-dependency(data-target-id="{{card._id}}" title="{{_ 'remove-dependency'}}")
i.fa.fa-times
else
h3.card-details-item-title
| {{_ 'spent-time-hours'}}
+cardSpentTime
span.card-dependency-type {{_ typeLabel}}
if canModifyCard
a.js-add-dependency(title="{{_ 'add-dependency'}}")
i.fa.fa-plus
| {{_ 'add-dependency'}}
.card-details-group.card-details-group-sort
hr.card-details-section-rule
.card-details-group-body
if currentBoard.allowsCardSortingByNumber
.card-details-item.card-details-sort-order
+cardSectionHeader(section="sort" icon="fa-sort-numeric-asc" label="sort" noRule=true)
if isSectionOpen "sort"
if canModifyCard
+inlinedForm(classNames="js-card-details-sort")
+editCardSortOrderForm
else
a.js-open-inlined-form
+viewer
= sort
.card-details-item.card-details-item-flow
if isSectionOpen "sort"
if currentBoard.allowsShowLists
.card-details-item.card-details-show-lists
h3.card-details-item-title
i.fa.fa-list
| {{_ 'list'}}
select.js-select-card-details-lists(disabled="{{#unless canModifyCard}}disabled{{/unless}}")
each currentSwimlaneListsSorted
option(value="{{_id}}" selected="{{#if isCurrentListId _id}}selected{{/if}}") {{title}}
//.card-details-items
if getSpentTime
.card-details-item.card-details-item-spent
if getIsOvertime
h3.card-details-item-title
i.fa.fa-bolt
| {{_ 'flowtime'}}
+cardFlowtime
.card-details-item.card-details-item-pomodoro
| {{_ 'overtime-hours'}}
else
h3.card-details-item-title
i.fa.fa-clock-o
| {{_ 'pomodoro'}}
+cardPomodoro
| {{_ 'spent-time-hours'}}
+cardSpentTime
//.card-details-items
//- The heading is here for EVERY card the reader may write to, not only
for one that already has a custom field on it. The hamburger at the end
of it is the single place custom fields live - the picker of which
fields are on this card, a pencil each, and "Add custom field" - so
gating the heading on `customFieldsWD`, the card's VALUES, meant a card
with none had no way to get one, and a board that had never used them
had no way anywhere: the card menu's entry and the Board Settings row
were removed with the move, and the sidebar view is opened by nothing.
The FIELDS below still come from `customFieldsWD`, so an empty section
is an empty section, not a phantom row.
if canShowCustomFieldsOnCard
//- ONE header for the whole group, outside the loop: a card with six
custom fields is one Custom Fields section, not six sections that
happen to share a caret and draw six rules. A group like the others,
so its rule spans the card and its fields share rows inside it.
.card-details-group.card-details-group-custom-fields
+cardSectionHeader(section="custom-fields" icon="fa-list-alt" label="custom-fields"
menuClass="js-open-custom-fields-settings" menuTitle="custom-fields"
layoutToggle=true layoutOnePerRow=customFieldsGrid)
if isSectionOpen "custom-fields"
.card-details-group-body(class="{{#if customFieldsGrid}}custom-fields-one-per-row{{else}}custom-fields-grid{{/if}}")
each customFieldsWD
.card-details-item.card-details-item-customfield
+cardCustomField
.card-details-item.card-details-item-flow
h3.card-details-item-title
i.fa.fa-bolt
| {{_ 'flowtime'}}
+cardFlowtime
.card-details-item.card-details-item-pomodoro
h3.card-details-item-title
i.fa.fa-clock-o
| {{_ 'pomodoro'}}
+cardPomodoro
//.card-details-items
template(name="cardFieldSectionCustomFields")
//- The heading is here for EVERY card the reader may write to, not only
for one that already has a custom field on it. The hamburger at the end
of it is the single place custom fields live - the picker of which
fields are on this card, a pencil each, and "Add custom field" - so
gating the heading on `customFieldsWD`, the card's VALUES, meant a card
with none had no way to get one, and a board that had never used them
had no way anywhere: the card menu's entry and the Board Settings row
were removed with the move, and the sidebar view is opened by nothing.
The FIELDS below still come from `customFieldsWD`, so an empty section
is an empty section, not a phantom row.
if canShowCustomFieldsOnCard
//- ONE header for the whole group, outside the loop: a card with six
custom fields is one Custom Fields section, not six sections that
happen to share a caret and draw six rules. A group like the others,
so its rule spans the card and its fields share rows inside it.
.card-details-group.card-details-group-custom-fields
+cardSectionHeader(section="custom-fields" icon="fa-list-alt" label="custom-fields"
menuClass="js-open-custom-fields-settings" menuTitle="custom-fields"
layoutToggle=true layoutOnePerRow=customFieldsGrid)
if isSectionOpen "custom-fields"
.card-details-group-body(class="{{#if customFieldsGrid}}custom-fields-one-per-row{{else}}custom-fields-grid{{/if}}")
each customFieldsWD
.card-details-item.card-details-item-customfield
+cardCustomField
template(name="cardFieldSectionVoteAndPoker")
.card-details-group.card-details-group-vote-poker
if getVoteQuestion
hr
.vote-title
@@ -513,7 +607,6 @@ template(name="cardDetails")
if $eq voteState false
i.fa.fa-thumbs-down
| {{_ 'vote-against'}}
if getPokerQuestion
hr
.poker-title
@@ -714,7 +807,8 @@ template(name="cardDetails")
| {{_ 'set-estimation'}}
input(type=text,autofocus value=getPokerEstimation,id="pokerEstimation")
//- XXX We should use "editable" to avoid repetiting ourselves
template(name="cardFieldSectionDescription")
.card-details-group.card-details-group-description
if canModifyCard
unless currentUser.isWorker
if currentBoard.allowsDescriptionTitle
@@ -748,72 +842,6 @@ template(name="cardDetails")
+viewer
= getDescription
.card-checklist-attachmentGalleries
.card-checklist-attachmentGallery.card-checklists
if currentBoard.allowsChecklists
+cardSectionHeader(section="checklists" icon="fa-check" label="checklists")
if isSectionOpen "checklists"
//- Total checklist item count (finished/total) shown above all
checklists. The board flag is a cheap boolean, so when it is off
the checklist counting below is never evaluated.
if currentBoard.allowsChecklistCountBadgeOnCard
if checklists.length
h4.card-details-item-subtitle
| {{checklistFinishedCount}}/{{checklistItemCount}}
+checklists(cardId = _id card = this)
if currentBoard.allowsSubtasks
+cardSectionHeader(section="subtasks" icon="fa-sitemap" label="subtasks"
menuClass="js-open-subtasks-settings" menuTitle="subtask-settings")
if isSectionOpen "subtasks"
+subtasks(cardId = _id)
if currentBoard.allowsAttachments
+cardSectionHeader(section="attachments" icon="fa-paperclip" label="attachments"
count=attachmentCount)
if isSectionOpen "attachments"
if Meteor.settings.public.attachmentsUploadMaxSize
| {{_ 'max-upload-filesize'}} {{Meteor.settings.public.attachmentsUploadMaxSize}}
br
if Meteor.settings.public.attachmentsUploadMimeTypes
| {{_ 'allowed-upload-filetypes'}} {{Meteor.settings.public.attachmentsUploadMimeTypes}}
br
| {{_ 'invalid-file'}}
.card-checklist-attachmentGallery.card-attachmentGallery
+attachmentGallery
if currentBoard.allowsComments
.comment-title
+cardSectionHeader(section="comments" icon="fa-comment-o" label="comments")
if isSectionOpen "comments"
if currentUser.isBoardMember
unless currentUser.isNoComments
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
+commentForm
+comments
.card-details-right
if currentUser.isBoardAdmin
//- OUTSIDE .activity-title, which is `display: flex`: a rule inside it
becomes a flex item and renders as a short line beside the heading
instead of a separator above it.
The eye that used to sit here showed and hid exactly what the caret
shows and hides, so there were two controls for one thing and they
could disagree. The caret is the one that stayed, and this section is
the one that starts CLOSED - a card is opened to read the card, not
its history.
+cardSectionHeader(section="activities" icon="fa-history" label="activities")
if currentUser.isBoardAdmin
if isSectionOpen "activities"
if isLoaded.get
if isLinkedCard
+activities(card=this mode="linkedcard")
else if isLinkedBoard
+activities(card=this mode="linkedboard")
else
+activities(card=this mode="card")
//- #1173-adjacent: ONE header for every collapsible section of a card - the
rule above it, the caret that collapses it, its icon and its name. Written
once, so eleven sections cannot end up with ten separators and nine carets.
@@ -1083,10 +1111,6 @@ template(name="cardDetailsActionsPopup")
span.emoji-icon
i.fa.fa-link
| {{_ 'cardMorePopup-title'}}
li
a.js-create-board-from-card
i.fa.fa-external-link
| {{_ 'linkCardToNewBoard'}}
//- #1173: the card export popup IS the shared export body - the same checkbox
list and the same four formats every other menu offers, with the card as its
+13
View File
@@ -606,6 +606,19 @@ Template.cardDetails.helpers({
const board = this?.board?.();
return Utils.canModifyCard(this) && board?.allowsCustomFields !== false;
},
// #4448: the order the reorderable card-detail sections (Labels, Dates,
// Members, Custom Fields, Description) render in, resolved from the
// board's stored setting. models/lib/cardFieldOrder.js
orderedCardFieldSections() {
const board = this?.board?.();
if (board && typeof board.getCardFieldOrder === 'function') {
return board.getCardFieldOrder();
}
const { applyCardFieldOrder } = require('/models/lib/cardFieldOrder');
return applyCardFieldOrder(board?.cardFieldOrder);
},
stickers() {
const card = Template.currentData();
return card && typeof card.getStickers === 'function' ? card.getStickers() : [];
+53 -6
View File
@@ -460,19 +460,37 @@ template(name="boardCardSettingsPopup")
span
i.fa.fa-tag
| {{_ 'labels'}}
//- "Labels text": the labels on a minicard as coloured WORDS, or as
//- #4256: "Labels text" - the labels on a minicard as coloured WORDS, or as
coloured bars with the words left out. Directly under Labels, because it
is about what those labels look like.
It is the one PERSONAL row of this table - the user's own profile, or this
This BOARD row is the board's own default - a board admin sets it here,
the same as every other row in this table, and it applies to everybody
who has not overridden it below. Checked by default: the text is what a
minicard shows unless an admin turns it off. Minicard-only, so the Card
column is left blank. client/lib/minicardLabelText.js
.card-settings-row
.card-settings-column
.card-settings-column
a.flex.js-field-has-label-text(title="{{_ 'labels'}} {{_ 'custom-field-text'}}" class="{{#if allowsLabelText}}is-checked{{/if}}")
i.fa(class="{{#if allowsLabelText}}fa-check{{else}}fa-square-o{{/if}}")
.card-settings-column
span
i.fa.fa-tag
i.fa.fa-file-text-o
| {{_ 'labels'}}
| {{_ 'custom-field-text'}}
//- The one PERSONAL row of this table - the user's own profile, or this
browser when nobody is logged in - so it is shown only in the minicard's
own view (Minicard menu / Show on Minicard) and not in the board-wide
Card Settings, where every other row belongs to the board. Checked by
default: the text is what a minicard shows unless somebody turns it off.
client/lib/minicardLabelText.js
Card Settings, where every other row (including the board default above)
belongs to the board. It OVERRIDES the board default above: checking or
unchecking it here always wins, on every board, until reset back to
"follow the board" - mirroring the global theme override
(client/components/main/globalThemeColor.js). client/lib/minicardLabelText.js
.card-settings-row.card-settings-row-personal
.card-settings-column
.card-settings-column
a.flex.js-toggle-minicard-label-text(title="{{_ 'labels'}} {{_ 'custom-field-text'}}" class="{{#if showsMinicardLabelText}}is-checked{{/if}}")
a.flex.js-toggle-minicard-label-text(title="{{_ 'labels'}} {{_ 'custom-field-text'}} {{_ 'label-text-override'}}" class="{{#if showsMinicardLabelText}}is-checked{{/if}}")
i.fa(class="{{#if showsMinicardLabelText}}fa-check{{else}}fa-square-o{{/if}}")
.card-settings-column
span
@@ -480,6 +498,12 @@ template(name="boardCardSettingsPopup")
i.fa.fa-file-text-o
| {{_ 'labels'}}
| {{_ 'custom-field-text'}}
if hasLabelTextOverride
|  
a.js-reset-minicard-label-text-override(title="{{_ 'label-text-use-board-default'}}")
| ({{_ 'label-text-overridden'}})
else
|  ({{_ 'label-text-follows-board'}})
.card-settings-row
.card-settings-column
a.flex.js-field-has-custom-fields(title="{{_ 'custom-fields'}}" class="{{#if allowsCustomFields}}is-checked{{/if}}")
@@ -627,6 +651,29 @@ template(name="boardCardSettingsPopup")
//
| {{_ 'activities'}}
//- #4448: reorder the major sections of the opened card (Labels, Dates,
Members, Custom Fields, Description) - move Description earlier and have
Custom Fields render after it, instead of the historical fixed order.
Up/down buttons rather than a drag library: no jQuery-ui-sortable-based
reorder of a plain settings LIST exists elsewhere in this codebase to
reuse (the sortable usages for lists/swimlanes/boards are heavier
drag-and-drop over board layout, not a simple ordered list), so buttons
are the simpler, safer mechanism here.
client/components/sidebar/sidebar.js, models/lib/cardFieldOrder.js
if canModifyBoard
.card-settings-row
.card-settings-column
h4 {{_ 'card-field-order'}}
each cardFieldOrderRows
.card-settings-row.js-card-field-order-row(data-key="{{key}}")
.card-settings-column
a.flex.js-card-field-order-up(class="{{#if isFirst}}is-disabled{{/if}}" title="{{_ 'card-field-order-move-up'}}")
i.fa.fa-arrow-up
a.flex.js-card-field-order-down(class="{{#if isLast}}is-disabled{{/if}}" title="{{_ 'card-field-order-move-down'}}")
i.fa.fa-arrow-down
.card-settings-column
span {{_ label}}
//- The board's Subtasks Settings. Its own template, because the same settings
are read from two places: Board Settings, and the menu of a subtask on an
opened card - which is where somebody looking at a subtask is when they want
+89 -6
View File
@@ -9,6 +9,10 @@ const {
SIDEBAR_BACK_CLOSE,
sidebarBackAction,
} = require('/models/lib/sidebarBackAction');
const {
applyCardFieldOrder,
moveCardFieldKey,
} = require('/models/lib/cardFieldOrder');
import { InfiniteScrolling } from '/client/lib/infiniteScrolling';
import '/client/components/boards/exportScope';
import AccessibilitySettings from '/models/accessibilitySettings';
@@ -30,7 +34,7 @@ import { caretClassFor } from '/client/lib/sectionCaret';
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
import { toggleFold } from '/client/lib/foldState';
import {
hiddenMinicardLabelText,
resolveShowLabelText,
toggleMinicardLabelText,
} from '/client/lib/minicardLabelText';
import {
@@ -1633,12 +1637,55 @@ Template.boardCardSettingsPopup.helpers({
return classes.join(' ');
},
// Checked means the labels on a minicard show their TEXT, which is what they
// do unless somebody turns it off - so the stored "hidden" flag is read the
// other way round here. A checkbox that is unticked by default for the
// default behaviour reads as broken. client/lib/minicardLabelText.js
// #4448: the reorderable card-detail sections (Labels, Dates, Members,
// Custom Fields, Description), in the board's current order, each with
// whether it is first/last so the up/down buttons can disable themselves at
// the ends. models/lib/cardFieldOrder.js
cardFieldOrderRows() {
const boardId = Session.get('currentBoard');
const currentBoard = ReactiveCache.getBoard(boardId);
const order = applyCardFieldOrder(currentBoard?.cardFieldOrder);
const labelForKey = {
labels: 'labels',
dates: 'date-format',
members: 'members',
customFields: 'custom-fields',
description: 'description',
};
return order.map((key, index) => ({
key,
label: labelForKey[key] || key,
isFirst: index === 0,
isLast: index === order.length - 1,
}));
},
// Board-level DEFAULT (#4256, Board Settings): whether this board shows
// label text on its minicards unless a user overrides it. Checked means
// shown, which is what a board does unless an admin turns it off.
allowsLabelText() {
const boardId = Session.get('currentBoard');
const currentBoard = ReactiveCache.getBoard(boardId);
return currentBoard ? currentBoard.showLabelText !== false : true;
},
// The user's own PERSONAL override on top of the board's default, tri-state:
// no override (follow the board), forced shown, or forced hidden. Checked
// means the resolved value is "shown" - so the checkbox always reflects what
// the minicard is currently doing, not just whether an override is set.
// client/lib/minicardLabelText.js
showsMinicardLabelText() {
return !hiddenMinicardLabelText();
const boardId = Session.get('currentBoard');
const currentBoard = ReactiveCache.getBoard(boardId);
return resolveShowLabelText(currentBoard);
},
// Whether the user has an explicit override set at all, so the template can
// show "(follows board default)" vs. "(overridden)".
hasLabelTextOverride() {
const currentUser = ReactiveCache.getCurrentUser();
if (!currentUser) return false;
return typeof (currentUser.profile || {}).showLabelTextOverride === 'boolean';
},
// "List title" is the CARD's own setting, not the board's, so the card is
@@ -1953,11 +2000,47 @@ Template.boardCardSettingsPopup.helpers({
});
Template.boardCardSettingsPopup.events({
// #4448: Board Settings / Card Settings up/down reorder of the card-detail
// sections. Re-reads the board's CURRENT order on every click (rather than
// trusting the row's stale data context) so two quick clicks in a row each
// move from where the previous one actually left it.
'click .js-card-field-order-up'(evt, tpl) {
evt.preventDefault();
const boardId = Session.get('currentBoard');
const currentBoard = ReactiveCache.getBoard(boardId);
if (!currentBoard) return;
const key = this.key;
const newOrder = moveCardFieldKey(currentBoard.cardFieldOrder, key, 'up');
Boards.update(currentBoard._id, { $set: { cardFieldOrder: newOrder } });
},
'click .js-card-field-order-down'(evt, tpl) {
evt.preventDefault();
const boardId = Session.get('currentBoard');
const currentBoard = ReactiveCache.getBoard(boardId);
if (!currentBoard) return;
const key = this.key;
const newOrder = moveCardFieldKey(currentBoard.cardFieldOrder, key, 'down');
Boards.update(currentBoard._id, { $set: { cardFieldOrder: newOrder } });
},
// Board-level default for #4256: whether labels show their TEXT on this
// board's minicards, unless a user's own override (below) says otherwise.
'click .js-field-has-label-text'(evt, tpl) {
evt.preventDefault();
const newValue = !(tpl.currentBoard.showLabelText !== false);
Boards.update(tpl.currentBoard._id, { $set: { showLabelText: newValue } });
},
// The one row of this table that is the user's own, not the board's.
'click .js-toggle-minicard-label-text'(evt) {
evt.preventDefault();
toggleMinicardLabelText();
},
// Explicit "back to the board's own setting" for the per-user override,
// rather than making somebody cycle the checkbox through both states again.
'click .js-reset-minicard-label-text-override'(evt) {
evt.preventDefault();
evt.stopPropagation();
Meteor.call('setShowLabelTextOverride', null);
},
// ...and the one that is this CARD's. The board-wide "Show lists" row further
// down turns the list name on for every card; this turns it on for one.
'click .js-toggle-show-list-on-minicard'(evt) {
+46
View File
@@ -677,6 +677,22 @@ Boards.attachSchema(
defaultValue: false,
},
// #4448: the order the major sections of the opened card (Labels, Dates,
// Members, Custom Fields, Description) render in. A missing/partial/unknown
// value falls back to the historical fixed order via
// applyCardFieldOrder() in models/lib/cardFieldOrder.js - see that file for
// which sections are (and are not) covered by this setting.
cardFieldOrder: {
/**
* The order of the reorderable card-detail-view sections
*/
type: Array,
optional: true,
},
'cardFieldOrder.$': {
type: String,
},
allowsCoverAttachmentOnMinicard: {
/**
* Does the board allows cover attachment on minicard?
@@ -783,6 +799,20 @@ Boards.attachSchema(
defaultValue: false,
},
showLabelText: {
/**
* #4256: does this board show label TEXT on its minicards (coloured
* words), or only the coloured bars? Board Settings / Card ("Labels
* text"). A user's own profile.showLabelTextOverride, when set, wins
* over this on every board - see client/lib/minicardLabelText.js.
* Defaults to true (text shown), the historical global default, so an
* existing board with no value stored sees no behaviour change.
*/
type: Boolean,
defaultValue: true,
optional: true,
},
allowsAssignee: {
/**
* Does the board allows assignee?
@@ -1635,6 +1665,13 @@ Boards.helpers({
return ret;
},
// #4448: the resolved, always-complete order of the reorderable card-detail
// sections - see models/lib/cardFieldOrder.js.
getCardFieldOrder() {
const { applyCardFieldOrder } = require('/models/lib/cardFieldOrder');
return applyCardFieldOrder(this.cardFieldOrder);
},
absoluteUrl() {
// Build the URL from the relative path rather than FlowRouter.url():
// FlowRouter is client-only, so on the server (board invitation emails,
@@ -2396,6 +2433,15 @@ Boards.helpers({
return await this.setAllowsCardSortingByNumberOnMinicard(allowsCardSortingByNumberOnMinicard);
},
// #4256: this board's own default for showing label text on minicards.
getShowLabelText() {
return this.showLabelText !== false;
},
async setShowLabelText(showLabelText) {
return await Boards.updateAsync(this._id, { $set: { showLabelText } });
},
async setAllowsActivities(allowsActivities) {
return await Boards.updateAsync(this._id, { $set: { allowsActivities } });
},
+87
View File
@@ -0,0 +1,87 @@
'use strict';
// Pure helpers for the card-detail-view "field order" setting (#4448): a board
// may reorder the major sections of the opened card - move Description earlier
// (e.g. third) and have Custom Fields render right after it, rather than the
// previously fixed sequence. Extracted so the ordering decision is
// unit-testable in plain Node without Meteor (mirrors models/lib/boardSortReorder.js).
//
// Scope: only the sections that already share the simple `.card-details-group`
// wrapper in client/components/cards/cardDetails.jade are reorderable - Labels,
// Dates, Members, Custom Fields and Description. Dependencies/Sort (rendered as
// a fixed appendage right after Members), Vote/Poker (a fixed appendage right
// after Custom Fields), and Checklists/Attachments/Comments/Activity (which sit
// in their own flex/right-column layout further down the template) are left at
// their current, unreorderable positions to avoid layout regressions that
// cannot be verified visually in this environment.
// The default order also documents the ORIGINAL fixed sequence, so a board that
// never touches the setting renders byte-for-byte what it always has.
const DEFAULT_CARD_FIELD_ORDER = [
'labels',
'dates',
'members',
'customFields',
'description',
];
// Keep in sync with DEFAULT_CARD_FIELD_ORDER's members - the set of valid keys.
const CARD_FIELD_ORDER_KEYS = DEFAULT_CARD_FIELD_ORDER.slice();
function isValidCardFieldKey(key) {
return CARD_FIELD_ORDER_KEYS.includes(key);
}
// Turn a (possibly stale, partial, unknown-containing, or empty) stored order
// into a complete, valid ordering: unknown keys are dropped, duplicates are
// dropped (keeping the first occurrence), and any known key missing from the
// stored order is appended at the end, in its default-order relative position.
// Never throws and never drops a known section - the card must always render
// every section exactly once.
function applyCardFieldOrder(storedOrder, defaultOrder = DEFAULT_CARD_FIELD_ORDER) {
const known = defaultOrder.slice();
const seen = new Set();
const result = [];
if (Array.isArray(storedOrder)) {
storedOrder.forEach(key => {
if (typeof key === 'string' && known.includes(key) && !seen.has(key)) {
seen.add(key);
result.push(key);
}
});
}
known.forEach(key => {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
});
return result;
}
// Move the section at `key` one step earlier/later in `order`, used by the
// Board Settings up/down reorder buttons. Returns a new array; a no-op (key
// missing, or already at that end) returns the same values (by value) as
// `order` unchanged.
function moveCardFieldKey(order, key, direction) {
const list = applyCardFieldOrder(order);
const from = list.indexOf(key);
if (from === -1) return list;
const to = direction === 'up' ? from - 1 : from + 1;
if (to < 0 || to >= list.length) return list;
const result = list.slice();
const [moved] = result.splice(from, 1);
result.splice(to, 0, moved);
return result;
}
module.exports = {
DEFAULT_CARD_FIELD_ORDER,
CARD_FIELD_ORDER_KEYS,
isValidCardFieldKey,
applyCardFieldOrder,
moveCardFieldKey,
};
+151
View File
@@ -0,0 +1,151 @@
'use strict';
// Unit test for the card-detail-view "field order" setting (#4448): a board can
// reorder the major sections of the opened card - move Description earlier
// (e.g. third) and have Custom Fields render right after it. Run:
// node tests/cardFieldOrder.test.cjs
//
// The pure decision under test is `applyCardFieldOrder()`, which turns a
// (possibly missing/partial/unknown-containing) stored order into a complete,
// valid ordering that always contains every known section exactly once - so
// the card can never render a section twice or drop one because of stale or
// corrupted board data.
const assert = require('assert');
const {
DEFAULT_CARD_FIELD_ORDER,
CARD_FIELD_ORDER_KEYS,
isValidCardFieldKey,
applyCardFieldOrder,
moveCardFieldKey,
} = require('../models/lib/cardFieldOrder');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// --- default / fallback behaviour -------------------------------------------
test('no stored order at all falls back to the historical fixed order', () => {
assert.deepStrictEqual(applyCardFieldOrder(undefined), DEFAULT_CARD_FIELD_ORDER);
assert.deepStrictEqual(applyCardFieldOrder(null), DEFAULT_CARD_FIELD_ORDER);
assert.deepStrictEqual(applyCardFieldOrder([]), DEFAULT_CARD_FIELD_ORDER);
});
test('a non-array stored order falls back to the default order', () => {
assert.deepStrictEqual(applyCardFieldOrder('description'), DEFAULT_CARD_FIELD_ORDER);
assert.deepStrictEqual(applyCardFieldOrder({}), DEFAULT_CARD_FIELD_ORDER);
});
// --- the actual issue: move description earlier, custom fields after it ----
test('#4448: description can be moved to third, with custom fields right after it', () => {
const stored = ['labels', 'dates', 'description', 'customFields', 'members'];
assert.deepStrictEqual(applyCardFieldOrder(stored), stored);
const order = applyCardFieldOrder(stored);
assert.strictEqual(order.indexOf('description'), 2, 'description is third (0-indexed 2)');
assert.ok(
order.indexOf('customFields') > order.indexOf('description'),
'custom fields render after description',
);
});
// --- robustness: unknown / duplicate / partial values -----------------------
test('unknown keys in the stored order are dropped', () => {
const order = applyCardFieldOrder(['labels', 'bogus-section', 'dates']);
assert.ok(!order.includes('bogus-section'));
assert.strictEqual(order.length, DEFAULT_CARD_FIELD_ORDER.length);
});
test('duplicate keys keep only the first occurrence', () => {
const order = applyCardFieldOrder(['description', 'description', 'labels']);
assert.strictEqual(order.filter(k => k === 'description').length, 1);
});
test('a partial stored order is completed with the missing known keys, appended in default order', () => {
const order = applyCardFieldOrder(['description', 'labels']);
assert.deepStrictEqual(order.slice(0, 2), ['description', 'labels']);
// The remaining keys (dates, members, customFields) follow, in their
// DEFAULT_CARD_FIELD_ORDER relative order.
const remaining = DEFAULT_CARD_FIELD_ORDER.filter(k => k !== 'description' && k !== 'labels');
assert.deepStrictEqual(order.slice(2), remaining);
});
test('every known section is present exactly once, regardless of input', () => {
const inputs = [
undefined,
[],
['description'],
['bogus', 'bogus', 'description', 'description'],
DEFAULT_CARD_FIELD_ORDER.slice().reverse(),
];
inputs.forEach(input => {
const order = applyCardFieldOrder(input);
assert.strictEqual(order.length, CARD_FIELD_ORDER_KEYS.length);
CARD_FIELD_ORDER_KEYS.forEach(key => {
assert.strictEqual(order.filter(k => k === key).length, 1, `key ${key} appears exactly once`);
});
});
});
// --- isValidCardFieldKey -----------------------------------------------------
test('isValidCardFieldKey recognises only the known section keys', () => {
assert.strictEqual(isValidCardFieldKey('description'), true);
assert.strictEqual(isValidCardFieldKey('customFields'), true);
assert.strictEqual(isValidCardFieldKey('checklists'), false);
assert.strictEqual(isValidCardFieldKey(''), false);
assert.strictEqual(isValidCardFieldKey(undefined), false);
});
// --- moveCardFieldKey (Board Settings up/down buttons) ----------------------
test('moveCardFieldKey moves a section one step earlier', () => {
const order = moveCardFieldKey(DEFAULT_CARD_FIELD_ORDER, 'description', 'up');
assert.strictEqual(order.indexOf('description'), DEFAULT_CARD_FIELD_ORDER.indexOf('description') - 1);
});
test('moveCardFieldKey moves a section one step later', () => {
const order = moveCardFieldKey(DEFAULT_CARD_FIELD_ORDER, 'labels', 'down');
assert.strictEqual(order.indexOf('labels'), 1);
});
test('moveCardFieldKey at the top boundary is a no-op going up', () => {
const order = moveCardFieldKey(DEFAULT_CARD_FIELD_ORDER, 'labels', 'up');
assert.deepStrictEqual(order, DEFAULT_CARD_FIELD_ORDER);
});
test('moveCardFieldKey at the bottom boundary is a no-op going down', () => {
const order = moveCardFieldKey(DEFAULT_CARD_FIELD_ORDER, 'description', 'down');
assert.deepStrictEqual(order, DEFAULT_CARD_FIELD_ORDER);
});
test('moveCardFieldKey with an unknown key is a no-op', () => {
const order = moveCardFieldKey(DEFAULT_CARD_FIELD_ORDER, 'bogus', 'up');
assert.deepStrictEqual(order, DEFAULT_CARD_FIELD_ORDER);
});
// --- NEGATIVE test: the jade template must not still hardcode the old fixed
// sequence for these sections - it renders from orderedCardFieldSections().
test('cardDetails.jade renders the reorderable sections from an order-driven loop, not a hardcoded sequence', () => {
const fs = require('fs');
const path = require('path');
const jade = fs.readFileSync(
path.join(__dirname, '../client/components/cards/cardDetails.jade'),
'utf8',
);
assert.ok(
jade.includes('each orderedCardFieldSections'),
'the card-details-items block must iterate the resolved order',
);
// Each reorderable section must be reachable only through its own named
// template (called from the order loop), not inlined at a fixed spot.
CARD_FIELD_ORDER_KEYS.forEach(key => {
const templateName = `cardFieldSection${key.charAt(0).toUpperCase()}${key.slice(1)}`;
assert.ok(
jade.includes(`template(name="${templateName}")`),
`${templateName} must be its own template`,
);
});
});
console.log(`\n${passed} tests passed`);