Add ember-best-practices skill
This commit is contained in:
258
.agents/skills/ember-best-practices/rules/helper-composition.md
Normal file
258
.agents/skills/ember-best-practices/rules/helper-composition.md
Normal file
@@ -0,0 +1,258 @@
|
||||
---
|
||||
title: Compose Helpers for Reusable Logic
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Better code reuse and testability
|
||||
tags: helpers, composition, functions, pipes, reusability
|
||||
---
|
||||
|
||||
## Compose Helpers for Reusable Logic
|
||||
|
||||
Compose helpers to create reusable, testable logic that can be combined in templates and components.
|
||||
|
||||
**Incorrect (logic duplicated in templates):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
<template>
|
||||
<div class="profile">
|
||||
<h1>{{uppercase (truncate @user.name 20)}}</h1>
|
||||
|
||||
{{#if (and @user.isActive (not @user.isDeleted))}}
|
||||
<span class="status">Active</span>
|
||||
{{/if}}
|
||||
|
||||
<p>{{lowercase @user.email}}</p>
|
||||
|
||||
{{#if (gt @user.posts.length 0)}}
|
||||
<span>Posts: {{@user.posts.length}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (composed helpers):**
|
||||
|
||||
```javascript
|
||||
// app/helpers/display-name.js
|
||||
export function displayName(name, { maxLength = 20 } = {}) {
|
||||
if (!name) return '';
|
||||
|
||||
const truncated = name.length > maxLength ? name.slice(0, maxLength) + '...' : name;
|
||||
|
||||
return truncated.toUpperCase();
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/helpers/is-visible-user.js
|
||||
export function isVisibleUser(user) {
|
||||
return user && user.isActive && !user.isDeleted;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/helpers/format-email.js
|
||||
export function formatEmail(email) {
|
||||
return email?.toLowerCase() || '';
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import { displayName } from '../helpers/display-name';
|
||||
import { isVisibleUser } from '../helpers/is-visible-user';
|
||||
import { formatEmail } from '../helpers/format-email';
|
||||
|
||||
<template>
|
||||
<div class="profile">
|
||||
<h1>{{displayName @user.name}}</h1>
|
||||
|
||||
{{#if (isVisibleUser @user)}}
|
||||
<span class="status">Active</span>
|
||||
{{/if}}
|
||||
|
||||
<p>{{formatEmail @user.email}}</p>
|
||||
|
||||
{{#if (gt @user.posts.length 0)}}
|
||||
<span>Posts: {{@user.posts.length}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Functional composition with pipe helper:**
|
||||
|
||||
```javascript
|
||||
// app/helpers/pipe.js
|
||||
export function pipe(...fns) {
|
||||
return (value) => fns.reduce((acc, fn) => fn(acc), value);
|
||||
}
|
||||
```
|
||||
|
||||
**Or use a compose helper:**
|
||||
|
||||
```javascript
|
||||
// app/helpers/compose.js
|
||||
export function compose(...helperFns) {
|
||||
return (value) => helperFns.reduceRight((acc, fn) => fn(acc), value);
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/text-processor.gjs
|
||||
import { fn } from '@ember/helper';
|
||||
|
||||
// Individual helpers
|
||||
const uppercase = (str) => str?.toUpperCase() || '';
|
||||
const trim = (str) => str?.trim() || '';
|
||||
const truncate = (str, length = 20) => str?.slice(0, length) || '';
|
||||
|
||||
<template>
|
||||
{{! Compose multiple transformations }}
|
||||
<div>
|
||||
{{pipe @text (fn trim) (fn uppercase) (fn truncate 50)}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Higher-order helpers:**
|
||||
|
||||
```javascript
|
||||
// app/helpers/partial-apply.js
|
||||
export function partialApply(fn, ...args) {
|
||||
return (...moreArgs) => fn(...args, ...moreArgs);
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/helpers/map-by.js
|
||||
export function mapBy(array, property) {
|
||||
return array?.map((item) => item[property]) || [];
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Usage in template
|
||||
import { mapBy } from '../helpers/map-by';
|
||||
import { partialApply } from '../helpers/partial-apply';
|
||||
|
||||
<template>
|
||||
{{! Extract property from array }}
|
||||
<ul>
|
||||
{{#each (mapBy @users "name") as |name|}}
|
||||
<li>{{name}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{! Partial application }}
|
||||
{{#let (partialApply @formatNumber 2) as |formatTwoDecimals|}}
|
||||
<span>Price: {{formatTwoDecimals @price}}</span>
|
||||
{{/let}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**Chainable transformation helpers:**
|
||||
|
||||
```javascript
|
||||
// app/helpers/transform.js
|
||||
class Transform {
|
||||
constructor(value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
filter(fn) {
|
||||
this.value = this.value?.filter(fn) || [];
|
||||
return this;
|
||||
}
|
||||
|
||||
map(fn) {
|
||||
this.value = this.value?.map(fn) || [];
|
||||
return this;
|
||||
}
|
||||
|
||||
sort(fn) {
|
||||
this.value = [...(this.value || [])].sort(fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
take(n) {
|
||||
this.value = this.value?.slice(0, n) || [];
|
||||
return this;
|
||||
}
|
||||
|
||||
get result() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
export function transform(value) {
|
||||
return new Transform(value);
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Usage
|
||||
import { transform } from '../helpers/transform';
|
||||
|
||||
function filter(items) {
|
||||
return items
|
||||
.filter((item) => item.active)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.take(10).result;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#let (transform @items) as |t|}}
|
||||
{{#each (filter t) as |item|}}
|
||||
<div>{{item.name}}</div>
|
||||
{{/each}}
|
||||
{{/let}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**Conditional composition:**
|
||||
|
||||
```javascript
|
||||
// app/helpers/when.js
|
||||
export function when(condition, trueFn, falseFn) {
|
||||
return condition ? trueFn() : falseFn ? falseFn() : null;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/helpers/unless.js
|
||||
export function unless(condition, falseFn, trueFn) {
|
||||
return !condition ? falseFn() : trueFn ? trueFn() : null;
|
||||
}
|
||||
```
|
||||
|
||||
**Testing composed helpers:**
|
||||
|
||||
```javascript
|
||||
// tests/helpers/display-name-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { displayName } from 'my-app/helpers/display-name';
|
||||
|
||||
module('Unit | Helper | display-name', function () {
|
||||
test('it formats name correctly', function (assert) {
|
||||
assert.strictEqual(displayName('John Doe'), 'JOHN DOE');
|
||||
});
|
||||
|
||||
test('it truncates long names', function (assert) {
|
||||
assert.strictEqual(
|
||||
displayName('A Very Long Name That Should Be Truncated', { maxLength: 10 }),
|
||||
'A VERY LON...',
|
||||
);
|
||||
});
|
||||
|
||||
test('it handles null', function (assert) {
|
||||
assert.strictEqual(displayName(null), '');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Composed helpers provide testable, reusable logic that keeps templates clean and components focused on behavior rather than data transformation.
|
||||
|
||||
Reference: [Ember Helpers](https://guides.emberjs.com/release/components/helper-functions/)
|
||||
Reference in New Issue
Block a user