WIP Group contributions in UI

This commit is contained in:
2026-07-26 16:04:56 +02:00
parent a7e017971a
commit 3cab203498
9 changed files with 416 additions and 45 deletions
+97 -15
View File
@@ -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);
}
}
}
+71 -22
View File
@@ -30,27 +30,76 @@
{{/if}}
<ul class="item-list contribution-list {{if @loading 'loading'}}">
{{#each this.contributionsFiltered as |contribution|}}
<li role="button" data-contribution-id={{contribution.id}}
{{action "openContributionDetails" contribution}}
class="{{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
<p class="meta">
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
<span class="category {{contribution.kind}}">({{contribution.kind}})</span>
<span class="title">{{contribution.description}}</span>
</p>
<p class="kredits-amount">
<span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span>
</p>
{{#unless contribution.vetoed}}
{{#unless (is-confirmed-contribution contribution)}}
<p class="voting">
{{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}}
<li role="button" data-group-id={{group.groupId}}
{{action "toggleGroup" group}}
class="grouped {{if (eq this.expandedGroup group.groupId) "expanded"}}">
<p class="meta">
<span class="avatars">
{{#each group.visibleContributors as |contributor|}}
<UserAvatar @contributor={{contributor}} />
{{/each}}
{{#if group.extraContributorCount}}
<span class="avatar-overflow">+{{group.extraContributorCount}}</span>
{{/if}}
</span>
<span class="category {{group.kind}}">({{group.kind}})</span>
<span class="title">{{group.description}}</span>
</p>
<p class="kredits-amount">
<span class="amount">{{group.amount}}</span><span class="symbol">₭S</span>
</p>
</li>
{{#if (eq this.expandedGroup group.groupId)}}
{{#each group.items key="id" as |contribution|}}
<li role="button" data-contribution-id={{contribution.id}}
{{action "openContributionDetails" contribution}}
class="group-item {{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
<p class="meta">
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
<span class="category {{contribution.kind}}">({{contribution.kind}})</span>
<span class="title">{{contribution.description}}</span>
</p>
<p class="kredits-amount">
<span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span>
</p>
{{#unless contribution.vetoed}}
{{#unless (is-confirmed-contribution contribution)}}
<p class="voting">
{{input type="button" class="button small danger" value="veto"
click=(action "veto" contribution.id)
disabled=contribution.hasPendingChanges}}
</p>
{{/unless}}
{{/unless}}
</li>
{{/each}}
{{/if}}
{{else}}
{{#each group.items as |contribution|}}
<li role="button" data-contribution-id={{contribution.id}}
{{action "openContributionDetails" contribution}}
class="{{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
<p class="meta">
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
<span class="category {{contribution.kind}}">({{contribution.kind}})</span>
<span class="title">{{contribution.description}}</span>
</p>
{{/unless}}
{{/unless}}
</li>
<p class="kredits-amount">
<span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span>
</p>
{{#unless contribution.vetoed}}
{{#unless (is-confirmed-contribution contribution)}}
<p class="voting">
{{input type="button" class="button small danger" value="veto"
click=(action "veto" contribution.id)
disabled=contribution.hasPendingChanges}}
</p>
{{/unless}}
{{/unless}}
</li>
{{/each}}
{{/if}}
{{/each}}
</ul>
</ul>
@@ -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);
})
});
@@ -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 {
@@ -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;
@@ -32,6 +32,22 @@
</a>
</p>
{{/if}}
{{#if this.siblingContributions.length}}
<div class="co-contributors">
<h4>Also contributed to this</h4>
<ul>
{{#each this.siblingContributions as |sibling|}}
<li>
<LinkTo @route="dashboard.contributions.show" @model={{sibling}} class="co-contributor">
<UserAvatar @contributor={{sibling.contributor}} />
<span class="name">{{sibling.contributor.name}}</span>
<span class="amount">{{sibling.amount}}</span><span class="symbol">₭S</span>
</LinkTo>
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{#if this.model.vetoed}}
<div class="hint vetoed">
<div class="icon">
+3 -6
View File
@@ -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;