Merge pull request #1683 from symonbaikov/feat/negative-label-filter

Add negative label filtering for cards

Two conflicts, both from work that landed while this branch was open.

In the endless list query, master had added an ORDER BY so the cursor and the
limit agree. The exclusion clause belongs in the WHERE part, so it is placed
before it rather than after; the other way round the statement does not parse.

The label item had been restructured here for the tri-state filter and had
gained a tooltip on master. The restructured version is kept and the tooltip
put back on top of it, which also brings back the translation hook this branch
had dropped.
This commit is contained in:
Daniel Hiller
2026-09-17 00:46:31 +02:00
23 changed files with 641 additions and 238 deletions
+25
View File
@@ -57,6 +57,13 @@
* schema:
* type: string
* example: 1357158568008091268,1357158568008091269
* - name: excludedLabelIds
* in: query
* required: false
* description: Comma-separated label IDs to exclude from results
* schema:
* type: string
* example: 1357158568008091268,1357158568008091269
* responses:
* 200:
* description: Cards retrieved successfully
@@ -189,6 +196,7 @@ module.exports = {
},
userIds: idsInput,
labelIds: idsInput,
excludedLabelIds: idsInput,
},
exits: {
@@ -235,12 +243,28 @@ module.exports = {
}
let filterLabelIds;
let filterExcludedLabelIds;
if (inputs.labelIds) {
const labels = await Label.qm.getByBoardId(list.boardId);
const availableLabelIdsSet = new Set(sails.helpers.utils.mapRecords(labels));
filterLabelIds = _.uniq(inputs.labelIds.split(','));
filterLabelIds = filterLabelIds.filter((labelId) => availableLabelIdsSet.has(labelId));
if (inputs.excludedLabelIds) {
filterExcludedLabelIds = _.uniq(inputs.excludedLabelIds.split(','));
filterExcludedLabelIds = filterExcludedLabelIds.filter((labelId) =>
availableLabelIdsSet.has(labelId),
);
}
} else if (inputs.excludedLabelIds) {
const labels = await Label.qm.getByBoardId(list.boardId);
const availableLabelIdsSet = new Set(sails.helpers.utils.mapRecords(labels));
filterExcludedLabelIds = _.uniq(inputs.excludedLabelIds.split(','));
filterExcludedLabelIds = filterExcludedLabelIds.filter((labelId) =>
availableLabelIdsSet.has(labelId),
);
}
// ISO 8601 allows forms Postgres rejects as a timestamp (`2026`, `2026-W35-3`,
@@ -257,6 +281,7 @@ module.exports = {
search: inputs.search,
userIds: filterUserIds,
labelIds: filterLabelIds,
excludedLabelIds: filterExcludedLabelIds,
});
const cardIds = sails.helpers.utils.mapRecords(cards);
+21 -2
View File
@@ -38,8 +38,13 @@ const getByListId = async (listId, { exceptIdOrIds, sort = ['position', 'id'] }
return defaultFind(criteria, { sort });
};
const getByEndlessListId = async (listId, { before, search, userIds, labelIds }) => {
if (search || userIds || labelIds) {
const getByEndlessListId = async (
listId,
{ before, search, userIds, labelIds, excludedLabelIds },
) => {
const hasExcludedLabelIds = excludedLabelIds && excludedLabelIds.length > 0;
if (search || userIds || labelIds || hasExcludedLabelIds) {
if (userIds && userIds.length === 0) {
return [];
}
@@ -108,6 +113,20 @@ const getByEndlessListId = async (listId, { before, search, userIds, labelIds })
query += ` AND card_label.label_id IN (${inValues.join(', ')})`;
}
if (hasExcludedLabelIds) {
const inValues = excludedLabelIds.map((labelId) => {
queryValues.push(labelId);
return `$${queryValues.length}`;
});
query += ` AND NOT EXISTS (
SELECT 1
FROM card_label AS excluded_card_label
WHERE excluded_card_label.card_id = card.id
AND excluded_card_label.label_id IN (${inValues.join(', ')})
)`;
}
// Must match the cursor built from the last returned card, otherwise the
// limit cuts an arbitrary slice and pages skip or repeat cards
query += ' ORDER BY card.list_changed_at DESC, card.id DESC';
@@ -0,0 +1,82 @@
const { expect } = require('chai');
describe('Card label filters', () => {
let originalSendNativeQuery;
let nativeQueryCalls;
beforeEach(() => {
nativeQueryCalls = [];
originalSendNativeQuery = sails.sendNativeQuery;
sails.sendNativeQuery = async (query, values) => {
nativeQueryCalls.push({ query, values });
return {
rows: [
{
id: 'card-1',
list_id: 'list-1',
board_id: 'board-1',
type: Card.Types.PROJECT,
position: null,
name: 'Frontend task',
list_changed_at: new Date('2026-01-01T00:00:00.000Z'),
},
],
};
};
});
afterEach(() => {
sails.sendNativeQuery = originalSendNativeQuery;
});
it('uses an included label condition when labelIds are provided', async () => {
await Card.qm.getByEndlessListId('list-1', {
labelIds: ['label-1', 'label-2'],
});
expect(nativeQueryCalls).to.have.length(1);
expect(nativeQueryCalls[0].query).to.include('LEFT JOIN card_label');
expect(nativeQueryCalls[0].query).to.include('card_label.label_id IN ($2, $3)');
expect(nativeQueryCalls[0].values).to.deep.equal(['list-1', 'label-1', 'label-2']);
});
it('uses a NOT EXISTS condition when excludedLabelIds are provided', async () => {
await Card.qm.getByEndlessListId('list-1', {
excludedLabelIds: ['label-1', 'label-2'],
});
expect(nativeQueryCalls).to.have.length(1);
expect(nativeQueryCalls[0].query).to.include('NOT EXISTS');
expect(nativeQueryCalls[0].query).to.include('excluded_card_label.card_id = card.id');
expect(nativeQueryCalls[0].query).to.include('excluded_card_label.label_id IN ($2, $3)');
expect(nativeQueryCalls[0].values).to.deep.equal(['list-1', 'label-1', 'label-2']);
});
it('combines included and excluded labels with AND semantics', async () => {
await Card.qm.getByEndlessListId('list-1', {
labelIds: ['label-1'],
excludedLabelIds: ['label-2'],
});
expect(nativeQueryCalls).to.have.length(1);
expect(nativeQueryCalls[0].query).to.include('card_label.label_id IN ($2)');
expect(nativeQueryCalls[0].query).to.include('NOT EXISTS');
expect(nativeQueryCalls[0].query).to.include('excluded_card_label.label_id IN ($3)');
expect(nativeQueryCalls[0].values).to.deep.equal(['list-1', 'label-1', 'label-2']);
});
it('combines search and excluded label filters', async () => {
await Card.qm.getByEndlessListId('list-1', {
search: 'task',
excludedLabelIds: ['label-1'],
});
expect(nativeQueryCalls).to.have.length(1);
expect(nativeQueryCalls[0].query).to.include('card.name ILIKE ALL');
expect(nativeQueryCalls[0].query).to.include('NOT EXISTS');
expect(nativeQueryCalls[0].query).to.include('excluded_card_label.label_id IN ($3)');
expect(nativeQueryCalls[0].values).to.deep.equal(['list-1', 'task', 'label-1']);
});
});