From 3cab203498577c8e27cafdeda9e2cd7bf8b661df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Sun, 26 Jul 2026 16:04:56 +0200 Subject: [PATCH] WIP Group contributions in UI --- app/components/contribution-list/component.js | 112 +++++++++++++++--- app/components/contribution-list/template.hbs | 93 +++++++++++---- .../dashboard/contributions/show.js | 7 ++ .../components/_contribution-details.scss | 49 ++++++++ app/styles/components/_contribution-list.scss | 56 +++++++++ .../dashboard/contributions/show.hbs | 16 +++ app/utils/contribution-grouping-key.js | 9 +- .../contribution-list/component-test.js | 85 ++++++++++++- .../dashboard/contributions/show-test.js | 34 ++++++ 9 files changed, 416 insertions(+), 45 deletions(-) create mode 100644 tests/unit/controllers/dashboard/contributions/show-test.js diff --git a/app/components/contribution-list/component.js b/app/components/contribution-list/component.js index 444d813..85491af 100644 --- a/app/components/contribution-list/component.js +++ b/app/components/contribution-list/component.js @@ -1,7 +1,7 @@ import Component from '@ember/component'; -import { computed } from '@ember/object'; +import EmberObject, { computed, observer } from '@ember/object'; import { sort } from '@ember/object/computed'; -import { isPresent } from '@ember/utils'; +import { isEmpty, isPresent } from '@ember/utils'; import { inject as service } from '@ember/service'; export default Component.extend({ @@ -12,6 +12,7 @@ export default Component.extend({ classNames: ['contributions'], selectedContribution: null, + expandedGroup: null, showQuickFilter: false, hideSmallContributions: false, @@ -33,23 +34,96 @@ export default Component.extend({ return this.contributions.mapBy('kind').uniq(); }), - contributionsFiltered: computed('contributions.[]', 'hideSmallContributions', 'contributorId', 'contributionKind', function() { - return this.contributions.filter(c => { - let included = true; + // Groups the contributions by their `groupId` (derived from `url`; see + // utils/contribution-grouping-key), then filters the groups: a group is + // included when any of its items matches the active filters, and when + // included all of its items render (grouped rows stay whole — filtering by + // a contributor does not collapse a group into a singleton). Preserves the + // input order so the controller's date-sort is retained. Manual + // contributions without a URL each form their own single-item group. + // + // Each group object exposes: groupId, items, contributors, contributorIds, + // amount (single contribution's, not a sum), kind, description, url, date, + // isGrouped, visibleContributors (capped at 3), extraContributorCount. + groupedContributions: computed('contributions.[]', 'hideSmallContributions', 'contributorId', 'contributionKind', function() { + const groupsMap = new Map(); + const orderedGroups = []; - if (this.hideSmallContributions && - c.amount <= 500) { included = false; } - - if (isPresent(this.contributorId) && - c.contributorId !== parseInt(this.contributorId)) { included = false; } - - if (isPresent(this.contributionKind) && - c.kind !== this.contributionKind) { included = false; } - - return included; + this.contributions.forEach(c => { + const groupId = c.groupId; + if (isEmpty(groupId)) { + orderedGroups.push({ groupId: null, items: [c] }); + } else if (groupsMap.has(groupId)) { + groupsMap.get(groupId).items.push(c); + } else { + const group = { groupId, items: [c] }; + groupsMap.set(groupId, group); + orderedGroups.push(group); + } }); + + return orderedGroups + .filter(raw => raw.items.any(c => this.matchesFilters(c))) + .map(raw => { + const items = raw.items; + const first = items.firstObject; + const contributorIds = items.mapBy('contributorId').uniq(); + const contributors = contributorIds + .map(id => this.contributors.findBy('id', id)) + .filter(Boolean); + + return EmberObject.create({ + groupId: raw.groupId, + items, + contributors, + contributorIds, + amount: first ? first.amount : null, + kind: first ? first.kind : null, + description: first ? first.description : null, + url: first ? first.url : null, + date: first ? first.date : null, + isGrouped: items.length > 1, + visibleContributors: contributors.slice(0, 3), + extraContributorCount: Math.max(0, contributors.length - 3) + }); + }); }), + // Shared filter predicates for a single contribution. Used to decide group + // inclusion in `groupedContributions`. A group is shown when any of its + // items passes; "hide small contributions" thus keeps a group as long as + // any item is above the threshold. + matchesFilters (c) { + if (this.hideSmallContributions && c.amount <= 500) return false; + if (isPresent(this.contributorId) && + c.contributorId !== parseInt(this.contributorId)) return false; + if (isPresent(this.contributionKind) && + c.kind !== this.contributionKind) return false; + return true; + }, + + // Auto-expands the group containing the selected contribution so the + // selected child row is visible with the `selected` highlight. + autoExpandSelected: observer('selectedContributionId', function() { + this._autoExpandSelected(); + }), + + _autoExpandSelected () { + const selectedId = this.selectedContributionId; + if (!selectedId) return; + const group = this.groupedContributions.find(g => + g.items.findBy('id', selectedId) + ); + if (group && group.isGrouped && this.expandedGroup !== group.groupId) { + this.set('expandedGroup', group.groupId); + } + }, + + init () { + this._super(...arguments); + this._autoExpandSelected(); + }, + actions: { veto (contributionId) { @@ -62,6 +136,14 @@ export default Component.extend({ openContributionDetails(contribution) { this.router.transitionTo('dashboard.contributions.show', contribution); + }, + + toggleGroup (group) { + if (this.expandedGroup === group.groupId) { + this.set('expandedGroup', null); + } else { + this.set('expandedGroup', group.groupId); + } } } diff --git a/app/components/contribution-list/template.hbs b/app/components/contribution-list/template.hbs index be603d0..e536656 100644 --- a/app/components/contribution-list/template.hbs +++ b/app/components/contribution-list/template.hbs @@ -30,27 +30,76 @@ {{/if}} \ No newline at end of file + diff --git a/app/controllers/dashboard/contributions/show.js b/app/controllers/dashboard/contributions/show.js index cd9a097..00e7d8f 100644 --- a/app/controllers/dashboard/contributions/show.js +++ b/app/controllers/dashboard/contributions/show.js @@ -1,12 +1,19 @@ import Controller from '@ember/controller'; import { computed } from '@ember/object'; +import { inject as service } from '@ember/service'; import config from 'kredits-web/config/environment'; export default Controller.extend({ + kredits: service(), + ipfsGatewayUrl: computed(function() { return config.ipfs.gatewayUrl; + }), + + siblingContributions: computed('model.groupId', 'kredits.contributions.[]', function() { + return this.kredits.siblingContributions(this.model); }) }); diff --git a/app/styles/components/_contribution-details.scss b/app/styles/components/_contribution-details.scss index a2071a0..bbd2d0f 100644 --- a/app/styles/components/_contribution-details.scss +++ b/app/styles/components/_contribution-details.scss @@ -49,6 +49,55 @@ section#contribution-details { text-decoration: underline; } } + + .co-contributors { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid $item-border-color; + + h4 { + font-size: 1.2rem; + margin-bottom: 1rem; + color: $body-text-color; + } + + ul { + list-style: none; + + li { + display: flex; + align-items: center; + padding: 0.5rem 0; + gap: 0.5rem; + + .co-contributor { + display: inline-flex; + align-items: center; + text-decoration: none; + color: $body-text-color; + flex: 1; + + .name { + margin-left: 0.5rem; + } + + .amount { + margin-left: auto; + font-weight: 500; + } + + .symbol { + font-size: 0.8rem; + padding-left: 0.2rem; + } + + &:hover .name { + color: $primary-color; + } + } + } + } + } } .actions { diff --git a/app/styles/components/_contribution-list.scss b/app/styles/components/_contribution-list.scss index 2c6034d..d6c781c 100644 --- a/app/styles/components/_contribution-list.scss +++ b/app/styles/components/_contribution-list.scss @@ -41,6 +41,62 @@ ul.contribution-list { opacity: 0.6; } + &.grouped { + grid-template-columns: auto 5rem; + + .avatars { + display: inline-flex; + align-items: center; + + img.avatar { + margin-right: 0; + margin-left: -0.6rem; + border: 2px solid $item-background-color; + z-index: 1; + position: relative; + + &:first-child { + margin-left: 0; + z-index: 3; + } + + &:nth-child(2) { + z-index: 2; + } + } + + .avatar-overflow { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin-left: -0.6rem; + border-radius: 1rem; + background-color: $blue; + color: #fff; + font-size: 0.8rem; + font-weight: 500; + border: 2px solid $item-background-color; + z-index: 0; + position: relative; + } + } + + &.expanded { + border-bottom: none; + } + } + + &.group-item { + padding-left: 2.8rem; + background-color: $item-highlighted-background-color; + + &:not(:last-child) { + border-bottom: 1px solid $item-border-color; + } + } + p { align-self: center; margin: 0; diff --git a/app/templates/dashboard/contributions/show.hbs b/app/templates/dashboard/contributions/show.hbs index 52d6b3d..a6a56ed 100644 --- a/app/templates/dashboard/contributions/show.hbs +++ b/app/templates/dashboard/contributions/show.hbs @@ -32,6 +32,22 @@

{{/if}} + {{#if this.siblingContributions.length}} +
+

Also contributed to this

+ +
+ {{/if}} {{#if this.model.vetoed}}
diff --git a/app/utils/contribution-grouping-key.js b/app/utils/contribution-grouping-key.js index 596ffb9..e036ab7 100644 --- a/app/utils/contribution-grouping-key.js +++ b/app/utils/contribution-grouping-key.js @@ -1,15 +1,12 @@ import { isEmpty } from '@ember/utils'; -// // Derives a stable grouping key for a contribution, so that contributions // created for the same issue/pull request (one per contributor) can be // linked together in the UI. // -// The bot writes the exact same `url` string (the html_url of the issue/PR) -// into every per-contributor IPFS document for a given piece of work, so the -// top-level `url` field on the model is sufficient as a grouping key. Manual -// contributions without a URL return null and are treated as singletons. -// +// Until we have identical IPFS documents for different contributions, we will +// use the URL as grouping key + export default function contributionGroupingKey (contribution) { let url = contribution ? contribution.url : null; if (isEmpty(url)) return null; diff --git a/tests/integration/components/contribution-list/component-test.js b/tests/integration/components/contribution-list/component-test.js index b7f87ba..2bb80b7 100644 --- a/tests/integration/components/contribution-list/component-test.js +++ b/tests/integration/components/contribution-list/component-test.js @@ -8,11 +8,17 @@ import contributions from '../../../fixtures/contributions'; module('Integration | Component | contribution-list', function(hooks) { setupRenderingTest(hooks); - test('it renders all contributions', async function(assert) { + test('it renders all contributions with grouped rows collapsed', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + this.set('fixtures', contributions); await render(hbs`{{contribution-list contributions=fixtures}}`); - assert.equal(this.element.querySelectorAll('li').length, 9); + // 9 contributions: 2 groups (ids 1+3, 7+8) + 5 singletons = 7 top-level rows + assert.equal(this.element.querySelectorAll('li').length, 7, '7 top-level rows when collapsed'); + assert.equal(this.element.querySelectorAll('li.grouped').length, 2, '2 grouped headers'); + assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'no expanded sub-items'); }); test('it renders filtered contributions', async function(assert) { @@ -23,12 +29,87 @@ module('Integration | Component | contribution-list', function(hooks) { await render(hbs`{{contribution-list contributions=fixtures showQuickFilter=true}}`); await fillIn('.filter-contributor select', '1'); + // Contributor 1 is in ids 1, 2, 5, 6, 8. Grouped rows stay whole: id 1's + // group (with id 3) and id 8's group (with id 7) show as grouped rows + // because id 1 / id 8 match; ids 2, 5, 6 are singletons. 5 top-level rows. assert.equal(this.element.querySelectorAll('li').length, 5, 'select contributor'); await click('.filter-contribution-size input'); + // Hide small (<=500): drops id 6 only. Grouped rows stay (matching items + // are 1500/1500). 4 top-level rows. assert.equal(this.element.querySelectorAll('li').length, 4, 'hide small contributions'); await fillIn('.filter-contribution-kind select', 'dev'); + // Kind dev (still filtered to contributor 1, hide small on): only groups + // where a single item passes all three filters survive. Group {1,3}: id 1 + // is contributor 1 + dev → included. Group {8,7}: id 8 is contributor 1 but + // community; id 7 is dev but contributor 3 → no item passes both → excluded. + // Singletons id 2 (ops), id 5 (design) → excluded. 1 top-level row. assert.equal(this.element.querySelectorAll('li').length, 1, 'select kind'); }); + + test('filtering by contributor keeps grouped rows whole', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + + this.set('fixtures', contributions); + await render(hbs`{{contribution-list contributions=fixtures showQuickFilter=true}}`); + + await fillIn('.filter-contributor select', '1'); + + // The first row is the group containing id 1 (contributor 1) and id 3 + // (contributor 2) — it stays grouped, not collapsed to a singleton. + const firstRow = this.element.querySelector('li'); + assert.ok(firstRow.classList.contains('grouped'), 'first row is still grouped'); + assert.equal(firstRow.querySelectorAll('.avatars img').length, 2, 'shows both contributors avatars'); + }); + + test('grouped row expands on click to reveal sub-items', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + + this.set('fixtures', contributions); + await render(hbs`{{contribution-list contributions=fixtures}}`); + + assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'no sub-items when collapsed'); + + await click('.grouped:first-child'); + + assert.equal(this.element.querySelectorAll('li.group-item').length, 2, '2 sub-items when expanded'); + assert.ok(this.element.querySelector('.grouped').classList.contains('expanded'), 'header has expanded class'); + + await click('.grouped:first-child'); + assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'sub-items hidden when collapsed'); + }); + + test('grouped row shows stacked avatars and single amount', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + + this.set('fixtures', contributions); + await render(hbs`{{contribution-list contributions=fixtures}}`); + + const firstGroup = this.element.querySelector('.grouped'); + // Group ids 1+3 → 2 contributors → 2 avatars, no overflow + assert.equal(firstGroup.querySelectorAll('.avatars img').length, 2, '2 avatar images'); + assert.equal(firstGroup.querySelectorAll('.avatar-overflow').length, 0, 'no overflow bubble for 2 contributors'); + // Single amount (first item's amount = 1500, not the sum) + assert.equal(firstGroup.querySelector('.amount').textContent.trim(), '1500', 'shows single contribution amount'); + }); + + test('auto-expands when selectedContributionId is a group child', async function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + + this.set('fixtures', contributions); + this.set('selectedId', 3); + + await render(hbs`{{contribution-list contributions=fixtures selectedContributionId=selectedId}}`); + + assert.equal(this.element.querySelectorAll('li.group-item').length, 2, 'group auto-expanded'); + + const selectedChild = this.element.querySelector('.group-item.selected'); + assert.ok(selectedChild, 'selected child has selected class'); + assert.equal(selectedChild.dataset.contributionId, '3', 'correct child is selected'); + }); }); diff --git a/tests/unit/controllers/dashboard/contributions/show-test.js b/tests/unit/controllers/dashboard/contributions/show-test.js new file mode 100644 index 0000000..724af21 --- /dev/null +++ b/tests/unit/controllers/dashboard/contributions/show-test.js @@ -0,0 +1,34 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import contributors from '../../../../fixtures/contributors'; +import contributions from '../../../../fixtures/contributions'; + +module('Unit | Controller | dashboard/contributions/show', function(hooks) { + setupTest(hooks); + + test('#siblingContributions returns co-contributor contributions for a grouped contribution', function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + kredits.set('contributions', contributions); + + let controller = this.owner.lookup('controller:dashboard.contributions.show'); + // id=1 shares a url with id=3 + controller.set('model', contributions.findBy('id', 1)); + + const siblings = controller.siblingContributions; + assert.equal(siblings.length, 1, 'one sibling'); + assert.equal(siblings[0].id, 3, 'sibling is contribution 3'); + }); + + test('#siblingContributions returns empty for a manual contribution without url', function(assert) { + let kredits = this.owner.lookup('service:kredits'); + kredits.set('contributors', contributors); + kredits.set('contributions', contributions); + + let controller = this.owner.lookup('controller:dashboard.contributions.show'); + // id=2 has no url + controller.set('model', contributions.findBy('id', 2)); + + assert.deepEqual(controller.siblingContributions, [], 'no siblings for manual contribution'); + }); +});