Files
marco/.agents/skills/ember-best-practices/rules/helper-composition.md

5.6 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Compose Helpers for Reusable Logic MEDIUM-HIGH Better code reuse and testability 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):

// 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):

// 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();
}
// app/helpers/is-visible-user.js
export function isVisibleUser(user) {
  return user && user.isActive && !user.isDeleted;
}
// app/helpers/format-email.js
export function formatEmail(email) {
  return email?.toLowerCase() || '';
}
// 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:

// app/helpers/pipe.js
export function pipe(...fns) {
  return (value) => fns.reduce((acc, fn) => fn(acc), value);
}

Or use a compose helper:

// app/helpers/compose.js
export function compose(...helperFns) {
  return (value) => helperFns.reduceRight((acc, fn) => fn(acc), value);
}

Usage:

// 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:

// app/helpers/partial-apply.js
export function partialApply(fn, ...args) {
  return (...moreArgs) => fn(...args, ...moreArgs);
}
// app/helpers/map-by.js
export function mapBy(array, property) {
  return array?.map((item) => item[property]) || [];
}
// 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:

// 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);
}
// 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:

// app/helpers/when.js
export function when(condition, trueFn, falseFn) {
  return condition ? trueFn() : falseFn ? falseFn() : null;
}
// app/helpers/unless.js
export function unless(condition, falseFn, trueFn) {
  return !condition ? falseFn() : trueFn ? trueFn() : null;
}

Testing composed helpers:

// 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