diff --git a/docs/userGuide/syntax/cardstacks.md b/docs/userGuide/syntax/cardstacks.md index a04122e701..e392fa304c 100644 --- a/docs/userGuide/syntax/cardstacks.md +++ b/docs/userGuide/syntax/cardstacks.md @@ -200,15 +200,42 @@ The `` element allows you to: - Bootstrap color names (e.g., `success`, `danger`, `primary`, `warning`, `info`, `secondary`, `light`, `dark`) - Any tags used in cards but not defined in `` will appear after the defined tags with default colors +### Disabling Tag Counts + +By default, tag badges display a count showing how many cards have that tag. You can disable this count display using the `disable-tag-count` attribute: + + +html + + + + Success is not final, failure is not fatal: it is the courage to continue that counts + + + In the middle of every difficulty lies opportunity + + + Do what you can, with what you have, where you are + + + Your time is limited, so don't waste it living someone else's life + + + + + +With `disable-tag-count` enabled, tag badges will only show the tag name and selection indicator, without the numerical count. + **Options** `cardstack`: -| Name | Type | Default | Description | -| --------------- | --------- | ------- | --------------------------------------------------------------------------------- | -| blocks | `String` | `2` | Number of `card` columns per row.
Supports: `1`, `2`, `3`, `4`, `6` | -| searchable | `Boolean` | `false` | Whether the card stack is searchable. | -| show-select-all | `Boolean` | `true` | Whether the select all tag button appears. (`false` by default if total tags ≤ 3) | +| Name | Type | Default | Description | +| ------------------ | --------- | ------- | --------------------------------------------------------------------------------- | +| blocks | `String` | `2` | Number of `card` columns per row.
Supports: `1`, `2`, `3`, `4`, `6` | +| searchable | `Boolean` | `false` | Whether the card stack is searchable. | +| show-select-all | `Boolean` | `true` | Whether the select all tag button appears. (`false` by default if total tags ≤ 3) | +| disable-tag-count | `Boolean` | `false` | Whether to hide the tag count badges. By default, counts are shown. | `tags` (optional): A container element inside `cardstack` to define tag ordering and colors. diff --git a/packages/vue-components/src/__tests__/CardStack.spec.js b/packages/vue-components/src/__tests__/CardStack.spec.js index 9c6c7d7e44..3f9b5abe07 100644 --- a/packages/vue-components/src/__tests__/CardStack.spec.js +++ b/packages/vue-components/src/__tests__/CardStack.spec.js @@ -317,7 +317,7 @@ describe('CardStack', () => { }); test('should handle invalid tag-configs gracefully', async () => { - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { }); const wrapper = mount(CardStack, { propsData: { dataTagConfigs: 'invalid-json', @@ -366,4 +366,396 @@ describe('CardStack', () => { expect(wrapper.vm.getTextColor('#000000')).toBe('#fff'); expect(wrapper.vm.getTextColor('#333333')).toBe('#fff'); }); + + test('should initialize tag count correctly for custom tag configs', async () => { + const tagConfigs = JSON.stringify([ + { name: 'Success', color: '#28a745' }, + { name: 'Failure', color: '#dc3545' }, + ]); + const wrapper = mount(CardStack, { + propsData: { + dataTagConfigs: tagConfigs.replace(/"/g, '"'), + }, + slots: { default: CARDS_WITH_CUSTOM_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const { tagCounts } = wrapper.vm; + expect(tagCounts.get('Success')).toBe(1); + expect(tagCounts.get('Failure')).toBe(1); + expect(tagCounts.get('Neutral')).toBe(1); + }); + + test('should increment tag count when same tag appears in multiple cards', async () => { + const CARDS_WITH_DUPLICATE_TAGS = ` + + + + + + `; + const wrapper = mount(CardStack, { + slots: { default: CARDS_WITH_DUPLICATE_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const { tagCounts } = wrapper.vm; + expect(tagCounts.get('Tag1')).toBe(3); + expect(tagCounts.get('Tag2')).toBe(2); + }); + + test('should display tag count in the badge', async () => { + const CARDS_WITH_DUPLICATE_TAGS = ` + + + + `; + const wrapper = mount(CardStack, { + slots: { default: CARDS_WITH_DUPLICATE_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Check that count badge exists and displays correct numbers + const tagBadges = wrapper.findAll('.tag-badge'); + // First tag (Success) should show count 2 + expect(tagBadges[0].text()).toContain('Success'); + const firstTagCountBadge = tagBadges[0].find('.tag-count'); + expect(firstTagCountBadge.text()).toBe('2'); + + // Second tag (Failure) should show count 1 + expect(tagBadges[1].text()).toContain('Failure'); + const secondTagCountBadge = tagBadges[1].findAll('.tag-count')[0]; + expect(secondTagCountBadge.text()).toBe('1'); + }); + + test('should show count badge before the select indicator badge', async () => { + const CARDS_WITH_DUPLICATE_TAGS = ` + + + `; + const wrapper = mount(CardStack, { + slots: { default: CARDS_WITH_DUPLICATE_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const firstTagBadge = wrapper.find('.tag-badge'); + const tagIndicators = firstTagBadge.findAll('.badge'); + // Should have two indicators: count badge and select badge + expect(tagIndicators.length).toBe(2); + // First one is count, should display "2" + expect(tagIndicators[0].text()).toBe('2'); + // Second one is select indicator, should display ✓ (since allSelected is true initially) + expect(tagIndicators[1].text()).toContain('✓'); + }); + + test('should hide tag count when disableTagCount is true', async () => { + const CARDS_WITH_DUPLICATE_TAGS = ` + + + + `; + const wrapper = mount(CardStack, { + propsData: { + disableTagCount: true, + }, + slots: { default: CARDS_WITH_DUPLICATE_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const firstTagBadge = wrapper.find('.tag-badge'); + const countBadge = firstTagBadge.find('.tag-count'); + // Count badge should not exist when disableTagCount is true + expect(countBadge.exists()).toBe(false); + + // Should only have select indicator badge + const tagIndicators = firstTagBadge.findAll('.tag-indicator'); + expect(tagIndicators.length).toBe(1); + // The only indicator should be the select indicator with ✓ + expect(tagIndicators[0].text()).toContain('✓'); + }); + + test('should show tag count by default when disableTagCount is false', async () => { + const CARDS_WITH_DUPLICATE_TAGS = ` + + + + `; + const wrapper = mount(CardStack, { + propsData: { + disableTagCount: false, + }, + slots: { default: CARDS_WITH_DUPLICATE_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const firstTagBadge = wrapper.find('.tag-badge'); + const countBadge = firstTagBadge.find('.tag-count'); + // Count badge should exist when disableTagCount is false + expect(countBadge.exists()).toBe(true); + expect(countBadge.text()).toBe('2'); + + // Should have both count and select indicator badges + const tagIndicators = firstTagBadge.findAll('.badge'); + expect(tagIndicators.length).toBe(2); + }); + + test('should update tag counts reactively when search filters cards', async () => { + const CARDS_WITH_SEARCHABLE_TAGS = ` + + + + + + `; + const wrapper = mount(CardStack, { + propsData: { + searchable: true, + }, + slots: { default: CARDS_WITH_SEARCHABLE_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Initially, Tag1 has 3 cards and Tag2 has 2 cards + expect(wrapper.vm.tagCounts.get('Tag1')).toBe(3); + expect(wrapper.vm.tagCounts.get('Tag2')).toBe(2); + + // Simulate a search for "alpha" which only matches the first card (Tag1) + const searchInput = wrapper.find('input.search-bar'); + await searchInput.setValue('alpha'); + await searchInput.trigger('input'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.tagCounts.get('Tag1')).toBe(1); + expect(wrapper.vm.tagCounts.get('Tag2')).toBeUndefined(); + + // Verify the DOM reflects the updated count + const tagBadges = wrapper.findAll('.tag-badge'); + const tag1Badge = tagBadges.find(b => b.text().includes('Tag1')); + const tag1Count = tag1Badge.find('.tag-count'); + expect(tag1Count.text()).toBe('1'); + + const tag2Badge = tagBadges.find(b => b.text().includes('Tag2')); + const tag2Count = tag2Badge.find('.tag-count'); + expect(tag2Count.text()).toBe('0'); + + // Clear search - counts should go back to original + await searchInput.setValue(''); + await searchInput.trigger('input'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.tagCounts.get('Tag1')).toBe(3); + expect(wrapper.vm.tagCounts.get('Tag2')).toBe(2); + }); + + test('tag counts should not change when tags are toggled off', async () => { + const CARDS = ` + + + + `; + const wrapper = mount(CardStack, { + slots: { default: CARDS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Initially both tags are selected + expect(wrapper.vm.tagCounts.get('Tag1')).toBe(2); + expect(wrapper.vm.tagCounts.get('Tag2')).toBe(1); + + // Deselect Tag1 - counts should remain the same since counts ignore tag selection + wrapper.vm.updateTag('Tag1'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.tagCounts.get('Tag1')).toBe(2); + expect(wrapper.vm.tagCounts.get('Tag2')).toBe(1); + }); + + test('should show tag count by default when disableTagCount is not specified', async () => { + const CARDS_WITH_DUPLICATE_TAGS = ` + + + + `; + const wrapper = mount(CardStack, { + slots: { default: CARDS_WITH_DUPLICATE_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const firstTagBadge = wrapper.find('.tag-badge'); + const countBadge = firstTagBadge.find('.tag-count'); + // Count badge should exist by default (disableTagCount defaults to false) + expect(countBadge.exists()).toBe(true); + expect(countBadge.text()).toBe('3'); + }); + + test('should cover card mounted lifecycle hook registration', async () => { + const CARDS_FOR_MOUNTED = ` + + + `; + const wrapper = mount(CardStack, { + slots: { default: CARDS_FOR_MOUNTED }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Verify cards were registered in cardStack children + expect(wrapper.vm.cardStackRef.children.length).toBe(2); + + // Verify tags were added to rawTags + expect(wrapper.vm.cardStackRef.rawTags).toContain('Tag1'); + expect(wrapper.vm.cardStackRef.rawTags).toContain('Tag2'); + + // Verify tagMapping was updated + expect(wrapper.vm.cardStackRef.tagMapping.length).toBe(2); + + // Verify searchData was populated + expect(wrapper.vm.cardStackRef.searchData.size).toBe(2); + }); + + test('should display correct results count text for single and multiple results', async () => { + // Use cards with content to prevent them from being disabled + const SINGLE_CARD = ` + Content + `; + const wrapper = mount(CardStack, { + slots: { default: SINGLE_CARD }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Single result should show "1 result" + const resultsCount = wrapper.find('.results-count'); + expect(resultsCount.text()).toBe('1 result'); + + // Add another card and verify plural form + const MULTIPLE_CARDS = ` + Content 1 + Content 2 + `; + const wrapper2 = mount(CardStack, { + slots: { default: MULTIPLE_CARDS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper2.vm.$nextTick(); + + const resultsCount2 = wrapper2.find('.results-count'); + expect(resultsCount2.text()).toBe('2 results'); + }); + + test('should handle cards with comma-separated tags', async () => { + const CARDS_WITH_COMMA_TAGS = ` + + + `; + const wrapper = mount(CardStack, { + slots: { default: CARDS_WITH_COMMA_TAGS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Verify all unique tags are in the mapping + const tagNames = wrapper.vm.cardStackRef.tagMapping.map(t => t[0]); + expect(tagNames).toContain('Tag1'); + expect(tagNames).toContain('Tag2'); + expect(tagNames).toContain('Tag3'); + expect(tagNames).toContain('Tag4'); + + // Verify tag counts + expect(wrapper.vm.tagCounts.get('Tag1')).toBe(1); + expect(wrapper.vm.tagCounts.get('Tag2')).toBe(2); + expect(wrapper.vm.tagCounts.get('Tag3')).toBe(1); + expect(wrapper.vm.tagCounts.get('Tag4')).toBe(1); + }); + + test('should handle cards with comma-separated keywords', async () => { + const CARDS_WITH_KEYWORDS = ` + + `; + const wrapper = mount(CardStack, { + propsData: { searchable: true }, + slots: { default: CARDS_WITH_KEYWORDS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + const cards = wrapper.findAllComponents(Card); + // Verify keywords are formatted correctly with comma and space + expect(cards.at(0).vm.computeKeywords).toBe('key1, key2, key3'); + }); + + test('should handle search with multiple terms', async () => { + const CARDS_FOR_SEARCH = ` + Alpha content + Beta content + Gamma content + `; + const wrapper = mount(CardStack, { + propsData: { searchable: true }, + slots: { default: CARDS_FOR_SEARCH }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Initially all 3 cards should be visible + expect(wrapper.vm.matchingCardsCount).toBe(3); + + // Search for "alpha beta" - should match only first card + const searchInput = wrapper.find('input.search-bar'); + await searchInput.setValue('alpha beta'); + await searchInput.trigger('input'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.matchingCardsCount).toBe(1); + }); + + test('should show empty state when all tags are deselected', async () => { + const CARDS = ` + Content 1 + Content 2 + `; + const wrapper = mount(CardStack, { + slots: { default: CARDS }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Initially both cards visible + expect(wrapper.vm.matchingCardsCount).toBe(2); + + // Deselect all tags + wrapper.vm.hideAllTags(); + await wrapper.vm.$nextTick(); + + // No cards should be visible + expect(wrapper.vm.matchingCardsCount).toBe(0); + const resultsCount = wrapper.find('.results-count'); + expect(resultsCount.text()).toBe('0 result'); + }); + + test('should correctly handle disabled cards in tag count', async () => { + const CARDS_WITH_DISABLED = ` + + + + `; + const wrapper = mount(CardStack, { + slots: { default: CARDS_WITH_DISABLED }, + global: DEFAULT_GLOBAL_MOUNT_OPTIONS, + }); + await wrapper.vm.$nextTick(); + + // Disabled cards should not be counted + // Only 2 non-disabled cards with Tag1 + expect(wrapper.vm.tagCounts.get('Tag1')).toBe(2); + }); }); diff --git a/packages/vue-components/src/__tests__/__snapshots__/CardStack.spec.js.snap b/packages/vue-components/src/__tests__/__snapshots__/CardStack.spec.js.snap index d3b3e71494..cc25da812a 100644 --- a/packages/vue-components/src/__tests__/__snapshots__/CardStack.spec.js.snap +++ b/packages/vue-components/src/__tests__/__snapshots__/CardStack.spec.js.snap @@ -108,6 +108,11 @@ exports[`CardStack should not hide cards when no filter is provided 1`] = ` class="badge bg-primary tag-badge" > Short  + + 1 + diff --git a/packages/vue-components/src/cardstack/CardStack.vue b/packages/vue-components/src/cardstack/CardStack.vue index 3f9d32e4e4..7f537596d0 100644 --- a/packages/vue-components/src/cardstack/CardStack.vue +++ b/packages/vue-components/src/cardstack/CardStack.vue @@ -34,6 +34,9 @@ @click="updateTag(key[0])" > {{ key[0] }}  + + {{ tagCounts.get(key[0]) || 0 }} +     @@ -52,11 +55,83 @@