diff --git a/app/components/contribution-list/component.js b/app/components/contribution-list/component.js index 444d813..24cf332 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,97 @@ 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({ + 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: { veto (contributionId) { @@ -62,6 +137,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..4359668 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/models/contribution.js b/app/models/contribution.js index f57bbed..75bd7fe 100644 --- a/app/models/contribution.js +++ b/app/models/contribution.js @@ -2,6 +2,8 @@ import EmberObject, { computed } from '@ember/object'; import { isEmpty, isPresent } from '@ember/utils'; import moment from 'moment'; +import contributionGroupingKey from 'kredits-web/utils/contribution-grouping-key'; + export default EmberObject.extend({ // Contract id: null, @@ -44,6 +46,14 @@ export default EmberObject.extend({ 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 () { return JSON.stringify(this); } diff --git a/app/services/kredits.js b/app/services/kredits.js index 641af86..eaec074 100644 --- a/app/services/kredits.js +++ b/app/services/kredits.js @@ -227,6 +227,74 @@ export default Service.extend({ 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() { return this.contributions .filter(c => c.confirmedAt > this.currentBlock); diff --git a/app/styles/_layout.scss b/app/styles/_layout.scss index e65c4ee..bb0f120 100644 --- a/app/styles/_layout.scss +++ b/app/styles/_layout.scss @@ -97,7 +97,7 @@ main { } } -@media (min-width: 550px) { +@include media-min(small) { main { &#dashboard { grid-column-gap: 4rem; @@ -107,10 +107,9 @@ main { "stats contributions"; &.with-details { - grid-column-gap: 3rem; - grid-template-columns: 2fr 4fr 2fr; + grid-template-columns: 1fr; grid-template-areas: - "stats contributions details"; + "details"; } } @@ -123,6 +122,23 @@ 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 { margin-bottom: 5rem; diff --git a/app/styles/components/_contribution-details.scss b/app/styles/components/_contribution-details.scss index a2071a0..af086e5 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 { @@ -88,7 +137,7 @@ section#contribution-details { // On small screens, hide intro text, contributor and contributions list when // showing details -@include media-max(small) { +@include media-max(medium) { #dashboard.with-details { #contributions, #stats { display: none; diff --git a/app/styles/components/_contribution-list.scss b/app/styles/components/_contribution-list.scss index 2c6034d..c97e9a0 100644 --- a/app/styles/components/_contribution-list.scss +++ b/app/styles/components/_contribution-list.scss @@ -41,6 +41,69 @@ ul.contribution-list { 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 { 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 new file mode 100644 index 0000000..92763cc --- /dev/null +++ b/app/utils/contribution-grouping-key.js @@ -0,0 +1,29 @@ +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); +} diff --git a/package-lock.json b/package-lock.json index 9258dda..96d88f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "web3-utils": "^1.7.3" }, "engines": { - "node": ">= 14" + "node": ">= 22" } }, "node_modules/@ampproject/remapping": { @@ -5291,6 +5291,17 @@ "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": { "version": "4.1.0", "dev": true, @@ -15167,6 +15178,14 @@ "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": { "version": "6.4.0", "dev": true, @@ -15738,6 +15757,21 @@ "dev": true, "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": { "version": "1.1.1", "dev": true, @@ -18796,6 +18830,14 @@ "dev": true, "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": { "version": "3.3.4", "dev": true, @@ -23656,6 +23698,26 @@ "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": { "version": "3.1.0", "dev": true, @@ -27829,6 +27891,16 @@ "version": "2.3.0", "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": { "version": "4.1.0", "dev": true, @@ -35048,6 +35120,13 @@ "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": { "version": "6.4.0", "dev": true @@ -35471,6 +35550,13 @@ "version": "1.0.0", "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": { "version": "1.1.1", "dev": true @@ -37591,6 +37677,13 @@ "version": "0.0.8", "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": { "version": "3.3.4", "dev": true @@ -40981,6 +41074,17 @@ "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": { "version": "3.1.0", "dev": true, diff --git a/tests/fixtures/contributions.js b/tests/fixtures/contributions.js index 5a809f0..8a22cac 100644 --- a/tests/fixtures/contributions.js +++ b/tests/fixtures/contributions.js @@ -5,15 +5,17 @@ import processContributionData from 'kredits-web/utils/process-contribution-data const items = []; const data = [ - { 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' }, - { 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' }, - { 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' }, - { 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: 1500, kind: 'community' }, - { id: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs' }, + { 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: 2, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'ops', description: 'Server maintenance', date: '2020-05-27' }, + { 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: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs', description: 'API documentation', date: '2020-05-28' }, + { id: 5, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: false, amount: 5000, kind: 'design', description: 'UI redesign', date: '2020-05-29' }, + { id: 6, contributorId: 1, confirmedAt: 1000, claimed: false, vetoed: true, amount: 500, kind: 'dev', description: 'Minor fix', date: '2020-05-30' }, + { 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: 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: 9, contributorId: 3, confirmedAt: 2000, claimed: false, vetoed: true, amount: 1500, kind: 'docs', description: 'Minor docs fix', date: '2020-06-02' }, + { 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 => { diff --git a/tests/integration/components/contribution-list/component-test.js b/tests/integration/components/contribution-list/component-test.js index b7f87ba..febf8e6 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); + // 11 contributions: 3 groups ({1,3}, {7,8}, {10,11}) + 5 singletons = 8 rows + 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) { @@ -23,12 +29,86 @@ 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/5000). 4 top-level rows. assert.equal(this.element.querySelectorAll('li').length, 4, 'hide small contributions'); await fillIn('.filter-contribution-kind select', 'dev'); - assert.equal(this.element.querySelectorAll('li').length, 1, 'select kind'); + // 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 {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'); }); }); 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'); + }); +}); diff --git a/tests/unit/services/kredits-test.js b/tests/unit/services/kredits-test.js index 16b5ca4..276b888 100644 --- a/tests/unit/services/kredits-test.js +++ b/tests/unit/services/kredits-test.js @@ -23,7 +23,7 @@ module('Unit | Service | kredits', function(hooks) { service.set('currentBlock', 1023); const items = service.contributionsUnconfirmed; - assert.equal(items.length, 3); + assert.equal(items.length, 5); }); test('#kreditsByContributor', function(assert) { @@ -38,18 +38,18 @@ module('Unit | Service | kredits', function(hooks) { const c1 = kreditsByContributor.find(k => k.contributor.id == 1); assert.equal(c1.amountConfirmed, 11500, 'correct amount confirmed'); - assert.equal(c1.amountUnconfirmed, 1500, 'correct amount unconfirmed'); - assert.equal(c1.amountTotal, 13000, 'correct amount total'); + assert.equal(c1.amountUnconfirmed, 5000, 'correct amount unconfirmed'); + assert.equal(c1.amountTotal, 16500, 'correct amount total'); const c2 = kreditsByContributor.find(k => k.contributor.id == 2); assert.equal(c2.amountConfirmed, 3000, 'correct amount confirmed'); - assert.equal(c2.amountUnconfirmed, 0, 'correct amount unconfirmed'); - assert.equal(c2.amountTotal, 3000, 'correct amount total'); + assert.equal(c2.amountUnconfirmed, 1500, 'correct amount unconfirmed'); + assert.equal(c2.amountTotal, 4500, 'correct amount total'); const c3 = kreditsByContributor.find(k => k.contributor.id == 3); assert.equal(c3.amountConfirmed, 0, 'correct amount confirmed'); - assert.equal(c3.amountUnconfirmed, 5000, 'correct amount unconfirmed'); - assert.equal(c3.amountTotal, 5000, 'correct amount total'); + assert.equal(c3.amountUnconfirmed, 6500, 'correct amount unconfirmed'); + assert.equal(c3.amountTotal, 6500, 'correct amount total'); }); test('#contributorsSorted', function(assert) { @@ -60,6 +60,82 @@ module('Unit | Service | kredits', function(hooks) { 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) { let service = this.owner.lookup('service:kredits'); service.set('contributors', contributors); diff --git a/tests/unit/utils/contribution-grouping-key-test.js b/tests/unit/utils/contribution-grouping-key-test.js new file mode 100644 index 0000000..45e649e --- /dev/null +++ b/tests/unit/utils/contribution-grouping-key-test.js @@ -0,0 +1,99 @@ +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); + }); +});