feat: Show how many cards a list holds
A board level switch, off by default. With it on, each list puts its card count on the add-card button. Useful to a board that is being kept to a size and noise to one that is not, which is why it is a choice rather than always on. The count is every card in the list, not the ones a filter leaves visible. Those answer different questions: how full is this list, versus how many match what I am looking at right now. The first is the one a counter is for, and it is also what the same switch counts in Pro, so a board keeps its meaning across an upgrade. The column matches Pro's name, type and default, so the setting survives that upgrade rather than being dropped on the way.
This commit is contained in:
@@ -43,6 +43,15 @@ const Others = React.memo(() => {
|
||||
className={styles.radio}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Radio
|
||||
toggle
|
||||
name="showCardCounter"
|
||||
checked={board.showCardCounter}
|
||||
label={t('common.showCardCounter')}
|
||||
className={styles.radio}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<p className={styles.hint}>{t('common.showCardCounterHint')}</p>
|
||||
<Radio
|
||||
toggle
|
||||
name="displayCardAges"
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
*/
|
||||
|
||||
:global(#app) {
|
||||
// Sits under the toggle it belongs to: pulled up out of that toggle's 16px
|
||||
// gap so only a few pixels separate the two, and carrying the full 16px on
|
||||
// its own underside so the next toggle keeps its distance.
|
||||
.hint {
|
||||
color: #666666;
|
||||
margin-bottom: 16px;
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
.radio {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
|
||||
@@ -49,12 +49,24 @@ const List = React.memo(({ id, index }) => {
|
||||
[],
|
||||
);
|
||||
|
||||
const selectCardCountByListId = useMemo(() => selectors.makeSelectCardCountByListId(), []);
|
||||
|
||||
const clipboard = useSelector(selectors.selectClipboard);
|
||||
const isFavoritesActive = useSelector(selectors.selectIsFavoritesActiveForCurrentUser);
|
||||
|
||||
const list = useSelector((state) => selectListById(state, id));
|
||||
const cardIds = useSelector((state) => selectFilteredCardIdsByListId(state, id));
|
||||
|
||||
// Shown on the add-card button when the board asks for it. Counted over every
|
||||
// card the list holds, not the ones the filter leaves standing: a filter
|
||||
// hides cards, it does not remove them, and a number that fell as the filter
|
||||
// narrowed would say the list had emptied.
|
||||
const showCardCounter = useSelector(
|
||||
(state) => selectors.selectCurrentBoard(state).showCardCounter,
|
||||
);
|
||||
|
||||
const cardCount = useSelector((state) => selectCardCountByListId(state, id));
|
||||
|
||||
const { canEdit, canArchiveCards, canAddCard, canPasteCard, canDropCard } = useSelector(
|
||||
(state) => {
|
||||
const isEditModeEnabled = selectors.selectIsEditModeEnabled(state); // TODO: move out?
|
||||
@@ -148,6 +160,10 @@ const List = React.memo(({ id, index }) => {
|
||||
cardsWrapperRef.current.scrollTop = cardsWrapperRef.current.scrollHeight;
|
||||
}, [scrollBottomState]);
|
||||
|
||||
const cardCountNode = showCardCounter && cardCount !== null && (
|
||||
<span className={styles.addCardButtonCount}>{cardCount}</span>
|
||||
);
|
||||
|
||||
const ActionsPopup = usePopup(ActionsStep);
|
||||
const ArchiveCardsPopup = usePopup(ArchiveCardsStep);
|
||||
|
||||
@@ -274,6 +290,7 @@ const List = React.memo(({ id, index }) => {
|
||||
)}
|
||||
onClick={handleAddCardClick}
|
||||
>
|
||||
{cardCountNode}
|
||||
<PlusMathIcon className={styles.addCardButtonIcon} />
|
||||
<span className={styles.addCardButtonText}>
|
||||
{cardIds.length > 0 ? t('action.addAnotherCard') : t('action.addCard')}
|
||||
|
||||
@@ -33,6 +33,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
.addCardButtonCount {
|
||||
background: #17394d;
|
||||
border-radius: 10px;
|
||||
color: #dfe3e6;
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
// Same 20px line box as the label beside it, so the two are centred on
|
||||
// each other by construction rather than by a nudge that only holds at one
|
||||
// font size.
|
||||
line-height: 20px;
|
||||
margin-right: 6px;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.addCardButtonIcon {
|
||||
height: 20px;
|
||||
padding: 0.64px;
|
||||
|
||||
@@ -398,6 +398,9 @@ export default {
|
||||
settings: 'Einstellungen',
|
||||
shared: 'Geteilt',
|
||||
sharedWithMe_title: 'Mit mir geteilt',
|
||||
showCardCounter: 'Kartenzähler in Listen anzeigen',
|
||||
showCardCounterHint:
|
||||
'Zeigt die Anzahl der Karten einer Liste auf ihrer Schaltfläche zum Hinzufügen einer Karte an. Gezählt werden alle Karten der Liste, nicht nur die von einem Filter übrig gelassenen.',
|
||||
showOnFrontOfCard: 'Auf der Vorderseite der Karte anzeigen',
|
||||
smtp: 'SMTP',
|
||||
sortList_title: 'Liste sortieren',
|
||||
|
||||
@@ -375,6 +375,9 @@ export default {
|
||||
settings: 'Settings',
|
||||
shared: 'Shared',
|
||||
sharedWithMe_title: 'Shared With Me',
|
||||
showCardCounter: 'Show card counter in lists',
|
||||
showCardCounterHint:
|
||||
'Adds the number of cards a list holds to its add-card button. The count covers every card in the list, not only the ones a filter leaves visible.',
|
||||
showOnFrontOfCard: 'Show on front of card',
|
||||
smtp: 'SMTP',
|
||||
sortList_title: 'Sort List',
|
||||
|
||||
@@ -36,6 +36,7 @@ export default class extends BaseModel {
|
||||
defaultCardType: attr(),
|
||||
limitCardTypesToDefaultOne: attr(),
|
||||
alwaysDisplayCardCreator: attr(),
|
||||
showCardCounter: attr(),
|
||||
displayCardAges: attr(),
|
||||
expandTaskListsByDefault: attr(),
|
||||
context: attr(),
|
||||
|
||||
@@ -65,6 +65,27 @@ export const makeSelectFilteredCardIdsByListId = () =>
|
||||
|
||||
export const selectFilteredCardIdsByListId = makeSelectFilteredCardIdsByListId();
|
||||
|
||||
// How many cards the list holds.
|
||||
//
|
||||
// Unfiltered on purpose: a filter hides cards, it does not remove them, and a
|
||||
// count that fell as the filter narrowed would say the list had emptied.
|
||||
export const makeSelectCardCountByListId = () =>
|
||||
createSelector(
|
||||
orm,
|
||||
(_, id) => id,
|
||||
({ List }, id) => {
|
||||
const listModel = List.withId(id);
|
||||
|
||||
if (!listModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return listModel.getCardsModelArray().length;
|
||||
},
|
||||
);
|
||||
|
||||
export const selectCardCountByListId = makeSelectCardCountByListId();
|
||||
|
||||
export const selectIsListWithIdAvailableForCurrentUser = createSelector(
|
||||
orm,
|
||||
(_, id) => id,
|
||||
@@ -171,6 +192,8 @@ export default {
|
||||
selectCardIdsByListId,
|
||||
makeSelectFilteredCardIdsByListId,
|
||||
selectFilteredCardIdsByListId,
|
||||
makeSelectCardCountByListId,
|
||||
selectCardCountByListId,
|
||||
selectIsListWithIdAvailableForCurrentUser,
|
||||
selectCurrentListId,
|
||||
selectCurrentList,
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
* type: boolean
|
||||
* description: Whether to always display card creators
|
||||
* example: false
|
||||
* showCardCounter:
|
||||
* type: boolean
|
||||
* description: Whether a list's add-card button shows how many cards it holds
|
||||
* example: false
|
||||
* displayCardAges:
|
||||
* type: boolean
|
||||
* description: Whether to display card ages
|
||||
@@ -124,6 +128,9 @@ module.exports = {
|
||||
alwaysDisplayCardCreator: {
|
||||
type: 'boolean',
|
||||
},
|
||||
showCardCounter: {
|
||||
type: 'boolean',
|
||||
},
|
||||
displayCardAges: {
|
||||
type: 'boolean',
|
||||
},
|
||||
@@ -167,6 +174,7 @@ module.exports = {
|
||||
'defaultCardType',
|
||||
'limitCardTypesToDefaultOne',
|
||||
'alwaysDisplayCardCreator',
|
||||
'showCardCounter',
|
||||
'displayCardAges',
|
||||
'expandTaskListsByDefault',
|
||||
);
|
||||
@@ -186,6 +194,7 @@ module.exports = {
|
||||
'defaultCardType',
|
||||
'limitCardTypesToDefaultOne',
|
||||
'alwaysDisplayCardCreator',
|
||||
'showCardCounter',
|
||||
'displayCardAges',
|
||||
'expandTaskListsByDefault',
|
||||
'isSubscribed',
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
* - defaultCardType
|
||||
* - limitCardTypesToDefaultOne
|
||||
* - alwaysDisplayCardCreator
|
||||
* - showCardCounter
|
||||
* - displayCardAges
|
||||
* - expandTaskListsByDefault
|
||||
* - createdAt
|
||||
@@ -68,6 +69,11 @@
|
||||
* default: false
|
||||
* description: Whether to always display the card creator
|
||||
* example: false
|
||||
* showCardCounter:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* description: Whether a list's add-card button shows how many cards it holds
|
||||
* example: false
|
||||
* displayCardAges:
|
||||
* type: boolean
|
||||
* default: false
|
||||
@@ -143,6 +149,12 @@ module.exports = {
|
||||
defaultsTo: false,
|
||||
columnName: 'always_display_card_creator',
|
||||
},
|
||||
// Whether a list's add-card button carries the number of cards it holds.
|
||||
showCardCounter: {
|
||||
type: 'boolean',
|
||||
defaultsTo: false,
|
||||
columnName: 'show_card_counter',
|
||||
},
|
||||
displayCardAges: {
|
||||
type: 'boolean',
|
||||
defaultsTo: false,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*!
|
||||
* Copyright (c) 2026 PLANKA Software GmbH
|
||||
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
// Whether a list says how many cards it holds, on its add-card button. Off by
|
||||
// default: a count is useful to a board that is being kept to a size and noise
|
||||
// to one that is not.
|
||||
module.exports.up = async (knex) => {
|
||||
await knex.schema.alterTable('board', (table) => {
|
||||
table.boolean('show_card_counter').notNullable().defaultTo(false);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.down = async (knex) => {
|
||||
await knex.schema.alterTable('board', (table) => {
|
||||
table.dropColumn('show_card_counter');
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user