Add negative label filtering for cards

This commit is contained in:
Symon
2026-06-05 16:43:45 +03:00
parent 856768c45e
commit 55d0e2f877
23 changed files with 638 additions and 235 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),
);
}
const cards = await Card.qm.getByEndlessListId(list.id, {
@@ -248,6 +272,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(', ')})
)`;
}
query += ` LIMIT ${LIMIT}`;
let queryResult;
@@ -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']);
});
});