Compare commits

..

1 Commits

Author SHA1 Message Date
raucao ed9d78315b Merge pull request 'Switch to Node.js 22+' (#232) from dev/node_22 into master
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 1m29s
Reviewed-on: #232
2026-07-26 12:48:17 +00:00
16 changed files with 62 additions and 847 deletions
+15 -98
View File
@@ -1,7 +1,7 @@
import Component from '@ember/component'; import Component from '@ember/component';
import EmberObject, { computed, observer } from '@ember/object'; import { computed } from '@ember/object';
import { sort } from '@ember/object/computed'; import { sort } from '@ember/object/computed';
import { isEmpty, isPresent } from '@ember/utils'; import { isPresent } from '@ember/utils';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
export default Component.extend({ export default Component.extend({
@@ -12,7 +12,6 @@ export default Component.extend({
classNames: ['contributions'], classNames: ['contributions'],
selectedContribution: null, selectedContribution: null,
expandedGroup: null,
showQuickFilter: false, showQuickFilter: false,
hideSmallContributions: false, hideSmallContributions: false,
@@ -34,97 +33,23 @@ export default Component.extend({
return this.contributions.mapBy('kind').uniq(); return this.contributions.mapBy('kind').uniq();
}), }),
// Groups the contributions by their `groupId` (derived from `url`; see contributionsFiltered: computed('contributions.[]', 'hideSmallContributions', 'contributorId', 'contributionKind', function() {
// utils/contribution-grouping-key), then filters the groups: a group is return this.contributions.filter(c => {
// included when any of its items matches the active filters, and when let included = true;
// 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 = [];
this.contributions.forEach(c => { if (this.hideSmallContributions &&
const groupId = c.groupId; c.amount <= 500) { included = false; }
if (isEmpty(groupId)) {
orderedGroups.push({ groupId: null, items: [c] }); if (isPresent(this.contributorId) &&
} else if (groupsMap.has(groupId)) { c.contributorId !== parseInt(this.contributorId)) { included = false; }
groupsMap.get(groupId).items.push(c);
} else { if (isPresent(this.contributionKind) &&
const group = { groupId, items: [c] }; c.kind !== this.contributionKind) { included = false; }
groupsMap.set(groupId, group);
orderedGroups.push(group); return included;
}
}); });
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({
key: raw.groupId || `c-${first.id}`,
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: { actions: {
veto (contributionId) { veto (contributionId) {
@@ -137,14 +62,6 @@ export default Component.extend({
openContributionDetails(contribution) { openContributionDetails(contribution) {
this.router.transitionTo('dashboard.contributions.show', 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);
}
} }
} }
+22 -71
View File
@@ -30,76 +30,27 @@
{{/if}} {{/if}}
<ul class="item-list contribution-list {{if @loading 'loading'}}"> <ul class="item-list contribution-list {{if @loading 'loading'}}">
{{#each this.groupedContributions key="key" as |group|}} {{#each this.contributionsFiltered as |contribution|}}
{{#if group.isGrouped}} <li role="button" data-contribution-id={{contribution.id}}
<li role="button" data-group-id={{group.groupId}} {{action "openContributionDetails" contribution}}
{{action "toggleGroup" group}} class="{{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
class="grouped {{if (eq this.expandedGroup group.groupId) "expanded"}}"> <p class="meta">
<p class="meta"> <span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
<span class="avatars"> <span class="category {{contribution.kind}}">({{contribution.kind}})</span>
{{#each group.visibleContributors as |contributor|}} <span class="title">{{contribution.description}}</span>
<UserAvatar @contributor={{contributor}} /> </p>
{{/each}} <p class="kredits-amount">
{{#if group.extraContributorCount}} <span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span>
<span class="avatar-overflow">+{{group.extraContributorCount}}</span> </p>
{{/if}} {{#unless contribution.vetoed}}
</span> {{#unless (is-confirmed-contribution contribution)}}
<span class="category {{group.kind}}">({{group.kind}})</span> <p class="voting">
<span class="title">{{group.description}}</span> {{input type="button" class="button small danger" value="veto"
</p> click=(action "veto" contribution.id)
<p class="kredits-amount"> disabled=contribution.hasPendingChanges}}
<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 key="id" 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>
<p class="kredits-amount"> {{/unless}}
<span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span> {{/unless}}
</p> </li>
{{#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}} {{/each}}
</ul> </ul>
@@ -1,19 +1,12 @@
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import { computed } from '@ember/object'; import { computed } from '@ember/object';
import { inject as service } from '@ember/service';
import config from 'kredits-web/config/environment'; import config from 'kredits-web/config/environment';
export default Controller.extend({ export default Controller.extend({
kredits: service(),
ipfsGatewayUrl: computed(function() { ipfsGatewayUrl: computed(function() {
return config.ipfs.gatewayUrl; return config.ipfs.gatewayUrl;
}),
siblingContributions: computed('model.groupId', 'kredits.contributions.[]', function() {
return this.kredits.siblingContributions(this.model);
}) })
}); });
-10
View File
@@ -2,8 +2,6 @@ import EmberObject, { computed } from '@ember/object';
import { isEmpty, isPresent } from '@ember/utils'; import { isEmpty, isPresent } from '@ember/utils';
import moment from 'moment'; import moment from 'moment';
import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key';
export default EmberObject.extend({ export default EmberObject.extend({
// Contract // Contract
id: null, id: null,
@@ -46,14 +44,6 @@ export default EmberObject.extend({
return isPresent(this.pendingTx); return isPresent(this.pendingTx);
}), }),
// Stable key shared by all per-contributor contributions created for the
// same piece of work (derived from `url`, `description`, `date`, `amount`,
// and `kind`). Contributions without enough fields to derive a key return
// null and are treated as singletons (not grouped).
groupId: computed('url', 'description', 'date', 'amount', 'kind', function() {
return contributionGroupingKey(this);
}),
serialize () { serialize () {
return JSON.stringify(this); return JSON.stringify(this);
} }
-68
View File
@@ -227,74 +227,6 @@ export default Service.extend({
return kreditsByContributor; return kreditsByContributor;
}), }),
// Groups contributions that were created together for the same
// issue/pull request (one contribution per contributor). The grouping key
// is the contribution's `url` (see utils/contribution-grouping-key).
// Manual contributions without a URL each form their own single-item group
// (they are never collapsed together, since `null` is not a real key).
//
// Each group is an EmberObject with: groupId, items, contributors,
// contributorIds, totalAmount, kind, description, url, date, isGrouped
// (true when more than one contribution shares the key).
contributionsGrouped: computed('contributions.[]', function() {
const groupsMap = new Map();
const singletons = [];
this.contributions.forEach(c => {
const groupId = c.groupId;
if (isEmpty(groupId)) {
singletons.push(c);
} else if (groupsMap.has(groupId)) {
groupsMap.get(groupId).items.push(c);
} else {
groupsMap.set(groupId, { groupId, items: [c] });
}
});
const groups = [
...groupsMap.values(),
...singletons.map(c => ({ groupId: null, items: [c] }))
];
return groups.map(group => {
const items = group.items;
const first = items.firstObject;
const contributorIds = items.mapBy('contributorId').uniq();
const contributors = contributorIds
.map(id => this.contributors.findBy('id', id))
.filter(Boolean);
const totalAmount = items
.mapBy('amount')
.reduce((a, b) => a + (b || 0), 0);
return EmberObject.create({
groupId: group.groupId,
items,
contributors,
contributorIds,
totalAmount,
kind: first ? first.kind : null,
description: first ? first.description : null,
url: first ? first.url : null,
date: first ? first.date : null,
isGrouped: items.length > 1
});
});
}),
// Returns the other contributions that share a grouping key with the given
// contribution (i.e. the co-contributors' contributions). Returns an empty
// array when the contribution has no group (manual, no URL) or has no
// siblings.
siblingContributions (contribution) {
if (!contribution) return [];
const groupId = contribution.groupId;
if (isEmpty(groupId)) return [];
const group = this.contributionsGrouped.findBy('groupId', groupId);
if (!group) return [];
return group.items.filter(c => c !== contribution);
},
contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() { contributionsUnconfirmed: computed('contributions.[]', 'currentBlock', function() {
return this.contributions return this.contributions
.filter(c => c.confirmedAt > this.currentBlock); .filter(c => c.confirmedAt > this.currentBlock);
+4 -20
View File
@@ -97,7 +97,7 @@ main {
} }
} }
@include media-min(small) { @media (min-width: 550px) {
main { main {
&#dashboard { &#dashboard {
grid-column-gap: 4rem; grid-column-gap: 4rem;
@@ -107,9 +107,10 @@ main {
"stats contributions"; "stats contributions";
&.with-details { &.with-details {
grid-template-columns: 1fr; grid-column-gap: 3rem;
grid-template-columns: 2fr 4fr 2fr;
grid-template-areas: grid-template-areas:
"details"; "stats contributions details";
} }
} }
@@ -122,23 +123,6 @@ main {
} }
} }
@include media-min(medium) {
main#dashboard.with-details {
grid-column-gap: 3rem;
grid-template-columns: 2fr 4fr 2fr;
grid-template-areas:
"stats contributions details";
#details {
position: sticky;
top: 2rem;
align-self: start;
max-height: calc(100vh - 4rem);
overflow-y: auto;
}
}
}
main section { main section {
margin-bottom: 5rem; margin-bottom: 5rem;
@@ -49,55 +49,6 @@ section#contribution-details {
text-decoration: underline; 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 { .actions {
@@ -137,7 +88,7 @@ section#contribution-details {
// On small screens, hide intro text, contributor and contributions list when // On small screens, hide intro text, contributor and contributions list when
// showing details // showing details
@include media-max(medium) { @include media-max(small) {
#dashboard.with-details { #dashboard.with-details {
#contributions, #stats { #contributions, #stats {
display: none; display: none;
@@ -41,69 +41,6 @@ ul.contribution-list {
opacity: 0.6; opacity: 0.6;
} }
&.grouped {
grid-template-columns: auto 5rem;
.avatars {
display: inline-flex;
align-items: center;
vertical-align: middle;
img.avatar {
margin-right: 0;
margin-left: -0.6rem;
border: 2px solid $item-background-color;
box-sizing: content-box;
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: #232323;
color: #fff;
font-size: 0.8rem;
font-weight: 500;
border: 2px solid $item-background-color;
box-sizing: content-box;
z-index: 0;
position: relative;
}
}
&.expanded {
border-bottom: none;
}
}
&.group-item {
padding-left: 2.8rem;
background-color: rgba(255,255,255,0.05);
&.selected {
background-color: $item-highlighted-background-color;
}
&:not(:last-child) {
border-bottom: 1px solid $item-border-color;
}
}
p { p {
align-self: center; align-self: center;
margin: 0; margin: 0;
@@ -32,22 +32,6 @@
</a> </a>
</p> </p>
{{/if}} {{/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}} {{#if this.model.vetoed}}
<div class="hint vetoed"> <div class="hint vetoed">
<div class="icon"> <div class="icon">
-29
View File
@@ -1,29 +0,0 @@
import ethers from 'ethers';
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 key is a 6-char keccak256 prefix of a combination of fields:
// url (optional), description, date, amount, kind. When the URL is present
// (bot contributions) it is included; when absent (manual contributions)
// the remaining fields still produce a reliable key. All of description,
// date, amount, and kind must be present — if any is missing, the
// contribution is treated as a singleton (null key).
export default function contributionGroupingKey (contribution) {
if (!contribution) return null;
const { url, description, date, amount, kind } = contribution;
if (isEmpty(description) || isEmpty(date) || isEmpty(amount) || isEmpty(kind)) {
return null;
}
const input = isEmpty(url)
? `${description}|${date}|${amount}|${kind}`
: `${url}|${description}|${date}|${amount}|${kind}`;
return ethers.utils.id(input).slice(2, 8);
}
+1 -105
View File
@@ -61,7 +61,7 @@
"web3-utils": "^1.7.3" "web3-utils": "^1.7.3"
}, },
"engines": { "engines": {
"node": ">= 22" "node": ">= 14"
} }
}, },
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
@@ -5291,17 +5291,6 @@
"url": "https://bevry.me/fund" "url": "https://bevry.me/fund"
} }
}, },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"dev": true, "dev": true,
@@ -15178,14 +15167,6 @@
"node": "^10.12.0 || >=12.0.0" "node": "^10.12.0 || >=12.0.0"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/filesize": { "node_modules/filesize": {
"version": "6.4.0", "version": "6.4.0",
"dev": true, "dev": true,
@@ -15757,21 +15738,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.1", "version": "1.1.1",
"dev": true, "dev": true,
@@ -18830,14 +18796,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.4", "version": "3.3.4",
"dev": true, "dev": true,
@@ -23698,26 +23656,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/watchpack-chokidar2/node_modules/fsevents": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"deprecated": "Upgrade to fsevents v2 to mitigate potential security issues",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"dependencies": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
},
"engines": {
"node": ">= 4.0"
}
},
"node_modules/watchpack-chokidar2/node_modules/glob-parent": { "node_modules/watchpack-chokidar2/node_modules/glob-parent": {
"version": "3.1.0", "version": "3.1.0",
"dev": true, "dev": true,
@@ -27891,16 +27829,6 @@
"version": "2.3.0", "version": "2.3.0",
"dev": true "dev": true
}, },
"bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"dev": true,
"optional": true,
"requires": {
"file-uri-to-path": "1.0.0"
}
},
"bl": { "bl": {
"version": "4.1.0", "version": "4.1.0",
"dev": true, "dev": true,
@@ -35120,13 +35048,6 @@
"flat-cache": "^3.0.4" "flat-cache": "^3.0.4"
} }
}, },
"file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"dev": true,
"optional": true
},
"filesize": { "filesize": {
"version": "6.4.0", "version": "6.4.0",
"dev": true "dev": true
@@ -35550,13 +35471,6 @@
"version": "1.0.0", "version": "1.0.0",
"dev": true "dev": true
}, },
"fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"optional": true
},
"function-bind": { "function-bind": {
"version": "1.1.1", "version": "1.1.1",
"dev": true "dev": true
@@ -37677,13 +37591,6 @@
"version": "0.0.8", "version": "0.0.8",
"dev": true "dev": true
}, },
"nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"dev": true,
"optional": true
},
"nanoid": { "nanoid": {
"version": "3.3.4", "version": "3.3.4",
"dev": true "dev": true
@@ -41074,17 +40981,6 @@
"to-regex-range": "^2.1.0" "to-regex-range": "^2.1.0"
} }
}, },
"fsevents": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true,
"optional": true,
"requires": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
}
},
"glob-parent": { "glob-parent": {
"version": "3.1.0", "version": "3.1.0",
"dev": true, "dev": true,
+9 -11
View File
@@ -5,17 +5,15 @@ import processContributionData from 'kredits-web/utils/process-contribution-data
const items = []; const items = [];
const data = [ const data = [
{ id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', url: 'https://github.com/67P/kredits-contracts/pull/196', description: 'Chore/dependency updates', date: '2020-05-27' }, { id: 1, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev' },
{ id: 2, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'ops', description: 'Server maintenance', date: '2020-05-27' }, { id: 2, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'ops' },
{ id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', url: 'https://github.com/67P/kredits-contracts/pull/196', description: 'Chore/dependency updates', date: '2020-05-27' }, { id: 3, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'ops' },
{ id: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs', description: 'API documentation', date: '2020-05-28' }, { id: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs' },
{ id: 5, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'design', description: 'UI redesign', date: '2020-05-29' }, { id: 5, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'design' },
{ id: 6, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: true, amount: 500, kind: 'dev', description: 'Minor fix', date: '2020-05-30' }, { id: 6, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: true, amount: 500, kind: 'dev' },
{ id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231', description: 'Grouped contributions UI', date: '2020-06-01' }, { id: 7, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev' },
{ id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 5000, kind: 'dev', url: 'https://gitea.kosmos.org/kredits/kredits-web/issues/231', description: 'Grouped contributions UI', date: '2020-06-01' }, { id: 8, contributorId: 1, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'community' },
{ id: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs', description: 'Minor docs fix', date: '2020-06-02' }, { id: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs' },
{ id: 10, contributorId: 2, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', description: 'Pair programming session', date: '2020-06-03' },
{ id: 11, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: false, amount: 1500, kind: 'dev', description: 'Pair programming session', date: '2020-06-03' },
]; ];
data.forEach(attrs => { data.forEach(attrs => {
@@ -8,17 +8,11 @@ import contributions from '../../../fixtures/contributions';
module('Integration | Component | contribution-list', function(hooks) { module('Integration | Component | contribution-list', function(hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
test('it renders all contributions with grouped rows collapsed', async function(assert) { test('it renders all contributions', async function(assert) {
let kredits = this.owner.lookup('service:kredits');
kredits.set('contributors', contributors);
this.set('fixtures', contributions); this.set('fixtures', contributions);
await render(hbs`{{contribution-list contributions=fixtures}}`); await render(hbs`{{contribution-list contributions=fixtures}}`);
// 11 contributions: 3 groups ({1,3}, {7,8}, {10,11}) + 5 singletons = 8 rows assert.equal(this.element.querySelectorAll('li').length, 9);
assert.equal(this.element.querySelectorAll('li').length, 8, '8 top-level rows when collapsed');
assert.equal(this.element.querySelectorAll('li.grouped').length, 3, '3 grouped headers');
assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'no expanded sub-items');
}); });
test('it renders filtered contributions', async function(assert) { test('it renders filtered contributions', async function(assert) {
@@ -29,86 +23,12 @@ module('Integration | Component | contribution-list', function(hooks) {
await render(hbs`{{contribution-list contributions=fixtures showQuickFilter=true}}`); await render(hbs`{{contribution-list contributions=fixtures showQuickFilter=true}}`);
await fillIn('.filter-contributor select', '1'); 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'); assert.equal(this.element.querySelectorAll('li').length, 5, 'select contributor');
await click('.filter-contribution-size input'); await click('.filter-contribution-size input');
// Hide small (<=500): drops id 6 only. Grouped rows stay (matching items
// are 1500/5000). 4 top-level rows.
assert.equal(this.element.querySelectorAll('li').length, 4, 'hide small contributions'); assert.equal(this.element.querySelectorAll('li').length, 4, 'hide small contributions');
await fillIn('.filter-contribution-kind select', 'dev'); await fillIn('.filter-contribution-kind select', 'dev');
// Kind dev (still filtered to contributor 1, hide small on): only groups assert.equal(this.element.querySelectorAll('li').length, 1, 'select kind');
// where a single item passes all three filters survive. Group {1,3}: id 1
// is contributor 1 + dev → included. Group {7,8}: id 8 is contributor 1 +
// dev → included. Singletons id 2 (ops), id 5 (design) → excluded. 2 rows.
assert.equal(this.element.querySelectorAll('li').length, 2, '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');
}); });
}); });
@@ -1,34 +0,0 @@
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');
});
});
+7 -83
View File
@@ -23,7 +23,7 @@ module('Unit | Service | kredits', function(hooks) {
service.set('currentBlock', 1023); service.set('currentBlock', 1023);
const items = service.contributionsUnconfirmed; const items = service.contributionsUnconfirmed;
assert.equal(items.length, 5); assert.equal(items.length, 3);
}); });
test('#kreditsByContributor', function(assert) { test('#kreditsByContributor', function(assert) {
@@ -38,18 +38,18 @@ module('Unit | Service | kredits', function(hooks) {
const c1 = kreditsByContributor.find(k => k.contributor.id == 1); const c1 = kreditsByContributor.find(k => k.contributor.id == 1);
assert.equal(c1.amountConfirmed, 11500, 'correct amount confirmed'); assert.equal(c1.amountConfirmed, 11500, 'correct amount confirmed');
assert.equal(c1.amountUnconfirmed, 5000, 'correct amount unconfirmed'); assert.equal(c1.amountUnconfirmed, 1500, 'correct amount unconfirmed');
assert.equal(c1.amountTotal, 16500, 'correct amount total'); assert.equal(c1.amountTotal, 13000, 'correct amount total');
const c2 = kreditsByContributor.find(k => k.contributor.id == 2); const c2 = kreditsByContributor.find(k => k.contributor.id == 2);
assert.equal(c2.amountConfirmed, 3000, 'correct amount confirmed'); assert.equal(c2.amountConfirmed, 3000, 'correct amount confirmed');
assert.equal(c2.amountUnconfirmed, 1500, 'correct amount unconfirmed'); assert.equal(c2.amountUnconfirmed, 0, 'correct amount unconfirmed');
assert.equal(c2.amountTotal, 4500, 'correct amount total'); assert.equal(c2.amountTotal, 3000, 'correct amount total');
const c3 = kreditsByContributor.find(k => k.contributor.id == 3); const c3 = kreditsByContributor.find(k => k.contributor.id == 3);
assert.equal(c3.amountConfirmed, 0, 'correct amount confirmed'); assert.equal(c3.amountConfirmed, 0, 'correct amount confirmed');
assert.equal(c3.amountUnconfirmed, 6500, 'correct amount unconfirmed'); assert.equal(c3.amountUnconfirmed, 5000, 'correct amount unconfirmed');
assert.equal(c3.amountTotal, 6500, 'correct amount total'); assert.equal(c3.amountTotal, 5000, 'correct amount total');
}); });
test('#contributorsSorted', function(assert) { test('#contributorsSorted', function(assert) {
@@ -60,82 +60,6 @@ module('Unit | Service | kredits', function(hooks) {
assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name'); assert.equal(service.contributorsSorted[1].name, 'Manuel', 'sorts by name');
}); });
test('#contributionsGrouped groups contributions sharing a url', function(assert) {
let service = this.owner.lookup('service:kredits');
service.set('contributors', contributors);
service.set('contributions', contributions);
const grouped = service.contributionsGrouped;
// 11 contributions: 3 groups ({1,3}, {7,8}, {10,11}) + 5 singletons
assert.equal(grouped.length, 8, 'produces one group per unique key plus singletons');
const ghGroup = grouped.findBy('groupId', contributions.findBy('id', 1).groupId);
assert.ok(ghGroup, 'github url group exists');
assert.ok(ghGroup.isGrouped, 'github group is flagged as grouped');
assert.equal(ghGroup.items.length, 2, 'github group has two contributions');
assert.deepEqual(ghGroup.contributorIds, [1, 2], 'github group lists both contributor ids');
assert.equal(ghGroup.contributors.length, 2, 'github group resolves both contributors');
assert.equal(ghGroup.totalAmount, 3000, 'github group sums amounts (1500 + 1500)');
assert.equal(ghGroup.kind, 'dev', 'github group takes kind from first item');
assert.equal(ghGroup.url, 'https://github.com/67P/kredits-contracts/pull/196');
const giteaGroup = grouped.findBy('groupId', contributions.findBy('id', 7).groupId);
assert.ok(giteaGroup, 'gitea url group exists');
assert.ok(giteaGroup.isGrouped, 'gitea group is flagged as grouped');
assert.deepEqual(giteaGroup.contributorIds, [3, 1], 'gitea group lists both contributor ids');
assert.equal(giteaGroup.totalAmount, 10000, 'gitea group sums amounts (5000 + 5000)');
const manualGroup = grouped.findBy('groupId', contributions.findBy('id', 10).groupId);
assert.ok(manualGroup, 'manual (no-url) group exists');
assert.ok(manualGroup.isGrouped, 'manual group is flagged as grouped');
assert.deepEqual(manualGroup.contributorIds, [2, 3], 'manual group lists both contributor ids');
assert.equal(manualGroup.totalAmount, 3000, 'manual group sums amounts (1500 + 1500)');
});
test('#contributionsGrouped treats contributions without enough fields as singletons', function(assert) {
let service = this.owner.lookup('service:kredits');
service.set('contributors', contributors);
service.set('contributions', contributions);
const grouped = service.contributionsGrouped;
const singletons = grouped.filterBy('isGrouped', false);
// 5 singletons: ids 2, 4, 5, 6, 9 (each has unique description/date/amount/kind)
assert.equal(singletons.length, 5, 'one singleton group per non-grouped contribution');
singletons.forEach(g => {
assert.equal(g.items.length, 1, 'singleton group has exactly one item');
});
});
test('#siblingContributions returns co-contributor contributions for the same url', function(assert) {
let service = this.owner.lookup('service:kredits');
service.set('contributors', contributors);
service.set('contributions', contributions);
const c1 = contributions.findBy('id', 1);
const siblings = service.siblingContributions(c1);
assert.equal(siblings.length, 1, 'contribution 1 has one sibling');
assert.equal(siblings[0].id, 3, 'the sibling is contribution 3');
});
test('#siblingContributions returns empty for contributions without siblings', function(assert) {
let service = this.owner.lookup('service:kredits');
service.set('contributors', contributors);
service.set('contributions', contributions);
const c2 = contributions.findBy('id', 2);
assert.deepEqual(service.siblingContributions(c2), [], 'no siblings for unique contribution');
});
test('#siblingContributions returns empty for null contribution', function(assert) {
let service = this.owner.lookup('service:kredits');
service.set('contributors', contributors);
service.set('contributions', contributions);
assert.deepEqual(service.siblingContributions(null), [], 'no siblings for null');
});
test('#kreditsByContributor ignores unconfirmed contributions for contributors that are not loaded', function(assert) { test('#kreditsByContributor ignores unconfirmed contributions for contributors that are not loaded', function(assert) {
let service = this.owner.lookup('service:kredits'); let service = this.owner.lookup('service:kredits');
service.set('contributors', contributors); service.set('contributors', contributors);
@@ -1,99 +0,0 @@
import { module, test } from 'qunit';
import EmberObject from '@ember/object';
import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key';
function makeContribution (attrs) {
return EmberObject.create(attrs);
}
const baseAttrs = {
url: 'https://github.com/67P/kredits-contracts/pull/196',
description: 'Chore/dependency updates',
date: '2020-05-27',
amount: 1500,
kind: 'dev'
};
module('Unit | Utils | contribution-grouping-key', function() {
test('returns a 6-char hex string when all fields are present', function(assert) {
const c = makeContribution(baseAttrs);
const key = contributionGroupingKey(c);
assert.equal(key.length, 6, '6 chars');
assert.ok(/^[0-9a-f]{6}$/.test(key), 'is lowercase hex: ' + key);
});
test('returns the same key for identical contributions with url', function(assert) {
const a = makeContribution(baseAttrs);
const b = makeContribution(baseAttrs);
assert.equal(contributionGroupingKey(a), contributionGroupingKey(b));
});
test('returns the same key for identical contributions without url', function(assert) {
const attrs = { ...baseAttrs, url: null };
const a = makeContribution(attrs);
const b = makeContribution(attrs);
assert.equal(contributionGroupingKey(a), contributionGroupingKey(b));
});
test('returns different keys when url differs', function(assert) {
const a = makeContribution(baseAttrs);
const b = makeContribution({ ...baseAttrs, url: 'https://github.com/67P/kredits-contracts/pull/197' });
assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b));
});
test('returns different keys when amount differs', function(assert) {
const a = makeContribution(baseAttrs);
const b = makeContribution({ ...baseAttrs, amount: 5000 });
assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b));
});
test('returns different keys when kind differs', function(assert) {
const a = makeContribution(baseAttrs);
const b = makeContribution({ ...baseAttrs, kind: 'ops' });
assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b));
});
test('returns different keys when description differs', function(assert) {
const a = makeContribution(baseAttrs);
const b = makeContribution({ ...baseAttrs, description: 'Something else' });
assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b));
});
test('returns different keys when date differs', function(assert) {
const a = makeContribution(baseAttrs);
const b = makeContribution({ ...baseAttrs, date: '2020-05-28' });
assert.notEqual(contributionGroupingKey(a), contributionGroupingKey(b));
});
test('returns different keys for url vs no-url with otherwise identical fields', function(assert) {
const withUrl = makeContribution(baseAttrs);
const withoutUrl = makeContribution({ ...baseAttrs, url: null });
assert.notEqual(contributionGroupingKey(withUrl), contributionGroupingKey(withoutUrl));
});
test('returns null when description is missing', function(assert) {
const c = makeContribution({ ...baseAttrs, description: null });
assert.equal(contributionGroupingKey(c), null);
});
test('returns null when date is missing', function(assert) {
const c = makeContribution({ ...baseAttrs, date: null });
assert.equal(contributionGroupingKey(c), null);
});
test('returns null when amount is missing', function(assert) {
const c = makeContribution({ ...baseAttrs, amount: null });
assert.equal(contributionGroupingKey(c), null);
});
test('returns null when kind is missing', function(assert) {
const c = makeContribution({ ...baseAttrs, kind: null });
assert.equal(contributionGroupingKey(c), null);
});
test('returns null when contribution is null/undefined', function(assert) {
assert.equal(contributionGroupingKey(null), null);
assert.equal(contributionGroupingKey(undefined), null);
});
});