WIP Group contributions in UI
This commit is contained in:
@@ -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,96 @@ 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({
|
||||||
|
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 +136,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 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 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>
|
||||||
@@ -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);
|
||||||
})
|
})
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -41,6 +41,62 @@ ul.contribution-list {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.grouped {
|
||||||
|
grid-template-columns: auto 5rem;
|
||||||
|
|
||||||
|
.avatars {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img.avatar {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: -0.6rem;
|
||||||
|
border: 2px solid $item-background-color;
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-overflow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
margin-left: -0.6rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background-color: $blue;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 2px solid $item-background-color;
|
||||||
|
z-index: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.group-item {
|
||||||
|
padding-left: 2.8rem;
|
||||||
|
background-color: $item-highlighted-background-color;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid $item-border-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
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">
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { isEmpty } from '@ember/utils';
|
import { isEmpty } from '@ember/utils';
|
||||||
|
|
||||||
//
|
|
||||||
// Derives a stable grouping key for a contribution, so that contributions
|
// Derives a stable grouping key for a contribution, so that contributions
|
||||||
// created for the same issue/pull request (one per contributor) can be
|
// created for the same issue/pull request (one per contributor) can be
|
||||||
// linked together in the UI.
|
// linked together in the UI.
|
||||||
//
|
//
|
||||||
// The bot writes the exact same `url` string (the html_url of the issue/PR)
|
// Until we have identical IPFS documents for different contributions, we will
|
||||||
// into every per-contributor IPFS document for a given piece of work, so the
|
// use the URL as grouping key
|
||||||
// top-level `url` field on the model is sufficient as a grouping key. Manual
|
|
||||||
// contributions without a URL return null and are treated as singletons.
|
|
||||||
//
|
|
||||||
export default function contributionGroupingKey (contribution) {
|
export default function contributionGroupingKey (contribution) {
|
||||||
let url = contribution ? contribution.url : null;
|
let url = contribution ? contribution.url : null;
|
||||||
if (isEmpty(url)) return null;
|
if (isEmpty(url)) return null;
|
||||||
|
|||||||
@@ -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);
|
// 9 contributions: 2 groups (ids 1+3, 7+8) + 5 singletons = 7 top-level rows
|
||||||
|
assert.equal(this.element.querySelectorAll('li').length, 7, '7 top-level rows when collapsed');
|
||||||
|
assert.equal(this.element.querySelectorAll('li.grouped').length, 2, '2 grouped headers');
|
||||||
|
assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'no expanded sub-items');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it renders filtered contributions', async function(assert) {
|
test('it renders filtered contributions', async function(assert) {
|
||||||
@@ -23,12 +29,87 @@ module('Integration | Component | contribution-list', function(hooks) {
|
|||||||
await render(hbs`{{contribution-list contributions=fixtures showQuickFilter=true}}`);
|
await 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/1500). 4 top-level rows.
|
||||||
assert.equal(this.element.querySelectorAll('li').length, 4, 'hide small contributions');
|
assert.equal(this.element.querySelectorAll('li').length, 4, 'hide small contributions');
|
||||||
|
|
||||||
await fillIn('.filter-contribution-kind select', 'dev');
|
await fillIn('.filter-contribution-kind select', 'dev');
|
||||||
|
// Kind dev (still filtered to contributor 1, hide small on): only groups
|
||||||
|
// where a single item passes all three filters survive. Group {1,3}: id 1
|
||||||
|
// is contributor 1 + dev → included. Group {8,7}: id 8 is contributor 1 but
|
||||||
|
// community; id 7 is dev but contributor 3 → no item passes both → excluded.
|
||||||
|
// Singletons id 2 (ops), id 5 (design) → excluded. 1 top-level row.
|
||||||
assert.equal(this.element.querySelectorAll('li').length, 1, 'select kind');
|
assert.equal(this.element.querySelectorAll('li').length, 1, 'select kind');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('filtering by contributor keeps grouped rows whole', async function(assert) {
|
||||||
|
let kredits = this.owner.lookup('service:kredits');
|
||||||
|
kredits.set('contributors', contributors);
|
||||||
|
|
||||||
|
this.set('fixtures', contributions);
|
||||||
|
await render(hbs`{{contribution-list contributions=fixtures showQuickFilter=true}}`);
|
||||||
|
|
||||||
|
await fillIn('.filter-contributor select', '1');
|
||||||
|
|
||||||
|
// The first row is the group containing id 1 (contributor 1) and id 3
|
||||||
|
// (contributor 2) — it stays grouped, not collapsed to a singleton.
|
||||||
|
const firstRow = this.element.querySelector('li');
|
||||||
|
assert.ok(firstRow.classList.contains('grouped'), 'first row is still grouped');
|
||||||
|
assert.equal(firstRow.querySelectorAll('.avatars img').length, 2, 'shows both contributors avatars');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grouped row expands on click to reveal sub-items', async function(assert) {
|
||||||
|
let kredits = this.owner.lookup('service:kredits');
|
||||||
|
kredits.set('contributors', contributors);
|
||||||
|
|
||||||
|
this.set('fixtures', contributions);
|
||||||
|
await render(hbs`{{contribution-list contributions=fixtures}}`);
|
||||||
|
|
||||||
|
assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'no sub-items when collapsed');
|
||||||
|
|
||||||
|
await click('.grouped:first-child');
|
||||||
|
|
||||||
|
assert.equal(this.element.querySelectorAll('li.group-item').length, 2, '2 sub-items when expanded');
|
||||||
|
assert.ok(this.element.querySelector('.grouped').classList.contains('expanded'), 'header has expanded class');
|
||||||
|
|
||||||
|
await click('.grouped:first-child');
|
||||||
|
assert.equal(this.element.querySelectorAll('li.group-item').length, 0, 'sub-items hidden when collapsed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grouped row shows stacked avatars and single amount', async function(assert) {
|
||||||
|
let kredits = this.owner.lookup('service:kredits');
|
||||||
|
kredits.set('contributors', contributors);
|
||||||
|
|
||||||
|
this.set('fixtures', contributions);
|
||||||
|
await render(hbs`{{contribution-list contributions=fixtures}}`);
|
||||||
|
|
||||||
|
const firstGroup = this.element.querySelector('.grouped');
|
||||||
|
// Group ids 1+3 → 2 contributors → 2 avatars, no overflow
|
||||||
|
assert.equal(firstGroup.querySelectorAll('.avatars img').length, 2, '2 avatar images');
|
||||||
|
assert.equal(firstGroup.querySelectorAll('.avatar-overflow').length, 0, 'no overflow bubble for 2 contributors');
|
||||||
|
// Single amount (first item's amount = 1500, not the sum)
|
||||||
|
assert.equal(firstGroup.querySelector('.amount').textContent.trim(), '1500', 'shows single contribution amount');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-expands when selectedContributionId is a group child', async function(assert) {
|
||||||
|
let kredits = this.owner.lookup('service:kredits');
|
||||||
|
kredits.set('contributors', contributors);
|
||||||
|
|
||||||
|
this.set('fixtures', contributions);
|
||||||
|
this.set('selectedId', 3);
|
||||||
|
|
||||||
|
await render(hbs`{{contribution-list contributions=fixtures selectedContributionId=selectedId}}`);
|
||||||
|
|
||||||
|
assert.equal(this.element.querySelectorAll('li.group-item').length, 2, 'group auto-expanded');
|
||||||
|
|
||||||
|
const selectedChild = this.element.querySelector('.group-item.selected');
|
||||||
|
assert.ok(selectedChild, 'selected child has selected class');
|
||||||
|
assert.equal(selectedChild.dataset.contributionId, '3', 'correct child is selected');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user