Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8a8d4f3d04
|
|||
|
0b14742a15
|
|||
|
4b6ca6eea9
|
|||
|
26f24be551
|
|||
|
33a429c448
|
|||
|
f7ee864071
|
|||
|
3cab203498
|
|||
|
a7e017971a
|
|||
|
606f5c9383
|
|||
|
eb74a5e6d1
|
|||
|
9054821e52
|
|||
| 51a9c8ad6f | |||
|
349f0e5eea
|
|||
|
c74d70040b
|
@@ -0,0 +1,14 @@
|
|||||||
|
name-template: 'v$RESOLVED_VERSION'
|
||||||
|
tag-template: 'v$RESOLVED_VERSION'
|
||||||
|
version-resolver:
|
||||||
|
major:
|
||||||
|
labels:
|
||||||
|
- 'release/major'
|
||||||
|
minor:
|
||||||
|
labels:
|
||||||
|
- 'release/minor'
|
||||||
|
- 'feature'
|
||||||
|
patch:
|
||||||
|
labels:
|
||||||
|
- 'release/patch'
|
||||||
|
default: patch
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
pull_request: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.head_ref || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
name: "Lint"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: "Test"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: cypress/browsers:node-22.21.0-chrome-141.0.7390.107-1-ff-144.0-edge-141.0.3537.92-1
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Run Tests
|
||||||
|
run: npm test
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name: Release Drafter
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
jobs:
|
||||||
|
release_drafter_job:
|
||||||
|
name: Update release notes draft
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Release Drafter
|
||||||
|
uses: https://github.com/raucao/gitea-release-drafter@dev
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
template: |
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
$CHANGES
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "master" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "master" ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [16.x]
|
|
||||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
cache: 'npm'
|
|
||||||
- run: npm install
|
|
||||||
- run: npm test
|
|
||||||
@@ -14,7 +14,7 @@ Unhosted Ember.js Web UI for [Kosmos Kredits](https://wiki.kosmos.org/Kredits)
|
|||||||
- **ethers 5** + **@kredits/contracts** for on-chain reads/writes
|
- **ethers 5** + **@kredits/contracts** for on-chain reads/writes
|
||||||
- **ember-concurrency** for async sync tasks; **Ember Services** for state (no Redux, no Ember Data)
|
- **ember-concurrency** for async sync tasks; **Ember Services** for state (no Redux, no Ember Data)
|
||||||
- **QUnit** + qunit-dom + fetch-mock for tests
|
- **QUnit** + qunit-dom + fetch-mock for tests
|
||||||
- **Node 16** (`.nvmrc`); `>= 14` in engines
|
- **Node 22** (`.nvmrc`); `>= 22` in engines
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -22,8 +22,7 @@ Unhosted Ember.js Web UI for [Kosmos Kredits](https://wiki.kosmos.org/Kredits)
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Dev server (RSK testnet) | `npm start` |
|
| Dev server (RSK testnet) | `npm start` |
|
||||||
| Dev against local Hardhat devchain | `npm run start:local` |
|
| Dev against local Hardhat devchain | `npm run start:local` |
|
||||||
| Full lint + test pipeline (CI) | `npm test` |
|
| Ember/QUnit suite (all tests) | `ember test` |
|
||||||
| Ember/QUnit suite only | `npm run test:ember` |
|
|
||||||
| Interactive test runner | `ember test --server` |
|
| Interactive test runner | `ember test --server` |
|
||||||
| All linters | `npm run lint` |
|
| All linters | `npm run lint` |
|
||||||
| Lint JS (autofix) | `npm run lint:js -- --fix` |
|
| Lint JS (autofix) | `npm run lint:js -- --fix` |
|
||||||
@@ -91,7 +90,7 @@ Read in `config/environment.js` (lines ~102–110). No `.env.example` (`.gitigno
|
|||||||
|
|
||||||
- **Framework**: QUnit via `ember-qunit` + `qunit-dom`; `@ember/test-helpers` (`render`, `click`, `fillIn`).
|
- **Framework**: QUnit via `ember-qunit` + `qunit-dom`; `@ember/test-helpers` (`render`, `click`, `fillIn`).
|
||||||
- **HTTP mocking**: `fetch-mock` — `tests/test-helper.js` mocks Bitstamp ticker endpoints globally.
|
- **HTTP mocking**: `fetch-mock` — `tests/test-helper.js` mocks Bitstamp ticker endpoints globally.
|
||||||
- **Runner**: headless Chrome (`testem.js`); CI uses Node 16 via `.github/workflows/ci.yml`.
|
- **Runner**: headless Chrome (`testem.js`); CI uses Node 22 via `.gitea/workflows/ci.yaml`.
|
||||||
- **File patterns**:
|
- **File patterns**:
|
||||||
- `tests/unit/<type>/<name>-test.js` — unit tests for services, controllers, routes, models, helpers, utils.
|
- `tests/unit/<type>/<name>-test.js` — unit tests for services, controllers, routes, models, helpers, utils.
|
||||||
- `tests/integration/components/<name>/component-test.js` — component rendering tests (PODS layout).
|
- `tests/integration/components/<name>/component-test.js` — component rendering tests (PODS layout).
|
||||||
@@ -102,6 +101,7 @@ Read in `config/environment.js` (lines ~102–110). No `.env.example` (`.gitigno
|
|||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Mid-migration codebase**: classic and Octane component styles coexist. Linters warn (not error) on legacy patterns; `.template-lintrc.js` keeps a `pending` list of templates still using `{{action}}`.
|
- **Mid-migration codebase**: classic and Octane component styles coexist. Linters warn (not error) on legacy patterns; `.template-lintrc.js` keeps a `pending` list of templates still using `{{action}}`.
|
||||||
|
- **OpenSSL on Node 17+**: `ember-auto-import@1` bundles webpack 4, which hashes with MD4 — dropped by OpenSSL 3 in Node 17+. The `npm` scripts (`start`, `test`, `build`, `build-prod`, `start:local`) prepend `NODE_OPTIONS=--openssl-legacy-provider` to work around this. Direct `npx ember ...` invocations must set the flag manually. Remove once ember-auto-import is upgraded to v2+.
|
||||||
- **`release/` is committed build output** — don't hand-edit; regenerated by `npm run build-prod` (auto-staged on `npm version`).
|
- **`release/` is committed build output** — don't hand-edit; regenerated by `npm run build-prod` (auto-staged on `npm version`).
|
||||||
- **Deployment is `git push 5apps master`** (5apps static hosting) — pushing to `master` triggers a deploy. Be deliberate about commits/pushes.
|
- **Deployment is `git push 5apps master`** (5apps static hosting) — pushing to `master` triggers a deploy. Be deliberate about commits/pushes.
|
||||||
- **No `.env.example`** — set `WEB3_*` vars inline (see `npm run start:local` for the pattern).
|
- **No `.env.example`** — set `WEB3_*` vars inline (see `npm run start:local` for the pattern).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
import { computed } from '@ember/object';
|
import EmberObject, { computed, observer } from '@ember/object';
|
||||||
import { sort } from '@ember/object/computed';
|
import { sort } from '@ember/object/computed';
|
||||||
import { isPresent } from '@ember/utils';
|
import { isEmpty, 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,6 +12,7 @@ export default Component.extend({
|
|||||||
classNames: ['contributions'],
|
classNames: ['contributions'],
|
||||||
|
|
||||||
selectedContribution: null,
|
selectedContribution: null,
|
||||||
|
expandedGroup: null,
|
||||||
|
|
||||||
showQuickFilter: false,
|
showQuickFilter: false,
|
||||||
hideSmallContributions: false,
|
hideSmallContributions: false,
|
||||||
@@ -33,23 +34,97 @@ export default Component.extend({
|
|||||||
return this.contributions.mapBy('kind').uniq();
|
return this.contributions.mapBy('kind').uniq();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
contributionsFiltered: computed('contributions.[]', 'hideSmallContributions', 'contributorId', 'contributionKind', function() {
|
// Groups the contributions by their `groupId` (derived from `url`; see
|
||||||
return this.contributions.filter(c => {
|
// utils/contribution-grouping-key), then filters the groups: a group is
|
||||||
let included = true;
|
// 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 &&
|
this.contributions.forEach(c => {
|
||||||
c.amount <= 500) { included = false; }
|
const groupId = c.groupId;
|
||||||
|
if (isEmpty(groupId)) {
|
||||||
if (isPresent(this.contributorId) &&
|
orderedGroups.push({ groupId: null, items: [c] });
|
||||||
c.contributorId !== parseInt(this.contributorId)) { included = false; }
|
} else if (groupsMap.has(groupId)) {
|
||||||
|
groupsMap.get(groupId).items.push(c);
|
||||||
if (isPresent(this.contributionKind) &&
|
} else {
|
||||||
c.kind !== this.contributionKind) { included = false; }
|
const group = { groupId, items: [c] };
|
||||||
|
groupsMap.set(groupId, group);
|
||||||
return included;
|
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: {
|
actions: {
|
||||||
|
|
||||||
veto (contributionId) {
|
veto (contributionId) {
|
||||||
@@ -62,6 +137,14 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,27 +30,76 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<ul class="item-list contribution-list {{if @loading 'loading'}}">
|
<ul class="item-list contribution-list {{if @loading 'loading'}}">
|
||||||
{{#each this.contributionsFiltered as |contribution|}}
|
{{#each this.groupedContributions key="key" as |group|}}
|
||||||
<li role="button" data-contribution-id={{contribution.id}}
|
{{#if group.isGrouped}}
|
||||||
{{action "openContributionDetails" contribution}}
|
<li role="button" data-group-id={{group.groupId}}
|
||||||
class="{{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
|
{{action "toggleGroup" group}}
|
||||||
<p class="meta">
|
class="grouped {{if (eq this.expandedGroup group.groupId) "expanded"}}">
|
||||||
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
|
<p class="meta">
|
||||||
<span class="category {{contribution.kind}}">({{contribution.kind}})</span>
|
<span class="avatars">
|
||||||
<span class="title">{{contribution.description}}</span>
|
{{#each group.visibleContributors as |contributor|}}
|
||||||
</p>
|
<UserAvatar @contributor={{contributor}} />
|
||||||
<p class="kredits-amount">
|
{{/each}}
|
||||||
<span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span>
|
{{#if group.extraContributorCount}}
|
||||||
</p>
|
<span class="avatar-overflow">+{{group.extraContributorCount}}</span>
|
||||||
{{#unless contribution.vetoed}}
|
{{/if}}
|
||||||
{{#unless (is-confirmed-contribution contribution)}}
|
</span>
|
||||||
<p class="voting">
|
<span class="category {{group.kind}}">({{group.kind}})</span>
|
||||||
{{input type="button" class="button small danger" value="veto"
|
<span class="title">{{group.description}}</span>
|
||||||
click=(action "veto" contribution.id)
|
</p>
|
||||||
disabled=contribution.hasPendingChanges}}
|
<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 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>
|
||||||
{{/unless}}
|
<p class="kredits-amount">
|
||||||
{{/unless}}
|
<span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span>
|
||||||
</li>
|
</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}}
|
{{/each}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -19,8 +19,17 @@ export default Component.extend({
|
|||||||
src: alias('avatarURL'),
|
src: alias('avatarURL'),
|
||||||
title: alias('contributor.name'),
|
title: alias('contributor.name'),
|
||||||
|
|
||||||
avatarURL: computed('contributor.github_uid', 'size', function() {
|
// Re-compute whenever the contributor reference itself changes (covers a
|
||||||
const github_uid = this.contributor.github_uid;
|
// previously-undefined contributor being lazily loaded later), as well as
|
||||||
|
// the github_uid property when one is present.
|
||||||
|
avatarURL: computed('contributor', 'contributor.github_uid', 'size', function() {
|
||||||
|
const contributor = this.contributor;
|
||||||
|
|
||||||
|
if (!contributor) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const github_uid = contributor.github_uid;
|
||||||
|
|
||||||
if (github_uid) {
|
if (github_uid) {
|
||||||
return `https://avatars2.githubusercontent.com/u/${github_uid}?v=3&s=${SIZES[this.size]}`;
|
return `https://avatars2.githubusercontent.com/u/${github_uid}?v=3&s=${SIZES[this.size]}`;
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
|
|
||||||
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);
|
||||||
})
|
})
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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,
|
||||||
@@ -44,6 +46,14 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-2
@@ -36,6 +36,11 @@ export default Service.extend({
|
|||||||
reimbursements: null,
|
reimbursements: null,
|
||||||
githubAccessToken: null,
|
githubAccessToken: null,
|
||||||
|
|
||||||
|
// IDs of contributors currently being lazy-fetched (see ensureContributorLoaded),
|
||||||
|
// used to avoid spawning duplicate concurrent requests when many contributions
|
||||||
|
// for the same missing contributor are loaded in a tight loop.
|
||||||
|
contributorsFetching: null,
|
||||||
|
|
||||||
currentUserIsContributor: notEmpty('currentUser'),
|
currentUserIsContributor: notEmpty('currentUser'),
|
||||||
currentUserIsCore: alias('currentUser.isCore'),
|
currentUserIsCore: alias('currentUser.isCore'),
|
||||||
hasAccounts: notEmpty('currentUserAccounts'),
|
hasAccounts: notEmpty('currentUserAccounts'),
|
||||||
@@ -56,6 +61,7 @@ export default Service.extend({
|
|||||||
this.set('contributors', []);
|
this.set('contributors', []);
|
||||||
this.set('contributions', []);
|
this.set('contributions', []);
|
||||||
this.set('reimbursements', []);
|
this.set('reimbursements', []);
|
||||||
|
this.set('contributorsFetching', new Set());
|
||||||
|
|
||||||
if (window.ethereum) {
|
if (window.ethereum) {
|
||||||
window.ethereum.on('chainChanged', this.handleUserChainChanged);
|
window.ethereum.on('chainChanged', this.handleUserChainChanged);
|
||||||
@@ -187,10 +193,17 @@ export default Service.extend({
|
|||||||
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
|
kreditsByContributor: computed('contributionsUnconfirmed.@each.vetoed', 'contributors.[]', function() {
|
||||||
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
|
const contributionsUnconfirmed = this.contributionsUnconfirmed.filterBy('vetoed', false);
|
||||||
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
|
const contributionsGrouped = groupBy(contributionsUnconfirmed, 'contributorId');
|
||||||
const contributorsWithUnconfirmed = contributionsGrouped.map(c => c.value);
|
|
||||||
|
// Drop groups whose contributor isn't loaded yet (e.g. an IPFS profile
|
||||||
|
// failed to deserialize). They'll be re-included once the contributor
|
||||||
|
// gets lazily fetched via loadContributionFromData.
|
||||||
|
const contributionsGroupedKnown = contributionsGrouped.filter(c => {
|
||||||
|
return this.contributors.findBy('id', c.value);
|
||||||
|
});
|
||||||
|
const contributorsWithUnconfirmed = contributionsGroupedKnown.map(c => c.value);
|
||||||
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
|
const contributorsWithOnlyConfirmed = this.contributors.reject(c => contributorsWithUnconfirmed.includes(c.id))
|
||||||
|
|
||||||
const kreditsByContributor = contributionsGrouped.map(c => {
|
const kreditsByContributor = contributionsGroupedKnown.map(c => {
|
||||||
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
|
const amountUnconfirmed = c.items.mapBy('amount').reduce((a, b) => a + b);
|
||||||
const contributor = this.contributors.findBy('id', c.value);
|
const contributor = this.contributors.findBy('id', c.value);
|
||||||
|
|
||||||
@@ -214,6 +227,74 @@ 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);
|
||||||
@@ -302,6 +383,26 @@ export default Service.extend({
|
|||||||
.then(data => this.loadContributorFromData(data))
|
.then(data => this.loadContributorFromData(data))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// When a contribution/reimbursement references a contributor that isn't in
|
||||||
|
// the local collection yet (e.g. its IPFS profile failed to deserialize on a
|
||||||
|
// previous attempt, or it was added out-of-band), lazily fetch it in the
|
||||||
|
// background. Fire-and-forget: never blocks the calling loop iteration, and
|
||||||
|
// any rejection is logged so it doesn't break the run loop. The successful
|
||||||
|
// arrival of the contributor will retrigger dependent computeds
|
||||||
|
// (kreditsByContributor, UserAvatar) via the `contributors.[]` dependency.
|
||||||
|
ensureContributorLoaded (id) {
|
||||||
|
if (!id) return;
|
||||||
|
if (this.contributors.findBy('id', id)) return;
|
||||||
|
if (this.contributorsFetching.has(id)) return;
|
||||||
|
|
||||||
|
console.debug(`[kredits] Contributor ${id} not loaded yet; fetching in background`);
|
||||||
|
this.contributorsFetching.add(id);
|
||||||
|
this.fetchContributor(id)
|
||||||
|
.then(() => console.debug(`[kredits] Lazy-loaded contributor ${id}`))
|
||||||
|
.catch(err => console.warn(`[kredits] Failed to lazy-load contributor ${id}:`, err))
|
||||||
|
.finally(() => this.contributorsFetching.delete(id));
|
||||||
|
},
|
||||||
|
|
||||||
fetchContributors () {
|
fetchContributors () {
|
||||||
console.debug(`[kredits] Fetching all contributors from the network`);
|
console.debug(`[kredits] Fetching all contributors from the network`);
|
||||||
return this.kredits.Contributor.all()
|
return this.kredits.Contributor.all()
|
||||||
@@ -385,6 +486,7 @@ export default Service.extend({
|
|||||||
const loadedContribution = this.contributions.findBy('id', contribution.id);
|
const loadedContribution = this.contributions.findBy('id', contribution.id);
|
||||||
if (loadedContribution) { this.contributions.removeObject(loadedContribution); }
|
if (loadedContribution) { this.contributions.removeObject(loadedContribution); }
|
||||||
this.contributions.pushObject(contribution);
|
this.contributions.pushObject(contribution);
|
||||||
|
this.ensureContributorLoaded(data.contributorId);
|
||||||
return contribution;
|
return contribution;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -620,6 +722,7 @@ export default Service.extend({
|
|||||||
obj.set('contributor', this.contributors.findBy('id', data.recipientId));
|
obj.set('contributor', this.contributors.findBy('id', data.recipientId));
|
||||||
this.removeObjectFromCollectionIfLoaded('reimbursements', obj.id);
|
this.removeObjectFromCollectionIfLoaded('reimbursements', obj.id);
|
||||||
this.reimbursements.pushObject(obj);
|
this.reimbursements.pushObject(obj);
|
||||||
|
this.ensureContributorLoaded(data.recipientId);
|
||||||
return obj;
|
return obj;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -97,7 +97,7 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 550px) {
|
@include media-min(small) {
|
||||||
main {
|
main {
|
||||||
&#dashboard {
|
&#dashboard {
|
||||||
grid-column-gap: 4rem;
|
grid-column-gap: 4rem;
|
||||||
@@ -107,10 +107,9 @@ main {
|
|||||||
"stats contributions";
|
"stats contributions";
|
||||||
|
|
||||||
&.with-details {
|
&.with-details {
|
||||||
grid-column-gap: 3rem;
|
grid-template-columns: 1fr;
|
||||||
grid-template-columns: 2fr 4fr 2fr;
|
|
||||||
grid-template-areas:
|
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 {
|
main section {
|
||||||
margin-bottom: 5rem;
|
margin-bottom: 5rem;
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,55 @@ 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 {
|
||||||
@@ -88,7 +137,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(small) {
|
@include media-max(medium) {
|
||||||
#dashboard.with-details {
|
#dashboard.with-details {
|
||||||
#contributions, #stats {
|
#contributions, #stats {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -41,6 +41,69 @@ 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,6 +32,22 @@
|
|||||||
</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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Generated
+105
-1
@@ -61,7 +61,7 @@
|
|||||||
"web3-utils": "^1.7.3"
|
"web3-utils": "^1.7.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
@@ -5291,6 +5291,17 @@
|
|||||||
"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,
|
||||||
@@ -15167,6 +15178,14 @@
|
|||||||
"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,
|
||||||
@@ -15738,6 +15757,21 @@
|
|||||||
"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,
|
||||||
@@ -18796,6 +18830,14 @@
|
|||||||
"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,
|
||||||
@@ -23656,6 +23698,26 @@
|
|||||||
"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,
|
||||||
@@ -27829,6 +27891,16 @@
|
|||||||
"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,
|
||||||
@@ -35048,6 +35120,13 @@
|
|||||||
"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
|
||||||
@@ -35471,6 +35550,13 @@
|
|||||||
"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
|
||||||
@@ -37591,6 +37677,13 @@
|
|||||||
"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
|
||||||
@@ -40981,6 +41074,17 @@
|
|||||||
"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,
|
||||||
|
|||||||
+6
-7
@@ -14,12 +14,11 @@
|
|||||||
"lint": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*",
|
"lint": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*",
|
||||||
"lint:hbs": "ember-template-lint .",
|
"lint:hbs": "ember-template-lint .",
|
||||||
"lint:js": "eslint .",
|
"lint:js": "eslint .",
|
||||||
"start": "ember serve",
|
"start": "NODE_OPTIONS=--openssl-legacy-provider ember serve",
|
||||||
"test": "npm-run-all lint:* test:*",
|
"test": "NODE_OPTIONS=--openssl-legacy-provider ember test",
|
||||||
"test:ember": "ember test",
|
"start:local": "WEB3_PROVIDER_URL=http://localhost:8545 WEB3_CHAIN_ID=1337 WEB3_NETWORK_NAME='Hardhat Devchain' NODE_OPTIONS=--openssl-legacy-provider ember serve",
|
||||||
"start:local": "WEB3_PROVIDER_URL=http://localhost:8545 WEB3_CHAIN_ID=1337 WEB3_NETWORK_NAME='Hardhat Devchain' ember serve",
|
"build": "NODE_OPTIONS=--openssl-legacy-provider ember build --environment=production",
|
||||||
"build": "ember build --environment=production",
|
"build-prod": "rm -rf release/* && NODE_OPTIONS=--openssl-legacy-provider ember build -prod --output-path release",
|
||||||
"build-prod": "rm -rf release/* && ember build -prod --output-path release",
|
|
||||||
"preversion": "npm test",
|
"preversion": "npm test",
|
||||||
"version": "npm run build-prod && git add release/",
|
"version": "npm run build-prod && git add release/",
|
||||||
"deploy": "git push 5apps master"
|
"deploy": "git push 5apps master"
|
||||||
@@ -77,7 +76,7 @@
|
|||||||
"web3-utils": "^1.7.3"
|
"web3-utils": "^1.7.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"ember": {
|
"ember": {
|
||||||
"edition": "octane"
|
"edition": "octane"
|
||||||
|
|||||||
Vendored
+11
-9
@@ -5,15 +5,17 @@ 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' },
|
{ 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' },
|
{ 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: '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: 4, contributorId: 2, confirmedAt: 1000, claimed: false, vetoed: false, amount: 1500, kind: 'docs' },
|
{ 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' },
|
{ 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' },
|
{ 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' },
|
{ 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: 1500, kind: 'community' },
|
{ 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' },
|
{ 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 => {
|
data.forEach(attrs => {
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ 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', 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);
|
this.set('fixtures', contributions);
|
||||||
await render(hbs`{{contribution-list contributions=fixtures}}`);
|
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) {
|
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 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');
|
||||||
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,4 +37,20 @@ module('Integration | Component | user-avatar', function(hooks) {
|
|||||||
`https://avatars2.githubusercontent.com/u/318?v=3&s=512`);
|
`https://avatars2.githubusercontent.com/u/318?v=3&s=512`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders empty src when contributor is undefined', async function(assert) {
|
||||||
|
this.set('contributor', undefined);
|
||||||
|
await render(hbs`{{user-avatar contributor=contributor}}`);
|
||||||
|
|
||||||
|
assert.dom(this.element).hasText('');
|
||||||
|
assert.equal(this.element.querySelector('img').getAttribute('src'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders empty src when contributor has no github_uid', async function(assert) {
|
||||||
|
const noGithub = { name: 'Mystery', github_uid: null };
|
||||||
|
this.set('contributor', noGithub);
|
||||||
|
await render(hbs`{{user-avatar contributor=contributor}}`);
|
||||||
|
|
||||||
|
assert.equal(this.element.querySelector('img').getAttribute('src'), '');
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@ import { module, test } from 'qunit';
|
|||||||
import { setupTest } from 'ember-qunit';
|
import { setupTest } from 'ember-qunit';
|
||||||
import contributors from '../../fixtures/contributors';
|
import contributors from '../../fixtures/contributors';
|
||||||
import contributions from '../../fixtures/contributions';
|
import contributions from '../../fixtures/contributions';
|
||||||
|
import ContributionModel from 'kredits-web/models/contribution';
|
||||||
|
import processContributionData from 'kredits-web/utils/process-contribution-data';
|
||||||
|
|
||||||
module('Unit | Service | kredits', function(hooks) {
|
module('Unit | Service | kredits', function(hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
@@ -21,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, 3);
|
assert.equal(items.length, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#kreditsByContributor', function(assert) {
|
test('#kreditsByContributor', function(assert) {
|
||||||
@@ -36,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, 1500, 'correct amount unconfirmed');
|
assert.equal(c1.amountUnconfirmed, 5000, 'correct amount unconfirmed');
|
||||||
assert.equal(c1.amountTotal, 13000, 'correct amount total');
|
assert.equal(c1.amountTotal, 16500, '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, 0, 'correct amount unconfirmed');
|
assert.equal(c2.amountUnconfirmed, 1500, 'correct amount unconfirmed');
|
||||||
assert.equal(c2.amountTotal, 3000, 'correct amount total');
|
assert.equal(c2.amountTotal, 4500, '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, 5000, 'correct amount unconfirmed');
|
assert.equal(c3.amountUnconfirmed, 6500, 'correct amount unconfirmed');
|
||||||
assert.equal(c3.amountTotal, 5000, 'correct amount total');
|
assert.equal(c3.amountTotal, 6500, 'correct amount total');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('#contributorsSorted', function(assert) {
|
test('#contributorsSorted', function(assert) {
|
||||||
@@ -57,4 +59,109 @@ module('Unit | Service | kredits', function(hooks) {
|
|||||||
assert.ok(service.contributorsSorted instanceof Array, 'is an array');
|
assert.ok(service.contributorsSorted instanceof Array, 'is an array');
|
||||||
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) {
|
||||||
|
let service = this.owner.lookup('service:kredits');
|
||||||
|
service.set('contributors', contributors);
|
||||||
|
|
||||||
|
// Contribution referencing a contributor that is not in the contributors
|
||||||
|
// collection (e.g. its IPFS profile failed to deserialize). The dashboard
|
||||||
|
// must not throw when computing the toplist.
|
||||||
|
const orphanAttrs = { id: 99, contributorId: 999, confirmedAt: 2000, claimed: false, vetoed: false, amount: 7000, kind: 'dev' };
|
||||||
|
const orphan = ContributionModel.create(processContributionData(orphanAttrs));
|
||||||
|
orphan.set('contributor', undefined);
|
||||||
|
|
||||||
|
service.set('contributions', [...contributions, orphan]);
|
||||||
|
service.set('currentBlock', 1023);
|
||||||
|
|
||||||
|
let kreditsByContributor;
|
||||||
|
try {
|
||||||
|
kreditsByContributor = service.kreditsByContributor;
|
||||||
|
} catch (e) {
|
||||||
|
assert.ok(false, `did not expect kreditsByContributor to throw: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(kreditsByContributor, 'computes without throwing');
|
||||||
|
assert.notOk(kreditsByContributor.findBy('contributor', undefined),
|
||||||
|
'no entry for the missing contributor');
|
||||||
|
assert.notOk(kreditsByContributor.find(k => k.contributor && k.contributor.id === 999),
|
||||||
|
'orphan contributor id is absent from the toplist');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user