Skip to content
This repository was archived by the owner on Jan 6, 2023. It is now read-only.

Commit 58a1a24

Browse files
committed
feat: ui-sorter component, tests, and Storybook entry
1 parent 7214491 commit 58a1a24

File tree

14 files changed

+798
-0
lines changed

14 files changed

+798
-0
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import type NativeArray from '@ember/array/-private/native-array';
2+
import type SortRule from '../../lib/SortRule';
3+
4+
import Component from '@ember/component';
5+
import { layout, tagName } from '@ember-decorators/component';
6+
import { action, computed, set } from '@ember/object';
7+
import { A, isArray } from '@ember/array';
8+
import { next } from '@ember/runloop';
9+
10+
import { SortOrder } from '../../constants';
11+
import { sortArrayWithRules } from '../../utils';
12+
import template from './template';
13+
14+
/**
15+
*
16+
*/
17+
function defaultSortDescription(rules: SortRule[]) {
18+
if (!rules.length) {
19+
return 'No sorting has been applied';
20+
}
21+
22+
const messages = rules
23+
.map((rule) => (rule.enabled ? `${rule.displayName} ${rule.direction}` : null))
24+
.filter(Boolean);
25+
26+
return `Sorted on ${messages.join(', ')}`;
27+
}
28+
29+
/**
30+
* The UiSorter provides an easy mechanism for multidimensional sorting of a recordset.
31+
*
32+
* ```handlebars
33+
* {{!-- This example shows a table whose Last Name column is sortable --}}
34+
*
35+
* <UiSorter @records={{this.records}} as |Sorter|>
36+
* <p>{{Sorter.description}}</p>
37+
*
38+
* <table class="table">
39+
* <thead>
40+
* <tr>
41+
* <th>First Name</th>
42+
*
43+
* <Sorter.Criterion @sortOn="lastName" as |Criterion|>
44+
* <th onclick={{Criterion.cycleDirection}} aria-sort="{{Criterion.direction}}">
45+
* {{if Criterion.index (concat Criterion.index '. ')}}
46+
* Last Name
47+
* <UiIcon @name={{Criterion.iconClass}} />
48+
* </th>
49+
* </Sorter.Criterion>
50+
* </tr>
51+
* </thead>
52+
*
53+
* <tbody>
54+
* {{#each Sorter.sortedRecords as |record|}}
55+
* <tr>
56+
* <td>{{record.firstName}}</td>
57+
* <td>{{record.lastName}}</td>
58+
* </tr>
59+
* {{/each}}
60+
* </tbody>
61+
* </table>
62+
* </UiSorter>
63+
* ```
64+
*
65+
* ## Sort Criterion
66+
* The workhorse of the UiSorter is the `Criterion` component which is yielded in the UiSorter's
67+
* block. This is a tagless component, and is intended to be wrapped around a button or whatever
68+
* other interactive element is to be used.
69+
*
70+
* The `@sortOn` attribute is always required. This is the property name of the objects within
71+
* the target recordset whose value will be sorted on.
72+
*
73+
* ```handlebars
74+
* <Sorter.Criterion @sortOn="lastName" as |Criterion|>
75+
* {{!-- --}}
76+
* </Sorter.Criterion>
77+
* ```
78+
*
79+
* ### Pre-sorting
80+
* By default, the order that the recordset is provided in will be the same order that the yielded
81+
* `sortedRecords` array will have as all sort criterion default their sort direction to "none".
82+
*
83+
* It is possible to request a default sort that will be applied when the component renders by
84+
* providing the `@direction` attribute with a string of either "ascending" or "descending".
85+
*
86+
* ```handlebars
87+
* <Sorter.Criterion @sortOn="lastName" @direction="ascending" as |Criterion|>
88+
* {{!-- --}}
89+
* </Sorter.Criterion>
90+
* ```
91+
*
92+
* ### Sub-sorting
93+
* Second, third, to _N_-th order child sorts can be provided with the `@subSortOn` attributes.
94+
*
95+
* Imagine a long list of names where many of the last names were all _"Smith"_. It might be convenient
96+
* to end-users to automatically sort on first names after the round of last name sorting so that
97+
* all of the _"Smith"_ records are easier to scan.
98+
*
99+
* ```handlebars
100+
* <Sorter.Criterion @sortOn="lastName" @subSortOn="firstName" as |Criterion|>
101+
* {{!-- --}}
102+
* </Sorter.Criterion>
103+
* ```
104+
*
105+
* Multiple values can be provided to the `@subSortOn` attribute by providing a comma separated list. E.g.
106+
* `@subSortOn="firstName,middleName,phoneNumber"`. Sorts will be processed in the order that they are
107+
* provided.
108+
*
109+
* By default, the direction of all sub-sorts follow the direction of the primary sort. If the direction
110+
* of the sub-sort needs to be fixed, then there is a `@subSortDirection` attribute for that.
111+
*
112+
* ```handlebars
113+
* <Sorter.Criterion @sortOn="lastName" @subSortOn="firstName" @subSortDirection="ascending" as |Criterion|>
114+
* {{!-- --}}
115+
* </Sorter.Criterion>
116+
* ```
117+
*
118+
* Multiple `@subSortDirection` values can be provided via a comma separated list, and are mapped to each
119+
* `@subSortOn` in the order they are provided.
120+
*/
121+
@tagName('')
122+
@layout(template)
123+
export default class UiSorter extends Component {
124+
public static readonly positionalParams = ['records'];
125+
126+
/**
127+
* The array of objects that will be sorted. Sort operations do not mutate this array.
128+
*/
129+
public declare records: unknown[];
130+
131+
/**
132+
* The method that is used to generate descriptive text for the state of the sort for
133+
* assistive technologies. It receives, as an argument, an array containing active
134+
* SortRule instances and must return a string.
135+
*/
136+
public createDescription: (rules: SortRule[]) => string = defaultSortDescription;
137+
138+
protected ruleSet: NativeArray<SortRule> = A([]);
139+
140+
@computed('ruleSet.[]', 'ruleSet.@each.{displayName,direction}')
141+
protected get description() {
142+
return this.createDescription(this.ruleSet);
143+
}
144+
145+
@computed(
146+
'records.[]',
147+
'ruleSet.[]',
148+
'ruleSet.@each.{sortOn,direction,subSortOn,subSortDirection}'
149+
)
150+
protected get sortedRecords() {
151+
return isArray(this.records) ? sortArrayWithRules(this.records, this.ruleSet) : [];
152+
}
153+
154+
// eslint-disable-next-line ember/classic-decorator-hooks
155+
init() {
156+
super.init();
157+
158+
// Cannot pin down why, but on first render the `description` property is
159+
// being computed before any rules are pushed in, but not being recomputed
160+
// after. Oddly enough `sortedRecords` is being recomputed, but the two
161+
// display different behavior even will the exact same dependent keys.
162+
next(this, 'notifyPropertyChange', 'description');
163+
}
164+
165+
@action
166+
handleRuleUpdated(rule: SortRule) {
167+
// The rule was turned off, remove it
168+
if (!rule.enabled) {
169+
this.ruleSet.removeObject(rule);
170+
set(rule, 'index', undefined);
171+
}
172+
// The rule was just turned on, add it to the end of the list
173+
else if (rule.previousDirection === SortOrder.NONE) {
174+
this.ruleSet.addObject(rule);
175+
}
176+
177+
// Re-index all remaining rules
178+
this.ruleSet.forEach((item, idx) => set(item, 'index', idx + 1));
179+
}
180+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import Component from '@ember/component';
2+
import { layout, tagName } from '@ember-decorators/component';
3+
import { action, computed, set } from '@ember/object';
4+
import { alias } from '@ember/object/computed';
5+
6+
import { SortOrder } from '../../../constants';
7+
import SortRule from '../../../lib/SortRule';
8+
import template from './template';
9+
10+
const IconClassNames = {
11+
[SortOrder.NONE]: 'fa-sort',
12+
[SortOrder.ASC]: 'fa-sort-asc',
13+
[SortOrder.DESC]: 'fa-sort-desc',
14+
};
15+
16+
/**
17+
* @class UiSorterCriterion
18+
*/
19+
@tagName('')
20+
@layout(template)
21+
export default class UiSorterCriterion extends Component {
22+
public static readonly positionalParams = ['sortOn', 'direction'];
23+
24+
@alias('rule.name')
25+
public declare name: SortRule['name'];
26+
27+
@alias('rule.sortOn')
28+
public declare sortOn: SortRule['sortOn'];
29+
30+
@alias('rule.direction')
31+
public declare direction: SortRule['direction'];
32+
33+
@alias('rule.subSortOn')
34+
public declare subSortOn: SortRule['subSortOn'];
35+
36+
@alias('rule.subSortDirection')
37+
public declare subSortDirection: SortRule['subSortDirection'];
38+
39+
@alias('rule.threeStepCycle')
40+
public declare threeStepCycle: SortRule['threeStepCycle'];
41+
42+
public declare onUpdate: (rule: SortRule) => void;
43+
44+
@computed('direction')
45+
public get iconClass() {
46+
return IconClassNames[this.direction];
47+
}
48+
49+
@action
50+
public cycleDirection() {
51+
this.rule.updateToNextSortDirection();
52+
this.onUpdate(this.rule);
53+
}
54+
55+
private rule = new SortRule();
56+
57+
// eslint-disable-next-line ember/classic-decorator-hooks
58+
init() {
59+
super.init();
60+
61+
if (this.rule.enabled) {
62+
this.onUpdate(this.rule);
63+
}
64+
}
65+
66+
// eslint-disable-next-line ember/no-component-lifecycle-hooks
67+
willDestroyElement() {
68+
set(this.rule, 'direction', SortOrder.NONE);
69+
this.onUpdate(this.rule);
70+
super.willDestroyElement();
71+
}
72+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{{yield (hash
2+
index = (readonly this.rule.index)
3+
direction = (readonly this.direction)
4+
iconClass = (readonly this.iconClass)
5+
cycleDirection = this.cycleDirection
6+
)}}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{{yield (hash
2+
sortedRecords = (readonly this.sortedRecords)
3+
description = (readonly this.description)
4+
Criterion = (component "ui-sorter/criterion" onUpdate=this.handleRuleUpdated)
5+
)}}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { hbs } from 'ember-cli-htmlbars';
2+
import { faker } from '@faker-js/faker';
3+
4+
export default {
5+
title: 'Elements/ui-sorter',
6+
component: 'components/ui-sorter/component',
7+
8+
parameters: {
9+
docs: {
10+
iframeHeight: 450,
11+
},
12+
},
13+
};
14+
15+
const Template = (context: unknown) => ({
16+
context: Object.assign({}, context, {
17+
get records() {
18+
const records = [];
19+
20+
for (let i = 0; i < 100; i += 1) {
21+
records.push({
22+
firstName: faker.name.firstName(),
23+
lastName: faker.name.lastName(),
24+
});
25+
}
26+
27+
return records;
28+
},
29+
}),
30+
31+
// language=hbs
32+
template: hbs`
33+
<UiSorter @records={{this.records}} as |Sorter|>
34+
<p class="text-right">{{Sorter.description}}</p>
35+
36+
<table class="table table-striped table-condensed">
37+
<thead>
38+
<tr>
39+
<Sorter.Criterion @sortOn="firstName" @direction="descending" as |Criterion|>
40+
<th
41+
onclick={{Criterion.cycleDirection}}
42+
aria-sort="{{Criterion.direction}}"
43+
>
44+
{{if Criterion.index (concat Criterion.index '. ')}}First Name <UiIcon @name={{Criterion.iconClass}} />
45+
</th>
46+
</Sorter.Criterion>
47+
48+
<th>Last Name</th>
49+
</tr>
50+
</thead>
51+
52+
<tbody>
53+
{{#each Sorter.sortedRecords as |record|}}
54+
<tr>
55+
<td>{{record.firstName}}</td>
56+
<td>{{record.lastName}}</td>
57+
</tr>
58+
{{/each}}
59+
</tbody>
60+
</table>
61+
</UiSorter>
62+
`,
63+
});
64+
65+
export const Default = Template.bind({});
66+
Default.storyName = 'ui-sorter';

addon/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,9 @@ export enum KeyCodes {
165165
Space = 'Space',
166166
Escape = 'Escape',
167167
}
168+
169+
export enum SortOrder {
170+
ASC = 'ascending',
171+
DESC = 'descending',
172+
NONE = 'none',
173+
}

0 commit comments

Comments
 (0)