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}}
- {{#each this.contributionsFiltered as |contribution|}}
- -
-
-
- ({{contribution.kind}})
- {{contribution.description}}
-
-
- {{contribution.amount}}₭S
-
- {{#unless contribution.vetoed}}
- {{#unless (is-confirmed-contribution contribution)}}
-
- {{input type="button" class="button small danger" value="veto"
- click=(action "veto" contribution.id)
- disabled=contribution.hasPendingChanges}}
+ {{#each this.groupedContributions as |group|}}
+ {{#if group.isGrouped}}
+
-
+
+
+ {{#each group.visibleContributors as |contributor|}}
+
+ {{/each}}
+ {{#if group.extraContributorCount}}
+ +{{group.extraContributorCount}}
+ {{/if}}
+
+ ({{group.kind}})
+ {{group.description}}
+
+
+ {{group.amount}}₭S
+
+
+ {{#if (eq this.expandedGroup group.groupId)}}
+ {{#each group.items key="id" as |contribution|}}
+ -
+
+
+ ({{contribution.kind}})
+ {{contribution.description}}
+
+
+ {{contribution.amount}}₭S
+
+ {{#unless contribution.vetoed}}
+ {{#unless (is-confirmed-contribution contribution)}}
+
+ {{input type="button" class="button small danger" value="veto"
+ click=(action "veto" contribution.id)
+ disabled=contribution.hasPendingChanges}}
+
+ {{/unless}}
+ {{/unless}}
+
+ {{/each}}
+ {{/if}}
+ {{else}}
+ {{#each group.items as |contribution|}}
+ -
+
+
+ ({{contribution.kind}})
+ {{contribution.description}}
- {{/unless}}
- {{/unless}}
-
+
+ {{contribution.amount}}₭S
+
+ {{#unless contribution.vetoed}}
+ {{#unless (is-confirmed-contribution contribution)}}
+
+ {{input type="button" class="button small danger" value="veto"
+ click=(action "veto" contribution.id)
+ disabled=contribution.hasPendingChanges}}
+
+ {{/unless}}
+ {{/unless}}
+
+ {{/each}}
+ {{/if}}
{{/each}}
-
\ 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
+
+ {{#each this.siblingContributions as |sibling|}}
+ -
+
+
+ {{sibling.contributor.name}}
+ {{sibling.amount}}₭S
+
+
+ {{/each}}
+
+
+ {{/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');
+ });
+});