Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9d06898b15
|
|||
|
6df43edbf9
|
|||
|
e2fcdce154
|
|||
|
829dff9839
|
|||
|
e7dfed204e
|
|||
|
2dfd411837
|
|||
|
5afece5f51
|
|||
|
f1979282bd
|
|||
|
04181d5772
|
|||
|
b4ab1e926d
|
|||
|
9b83d35b40
|
|||
|
beda03c2ac
|
|||
|
ecbac12440
|
|||
|
913d5c915c
|
|||
|
89f667b17e
|
|||
|
22d4ef8d96
|
|||
|
b17793af9d
|
|||
|
dc9e0f210a
|
|||
|
2b219fe0cf
|
|||
|
9fd6c4d64d
|
|||
|
8e5b2c7439
|
|||
|
0f29430e1a
|
|||
|
0059d89cc3
|
|||
|
54e2766dc4
|
|||
|
5978f67d48
|
|||
|
d72e5f3de2
|
|||
|
582ab4f8b3
|
|||
|
0ac6db65cb
|
|||
|
86b20fd474
|
|||
|
8478e00253
|
|||
|
818ec35071
|
|||
|
46605dbd32
|
|||
|
bcc51efecc
|
|||
|
8bec4b978e
|
|||
|
cd9676047d
|
|||
|
a92b44ec13
|
|||
|
0c2d1f8419
|
|||
|
bb77ed8337
|
|||
|
438bf0c31c
|
|||
|
af57e7fe57
|
|||
|
9183e3c366
|
|||
|
7e98b6796c
|
|||
|
8e9beb16de
|
|||
|
b083c1d001
|
|||
|
4008a8c883
|
|||
|
eb7cff7ff5
|
|||
|
db6478e353
|
|||
|
b39d92b7c4
|
|||
|
aa99e5d766
|
|||
|
5fd4ebe184
|
|||
|
f2a2d910a0
|
|||
|
6b37508f66
|
|||
|
8106009677
|
|||
|
07489c43a4
|
|||
|
a4e375cb51
|
|||
|
b680769eac
|
|||
|
4a609c8388
|
|||
|
cfcaaea3ec
|
|||
|
2f440d4971
|
|||
|
1c6cbe6b0f
|
|||
|
bdd5db157c
|
|||
|
f7c40095d5
|
|||
|
579892067e
|
|||
|
48f87f98d6
|
|||
|
3cd1b32af9
|
|||
|
462404b53e
|
|||
|
e3147caa90
|
|||
|
7c11fefdb7
|
|||
|
9af6636971
|
|||
|
8ae7fd1fd8
|
|||
|
cb3fbade39
|
|||
|
6dfd9765b4
|
|||
|
45eb8f087d
|
|||
|
3630fae133
|
|||
|
1116161e93
|
|||
|
88eb0ac0c1
|
|||
|
6da004e199
|
|||
|
8877a9e1c8
|
|||
|
03d6a86569
|
|||
|
5baebbb846
|
|||
|
dca2991754
|
|||
|
aee7f9d181
|
|||
|
56a077cceb
|
|||
|
7e5a034cac
|
|||
|
5892bd0cda
|
|||
|
baaf027900
|
|||
|
beb3d12169
|
|||
|
a5aa396411
|
|||
|
21cb8e6cc2
|
|||
|
12ec7fcbbf
|
|||
|
78d7aeba2c
|
|||
|
3b71531de2
|
|||
|
6ef7549ea9
|
|||
|
9097c63a55
|
|||
|
ec0d5a30f9
|
|||
|
f1779131e8
|
|||
|
37cf47b3dd
|
|||
|
ff68b5addc
|
|||
|
990f3afa88
|
|||
|
b2220b8310
|
|||
|
a8613ab81a
|
|||
|
bcb9b20e85
|
|||
|
466b1d5383
|
|||
|
ea7cb2f895
|
|||
|
7e94f335ac
|
|||
|
066ddb240d
|
|||
|
df336b87ac
|
|||
|
dbf71e366a
|
|||
|
6a83003acb
|
|||
|
bcc7c2a011
|
|||
|
19f04efecb
|
|||
|
c79bbaa41a
|
|||
|
b07640375a
|
|||
|
ffcb8219b0
|
|||
|
e01cb2ce6f
|
|||
|
808c1ee37b
|
|||
|
34bc15cfa9
|
|||
|
ee5e56910d
|
|||
|
e019fc2d6b
|
|||
|
9e03426b2e
|
|||
|
ecbf77c573
|
|||
|
703a5e8de0
|
|||
|
b3c733769c
|
|||
|
60b2548efd
|
|||
|
2e632658ad
|
|||
|
845be96b71
|
|||
|
9ac4273fae
|
|||
|
3a825c3d6c
|
|||
|
a6ca362876
|
|||
|
95e9c621a5
|
|||
|
e980431c17
|
|||
|
4fdf2e2fb6
|
|||
|
de1b162ee9
|
|||
|
1df77c2045
|
|||
|
eb1445b749
|
|||
|
316a38dbf8
|
|||
|
7bcb572dbf
|
|||
|
d827fe263b
|
|||
|
1926e2b20c
|
|||
|
df1f32d8bd
|
|||
|
aa058bd7a3
|
|||
|
361a826e4f
|
|||
|
ff01d54fdd
|
|||
|
f73677139d
|
|||
|
8135695bba
|
|||
|
8217e85836
|
|||
|
d9645d1a8c
|
|||
|
688e8eda8d
|
|||
|
323aab8256
|
|||
|
ecb3fe4b5a
|
|||
|
43b2700465
|
|||
|
00454c8fab
|
|||
|
bf12305600
|
|||
|
2734f08608
|
|||
|
2aa59f9384
|
|||
|
bcf8ca4255
|
|||
|
20f63065ad
|
|||
|
39a7ec3595
|
|||
| 32dfa3a30f | |||
|
64ccc694d3
|
8550
.agents/skills/ember-best-practices/AGENTS.md
Normal file
8550
.agents/skills/ember-best-practices/AGENTS.md
Normal file
File diff suppressed because it is too large
Load Diff
101
.agents/skills/ember-best-practices/README.md
Normal file
101
.agents/skills/ember-best-practices/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Ember.js Best Practices
|
||||
|
||||
A structured repository for creating and maintaining Ember.js Best Practices optimized for agents and LLMs.
|
||||
|
||||
## Structure
|
||||
|
||||
- `rules/` - Individual rule files (one per rule)
|
||||
- `_sections.md` - Section metadata (titles, impacts, descriptions)
|
||||
- `_template.md` - Template for creating new rules
|
||||
- `area-description.md` - Individual rule files
|
||||
- `metadata.json` - Document metadata (version, organization, abstract)
|
||||
- **`AGENTS.md`** - Compiled output (generated)
|
||||
- **`SKILL.md`** - Skill definition for Claude Code
|
||||
|
||||
## Rule Categories
|
||||
|
||||
Rules are organized by prefix:
|
||||
|
||||
- `route-` for Route Loading and Data Fetching (Section 1)
|
||||
- `bundle-` for Build and Bundle Optimization (Section 2)
|
||||
- `component-` for Component and Reactivity (Section 3)
|
||||
- `a11y-` for Accessibility Best Practices (Section 4)
|
||||
- `service-` for Service and State Management (Section 5)
|
||||
- `template-` for Template Optimization (Section 6)
|
||||
- `advanced-` for Advanced Patterns (Section 7)
|
||||
|
||||
## Rule File Structure
|
||||
|
||||
Each rule file should follow this structure:
|
||||
|
||||
````markdown
|
||||
---
|
||||
title: Rule Title Here
|
||||
impact: MEDIUM
|
||||
impactDescription: Optional description
|
||||
tags: tag1, tag2, tag3
|
||||
---
|
||||
|
||||
## Rule Title Here
|
||||
|
||||
Brief explanation of the rule and why it matters.
|
||||
|
||||
**Incorrect (description of what's wrong):**
|
||||
|
||||
```javascript
|
||||
// Bad code example
|
||||
```
|
||||
|
||||
**Correct (description of what's right):**
|
||||
|
||||
```javascript
|
||||
// Good code example
|
||||
```
|
||||
|
||||
Optional explanatory text after examples.
|
||||
|
||||
Reference: [Link](https://example.com)
|
||||
````
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
- Files starting with `_` are special (excluded from build)
|
||||
- Rule files: `area-description.md` (e.g., `route-parallel-model.md`)
|
||||
- Section is automatically inferred from filename prefix
|
||||
- Rules are sorted alphabetically by title within each section
|
||||
- IDs (e.g., 1.1, 1.2) are auto-generated during build
|
||||
|
||||
## Impact Levels
|
||||
|
||||
- `CRITICAL` - Highest priority, major performance gains
|
||||
- `HIGH` - Significant performance improvements
|
||||
- `MEDIUM-HIGH` - Moderate-high gains
|
||||
- `MEDIUM` - Moderate performance improvements
|
||||
- `LOW-MEDIUM` - Low-medium gains
|
||||
- `LOW` - Incremental improvements
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding or modifying rules:
|
||||
|
||||
1. Use the correct filename prefix for your section
|
||||
2. Follow the `_template.md` structure
|
||||
3. Include clear bad/good examples with explanations
|
||||
4. Add appropriate tags
|
||||
5. Rules are automatically sorted by title - no need to manage numbers!
|
||||
|
||||
## Accessibility Focus
|
||||
|
||||
This guide emphasizes Ember's strong accessibility ecosystem:
|
||||
|
||||
- **ember-a11y-testing** - Automated testing with axe-core
|
||||
- **ember-a11y** - Route announcements and focus management
|
||||
- **ember-focus-trap** - Modal focus trapping
|
||||
- **ember-page-title** - Accessible page titles
|
||||
- **Semantic HTML** - Proper use of native elements
|
||||
- **ARIA attributes** - When custom elements are needed
|
||||
- **Keyboard navigation** - Full keyboard support patterns
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built for the Ember.js community, drawing from official guides, Octane patterns, and accessibility best practices.
|
||||
161
.agents/skills/ember-best-practices/SKILL.md
Normal file
161
.agents/skills/ember-best-practices/SKILL.md
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: ember-best-practices
|
||||
description: Ember.js performance optimization and accessibility guidelines. This skill should be used when writing, reviewing, or refactoring Ember.js code to ensure optimal performance patterns and accessibility. Triggers on tasks involving Ember components, routes, data fetching, bundle optimization, or accessibility improvements.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: Ember.js Community
|
||||
version: '1.0.0'
|
||||
---
|
||||
|
||||
# Ember.js Best Practices
|
||||
|
||||
Comprehensive performance optimization and accessibility guide for Ember.js applications. Contains 58 rules across 10 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Writing new Ember components or routes
|
||||
- Implementing data fetching with WarpDrive
|
||||
- Reviewing code for performance issues
|
||||
- Refactoring existing Ember.js code
|
||||
- Optimizing bundle size or load times
|
||||
- Implementing accessibility features
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | ------------------------------- | ----------- | ------------------------ |
|
||||
| 1 | Route Loading and Data Fetching | CRITICAL | `route-` |
|
||||
| 2 | Build and Bundle Optimization | CRITICAL | `bundle-` |
|
||||
| 3 | Component and Reactivity | HIGH | `component-`, `exports-` |
|
||||
| 4 | Accessibility Best Practices | HIGH | `a11y-` |
|
||||
| 5 | Service and State Management | MEDIUM-HIGH | `service-` |
|
||||
| 6 | Template Optimization | MEDIUM | `template-`, `helper-` |
|
||||
| 7 | Performance Optimization | MEDIUM | `performance-` |
|
||||
| 8 | Testing Best Practices | MEDIUM | `testing-` |
|
||||
| 9 | Tooling and Configuration | MEDIUM | `vscode-` |
|
||||
| 10 | Advanced Patterns | MEDIUM-HIGH | `advanced-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Route Loading and Data Fetching (CRITICAL)
|
||||
|
||||
- `route-parallel-model` - Use RSVP.hash() for parallel data loading
|
||||
- `route-loading-substates` - Implement loading substates for better UX
|
||||
- `route-lazy-routes` - Use route-based code splitting with Embroider
|
||||
- `route-templates` - Use route templates with co-located syntax
|
||||
- `route-model-caching` - Implement smart route model caching
|
||||
|
||||
### 2. Build and Bundle Optimization (CRITICAL)
|
||||
|
||||
- `bundle-direct-imports` - Import directly, avoid entire namespaces
|
||||
- `bundle-embroider-static` - Enable Embroider static mode for tree-shaking
|
||||
- `bundle-lazy-dependencies` - Lazy load heavy dependencies
|
||||
|
||||
### 3. Component and Reactivity Optimization (HIGH)
|
||||
|
||||
- `component-use-glimmer` - Use Glimmer components over classic components
|
||||
- `component-cached-getters` - Use @cached for expensive computations
|
||||
- `component-minimal-tracking` - Only track properties that affect rendering
|
||||
- `component-tracked-toolbox` - Use tracked-built-ins for complex state
|
||||
- `component-composition-patterns` - Use yield blocks and contextual components
|
||||
- `component-reactive-chains` - Build reactive chains with dependent getters
|
||||
- `component-class-fields` - Use class fields for component composition
|
||||
- `component-controlled-forms` - Implement controlled form patterns
|
||||
- `component-on-modifier` - Use {{on}} modifier for event handling
|
||||
- `component-args-validation` - Validate component arguments
|
||||
- `component-memory-leaks` - Prevent memory leaks in components
|
||||
- `component-strict-mode` - Use strict mode and template-only components
|
||||
- `component-avoid-classes-in-examples` - Avoid unnecessary classes in component examples
|
||||
- `component-avoid-constructors` - Avoid constructors in Glimmer components
|
||||
- `component-avoid-lifecycle-hooks` - Avoid legacy lifecycle hooks
|
||||
- `component-file-conventions` - Follow proper file naming conventions
|
||||
- `exports-named-only` - Use named exports only
|
||||
|
||||
### 4. Accessibility Best Practices (HIGH)
|
||||
|
||||
- `a11y-automated-testing` - Use ember-a11y-testing for automated checks
|
||||
- `a11y-semantic-html` - Use semantic HTML and proper ARIA attributes
|
||||
- `a11y-keyboard-navigation` - Ensure full keyboard navigation support
|
||||
- `a11y-form-labels` - Associate labels with inputs, announce errors
|
||||
- `a11y-route-announcements` - Announce route transitions to screen readers
|
||||
|
||||
### 5. Service and State Management (MEDIUM-HIGH)
|
||||
|
||||
- `service-cache-responses` - Cache API responses in services
|
||||
- `service-shared-state` - Use services for shared state
|
||||
- `service-ember-data-optimization` - Optimize WarpDrive queries
|
||||
- `service-owner-linkage` - Manage service owner and linkage patterns
|
||||
- `service-data-requesting` - Implement robust data requesting patterns
|
||||
|
||||
### 6. Template Optimization (MEDIUM)
|
||||
|
||||
- `template-let-helper` - Use {{#let}} to avoid recomputation
|
||||
- `template-each-key` - Use {{#each}} with @key for efficient list updates
|
||||
- `template-avoid-computation` - Move expensive work to cached getters
|
||||
- `template-helper-imports` - Import helpers directly in templates
|
||||
- `template-conditional-rendering` - Optimize conditional rendering
|
||||
- `template-fn-helper` - Use {{fn}} helper for partial application
|
||||
- `template-only-component-functions` - Use template-only components
|
||||
- `helper-composition` - Compose helpers for reusable logic
|
||||
- `helper-builtin-functions` - Use built-in helpers effectively
|
||||
- `helper-plain-functions` - Write helpers as plain functions
|
||||
|
||||
### 7. Performance Optimization (MEDIUM)
|
||||
|
||||
- `performance-on-modifier-vs-handlers` - Use {{on}} modifier instead of event handler properties
|
||||
|
||||
### 8. Testing Best Practices (MEDIUM)
|
||||
|
||||
- `testing-modern-patterns` - Use modern testing patterns
|
||||
- `testing-qunit-dom-assertions` - Use qunit-dom for better test assertions
|
||||
- `testing-test-waiters` - Use @ember/test-waiters for async testing
|
||||
- `testing-render-patterns` - Use correct render patterns for components
|
||||
- `testing-msw-setup` - Mock API requests with MSW
|
||||
- `testing-library-dom-abstraction` - Use Testing Library patterns
|
||||
|
||||
### 9. Tooling and Configuration (MEDIUM)
|
||||
|
||||
- `vscode-setup-recommended` - VS Code extensions and MCP server setup
|
||||
|
||||
### 10. Advanced Patterns (MEDIUM-HIGH)
|
||||
|
||||
- `advanced-modifiers` - Use modifiers for DOM side effects
|
||||
- `advanced-helpers` - Extract reusable logic into helpers
|
||||
- `advanced-tracked-built-ins` - Use reactive collections from @ember/reactive/collections
|
||||
- `advanced-concurrency` - Use ember-concurrency for task management
|
||||
- `advanced-data-loading-with-ember-concurrency` - Data loading patterns with ember-concurrency
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
|
||||
```
|
||||
rules/route-parallel-model.md
|
||||
rules/bundle-embroider-static.md
|
||||
rules/a11y-automated-testing.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect code example with explanation
|
||||
- Correct code example with explanation
|
||||
- Additional context and references
|
||||
|
||||
## Accessibility with OSS Tools
|
||||
|
||||
Ember has excellent accessibility support through community addons:
|
||||
|
||||
- **ember-a11y-testing** - Automated accessibility testing in your test suite
|
||||
- **ember-a11y** - Route announcements and focus management
|
||||
- **ember-focus-trap** - Focus trapping for modals and dialogs
|
||||
- **ember-page-title** - Accessible page title management
|
||||
- **Platform-native validation** - Use browser's Constraint Validation API for accessible form validation
|
||||
|
||||
These tools, combined with native web platform features, provide comprehensive a11y support with minimal configuration.
|
||||
|
||||
## Full Compiled Document
|
||||
|
||||
For the complete guide with all rules expanded: `AGENTS.md`
|
||||
38
.agents/skills/ember-best-practices/build-agents.sh
Executable file
38
.agents/skills/ember-best-practices/build-agents.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
OUTPUT="AGENTS.md"
|
||||
RULES_DIR="rules"
|
||||
|
||||
# Start with the header
|
||||
cat > "$OUTPUT" << 'HEADER'
|
||||
# Ember Best Practices
|
||||
|
||||
Comprehensive performance optimization and accessibility patterns for modern Ember.js applications. Includes rules across 7 categories using gjs/gts format and modern Ember patterns.
|
||||
|
||||
---
|
||||
|
||||
HEADER
|
||||
|
||||
# Add sections
|
||||
cat "$RULES_DIR/_sections.md" >> "$OUTPUT"
|
||||
|
||||
echo "" >> "$OUTPUT"
|
||||
echo "---" >> "$OUTPUT"
|
||||
echo "" >> "$OUTPUT"
|
||||
|
||||
# Add all rules
|
||||
for file in "$RULES_DIR"/*.md; do
|
||||
# Skip the _sections.md file
|
||||
if [[ "$(basename "$file")" == "_sections.md" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Adding $(basename "$file")..." >&2
|
||||
cat "$file" >> "$OUTPUT"
|
||||
echo "" >> "$OUTPUT"
|
||||
echo "---" >> "$OUTPUT"
|
||||
echo "" >> "$OUTPUT"
|
||||
done
|
||||
|
||||
echo "Built $OUTPUT successfully!" >&2
|
||||
57
.agents/skills/ember-best-practices/rules/_sections.md
Normal file
57
.agents/skills/ember-best-practices/rules/_sections.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Sections
|
||||
|
||||
This file defines all sections, their ordering, impact levels, and descriptions.
|
||||
The section ID (in parentheses) is the filename prefix used to group rules.
|
||||
When multiple prefixes map to one section, all supported prefixes are listed.
|
||||
|
||||
---
|
||||
|
||||
## 1. Route Loading and Data Fetching (route)
|
||||
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Efficient route loading and parallel data fetching eliminate waterfalls. Using route model hooks effectively and loading data in parallel yields the largest performance gains.
|
||||
|
||||
## 2. Build and Bundle Optimization (bundle)
|
||||
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Using Embroider with static build optimizations, route-based code splitting, and proper imports reduces bundle size and improves Time to Interactive.
|
||||
|
||||
## 3. Component and Reactivity Optimization (component, exports)
|
||||
|
||||
**Impact:** HIGH
|
||||
**Description:** Proper use of Glimmer components, modern file conventions, tracked properties, and avoiding unnecessary recomputation improves rendering performance.
|
||||
|
||||
## 4. Accessibility Best Practices (a11y)
|
||||
|
||||
**Impact:** HIGH
|
||||
**Description:** Making applications accessible is critical. Use ember-a11y-testing, semantic HTML, proper ARIA attributes, and keyboard navigation support.
|
||||
|
||||
## 5. Service and State Management (service)
|
||||
|
||||
**Impact:** MEDIUM-HIGH
|
||||
**Description:** Efficient service patterns, proper dependency injection, and state management reduce redundant computations and API calls.
|
||||
|
||||
## 6. Template Optimization (template, helper)
|
||||
|
||||
**Impact:** MEDIUM
|
||||
**Description:** Optimizing templates with proper helpers, avoiding expensive computations in templates, and using {{#each}} efficiently improves rendering speed.
|
||||
|
||||
## 7. Performance Optimization (performance)
|
||||
|
||||
**Impact:** MEDIUM
|
||||
**Description:** Performance-focused rendering and event handling patterns help reduce unnecessary work in hot UI paths.
|
||||
|
||||
## 8. Testing Best Practices (testing)
|
||||
|
||||
**Impact:** MEDIUM
|
||||
**Description:** Modern testing patterns, waiters, and abstraction utilities improve test reliability and maintainability.
|
||||
|
||||
## 9. Tooling and Configuration (vscode)
|
||||
|
||||
**Impact:** MEDIUM
|
||||
**Description:** Consistent editor setup and tooling recommendations improve team productivity and reduce environment drift.
|
||||
|
||||
## 10. Advanced Patterns (advanced)
|
||||
|
||||
**Impact:** MEDIUM-HIGH
|
||||
**Description:** Modern Ember patterns including Resources for lifecycle management, ember-concurrency for async operations, modifiers for DOM side effects, helpers for reusable logic, and comprehensive testing patterns with render strategies.
|
||||
28
.agents/skills/ember-best-practices/rules/_template.md
Normal file
28
.agents/skills/ember-best-practices/rules/_template.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: Rule Title Here
|
||||
impact: MEDIUM
|
||||
impactDescription: Optional description of impact (e.g., "20-50% improvement")
|
||||
tags: tag1, tag2
|
||||
---
|
||||
|
||||
## Rule Title Here
|
||||
|
||||
**Impact: MEDIUM (optional impact description)**
|
||||
|
||||
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
|
||||
|
||||
**Incorrect (description of what's wrong):**
|
||||
|
||||
```glimmer-ts
|
||||
// Bad code example here
|
||||
const bad = example();
|
||||
```
|
||||
|
||||
**Correct (description of what's right):**
|
||||
|
||||
```glimmer-ts
|
||||
// Good code example here
|
||||
const good = example();
|
||||
```
|
||||
|
||||
Reference: [Link to documentation or resource](https://example.com)
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: Use ember-a11y-testing for Automated Checks
|
||||
impact: HIGH
|
||||
impactDescription: Catch 30-50% of a11y issues automatically
|
||||
tags: accessibility, a11y, testing, ember-a11y-testing
|
||||
---
|
||||
|
||||
## Use ember-a11y-testing for Automated Checks
|
||||
|
||||
Integrate ember-a11y-testing into your test suite to automatically catch common accessibility violations during development. This addon uses axe-core to identify issues before they reach production.
|
||||
|
||||
**Incorrect (no accessibility testing):**
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/user-form-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, fillIn, click } from '@ember/test-helpers';
|
||||
import UserForm from 'my-app/components/user-form';
|
||||
|
||||
module('Integration | Component | user-form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it submits the form', async function (assert) {
|
||||
await render(<template><UserForm /></template>);
|
||||
await fillIn('input', 'John');
|
||||
await click('button');
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (with a11y testing):**
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/user-form-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, fillIn, click } from '@ember/test-helpers';
|
||||
import a11yAudit from 'ember-a11y-testing/test-support/audit';
|
||||
import UserForm from 'my-app/components/user-form';
|
||||
|
||||
module('Integration | Component | user-form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it submits the form', async function (assert) {
|
||||
await render(<template><UserForm /></template>);
|
||||
|
||||
// Automatically checks for a11y violations
|
||||
await a11yAudit();
|
||||
|
||||
await fillIn('input', 'John');
|
||||
await click('button');
|
||||
assert.ok(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Setup (install and configure):**
|
||||
|
||||
```bash
|
||||
ember install ember-a11y-testing
|
||||
```
|
||||
|
||||
```javascript
|
||||
// tests/test-helper.js
|
||||
import { setupGlobalA11yHooks } from 'ember-a11y-testing/test-support';
|
||||
|
||||
setupGlobalA11yHooks(); // Runs on every test automatically
|
||||
```
|
||||
|
||||
ember-a11y-testing catches issues like missing labels, insufficient color contrast, invalid ARIA, and keyboard navigation problems automatically.
|
||||
|
||||
Reference: [ember-a11y-testing](https://github.com/ember-a11y/ember-a11y-testing)
|
||||
147
.agents/skills/ember-best-practices/rules/a11y-form-labels.md
Normal file
147
.agents/skills/ember-best-practices/rules/a11y-form-labels.md
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Form Labels and Error Announcements
|
||||
impact: HIGH
|
||||
impactDescription: Essential for screen reader users
|
||||
tags: accessibility, a11y, forms, aria-live
|
||||
---
|
||||
|
||||
## Form Labels and Error Announcements
|
||||
|
||||
All form inputs must have associated labels, and validation errors should be announced to screen readers using ARIA live regions.
|
||||
|
||||
**Incorrect (missing labels and announcements):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/form.gjs
|
||||
<template>
|
||||
<form {{on "submit" this.handleSubmit}}>
|
||||
<input type="email" value={{this.email}} {{on "input" this.updateEmail}} placeholder="Email" />
|
||||
|
||||
{{#if this.emailError}}
|
||||
<span>{{this.emailError}}</span>
|
||||
{{/if}}
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (with labels and announcements):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/form.gjs
|
||||
<template>
|
||||
<form {{on "submit" this.handleSubmit}}>
|
||||
<div>
|
||||
<label for="email-input">
|
||||
Email Address
|
||||
{{#if this.isEmailRequired}}
|
||||
<span aria-label="required">*</span>
|
||||
{{/if}}
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="email-input"
|
||||
type="email"
|
||||
value={{this.email}}
|
||||
{{on "input" this.updateEmail}}
|
||||
aria-describedby={{if this.emailError "email-error"}}
|
||||
aria-invalid={{if this.emailError "true"}}
|
||||
required={{this.isEmailRequired}}
|
||||
/>
|
||||
|
||||
{{#if this.emailError}}
|
||||
<span id="email-error" role="alert" aria-live="polite">
|
||||
{{this.emailError}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={{this.isSubmitting}}>
|
||||
{{#if this.isSubmitting}}
|
||||
<span aria-live="polite">Submitting...</span>
|
||||
{{else}}
|
||||
Submit
|
||||
{{/if}}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
**For complex forms, use platform-native validation with custom logic:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-form.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class UserForm extends Component {
|
||||
@tracked errorMessages = {};
|
||||
|
||||
validateEmail = (event) => {
|
||||
// Custom business logic validation
|
||||
const input = event.target;
|
||||
const value = input.value;
|
||||
|
||||
if (!value) {
|
||||
input.setCustomValidity('Email is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!input.validity.valid) {
|
||||
input.setCustomValidity('Must be a valid email');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional custom validation (e.g., check if email is already taken)
|
||||
if (value === 'taken@example.com') {
|
||||
input.setCustomValidity('This email is already registered');
|
||||
return false;
|
||||
}
|
||||
|
||||
input.setCustomValidity('');
|
||||
return true;
|
||||
};
|
||||
|
||||
handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
|
||||
// Run custom validations
|
||||
const emailInput = form.querySelector('[name="email"]');
|
||||
const fakeEvent = { target: emailInput };
|
||||
this.validateEmail(fakeEvent);
|
||||
|
||||
// Use native validation check
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
await this.args.onSubmit(formData);
|
||||
};
|
||||
|
||||
<template>
|
||||
<form {{on "submit" this.handleSubmit}}>
|
||||
<label for="user-email">
|
||||
Email
|
||||
<input
|
||||
id="user-email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
value={{@user.email}}
|
||||
{{on "blur" this.validateEmail}}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
Always associate labels with inputs and announce dynamic changes to screen readers using aria-live regions.
|
||||
|
||||
Reference: [Ember Accessibility - Application Considerations](https://guides.emberjs.com/release/accessibility/application-considerations/)
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Keyboard Navigation Support
|
||||
impact: HIGH
|
||||
impactDescription: Critical for keyboard-only users
|
||||
tags: accessibility, a11y, keyboard, focus-management
|
||||
---
|
||||
|
||||
## Keyboard Navigation Support
|
||||
|
||||
Ensure all interactive elements are keyboard accessible and focus management is handled properly, especially in modals and dynamic content.
|
||||
|
||||
**Incorrect (no keyboard support):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/dropdown.gjs
|
||||
<template>
|
||||
<div class="dropdown" {{on "click" this.toggleMenu}}>
|
||||
Menu
|
||||
{{#if this.isOpen}}
|
||||
<div class="dropdown-menu">
|
||||
<div {{on "click" this.selectOption}}>Option 1</div>
|
||||
<div {{on "click" this.selectOption}}>Option 2</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (full keyboard support with custom modifier):**
|
||||
|
||||
```javascript
|
||||
// app/modifiers/focus-first.js
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element, [selector = 'button']) => {
|
||||
// Focus first matching element when modifier runs
|
||||
element.querySelector(selector)?.focus();
|
||||
});
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/dropdown.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { fn } from '@ember/helper';
|
||||
import focusFirst from '../modifiers/focus-first';
|
||||
|
||||
class Dropdown extends Component {
|
||||
@tracked isOpen = false;
|
||||
|
||||
@action
|
||||
toggleMenu() {
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
|
||||
@action
|
||||
handleButtonKeyDown(event) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.isOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleMenuKeyDown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
this.isOpen = false;
|
||||
// Return focus to button
|
||||
event.target.closest('.dropdown').querySelector('button').focus();
|
||||
}
|
||||
// Handle arrow key navigation between menu items
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.moveFocus(event.key === 'ArrowDown' ? 1 : -1);
|
||||
}
|
||||
}
|
||||
|
||||
moveFocus(direction) {
|
||||
const items = Array.from(document.querySelectorAll('[role="menuitem"] button'));
|
||||
const currentIndex = items.indexOf(document.activeElement);
|
||||
const nextIndex = (currentIndex + direction + items.length) % items.length;
|
||||
items[nextIndex]?.focus();
|
||||
}
|
||||
|
||||
@action
|
||||
selectOption(value) {
|
||||
this.args.onSelect?.(value);
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="dropdown">
|
||||
<button
|
||||
type="button"
|
||||
{{on "click" this.toggleMenu}}
|
||||
{{on "keydown" this.handleButtonKeyDown}}
|
||||
aria-haspopup="true"
|
||||
aria-expanded="{{this.isOpen}}"
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
|
||||
{{#if this.isOpen}}
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
role="menu"
|
||||
{{focusFirst '[role="menuitem"] button'}}
|
||||
{{on "keydown" this.handleMenuKeyDown}}
|
||||
>
|
||||
<li role="menuitem">
|
||||
<button type="button" {{on "click" (fn this.selectOption "1")}}>
|
||||
Option 1
|
||||
</button>
|
||||
</li>
|
||||
<li role="menuitem">
|
||||
<button type="button" {{on "click" (fn this.selectOption "2")}}>
|
||||
Option 2
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**For focus trapping in modals, use ember-focus-trap:**
|
||||
|
||||
```bash
|
||||
ember install ember-focus-trap
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/modal.gjs
|
||||
import FocusTrap from 'ember-focus-trap/components/focus-trap';
|
||||
|
||||
<template>
|
||||
{{#if this.showModal}}
|
||||
<FocusTrap @isActive={{true}} @initialFocus="#modal-title">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<h2 id="modal-title">{{@title}}</h2>
|
||||
{{yield}}
|
||||
<button type="button" {{on "click" this.closeModal}}>Close</button>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
{{/if}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**Alternative: Use libraries for keyboard support:**
|
||||
|
||||
For complex keyboard interactions, consider using libraries that abstract keyboard support patterns:
|
||||
|
||||
```bash
|
||||
npm install @fluentui/keyboard-keys
|
||||
```
|
||||
|
||||
Or use [tabster](https://tabster.io/) for comprehensive keyboard navigation management including focus trapping, arrow key navigation, and modalizers.
|
||||
|
||||
Proper keyboard navigation ensures all users can interact with your application effectively.
|
||||
|
||||
Reference: [Ember Accessibility - Keyboard](https://guides.emberjs.com/release/accessibility/keyboard/)
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Announce Route Transitions to Screen Readers
|
||||
impact: HIGH
|
||||
impactDescription: Critical for screen reader navigation
|
||||
tags: accessibility, a11y, routing, screen-readers
|
||||
---
|
||||
|
||||
## Announce Route Transitions to Screen Readers
|
||||
|
||||
Announce page title changes and route transitions to screen readers so users know when navigation has occurred.
|
||||
|
||||
**Incorrect (no announcements):**
|
||||
|
||||
```javascript
|
||||
// app/router.js
|
||||
export default class Router extends EmberRouter {
|
||||
location = config.locationType;
|
||||
rootURL = config.rootURL;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using a11y-announcer library - recommended):**
|
||||
|
||||
Use the [a11y-announcer](https://github.com/ember-a11y/a11y-announcer) library for robust route announcements:
|
||||
|
||||
```bash
|
||||
ember install @ember-a11y/a11y-announcer
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/router.js
|
||||
import EmberRouter from '@ember/routing/router';
|
||||
import config from './config/environment';
|
||||
|
||||
export default class Router extends EmberRouter {
|
||||
location = config.locationType;
|
||||
rootURL = config.rootURL;
|
||||
}
|
||||
|
||||
Router.map(function () {
|
||||
this.route('about');
|
||||
this.route('dashboard');
|
||||
this.route('posts', function () {
|
||||
this.route('post', { path: '/:post_id' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
The a11y-announcer library automatically handles route announcements. For custom announcements in your routes:
|
||||
|
||||
```javascript
|
||||
// app/routes/dashboard.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class DashboardRoute extends Route {
|
||||
@service announcer;
|
||||
|
||||
afterModel() {
|
||||
this.announcer.announce('Loaded dashboard with latest data');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative: DIY approach with ARIA live regions:**
|
||||
|
||||
If you prefer not to use a library, you can implement route announcements yourself:
|
||||
|
||||
```javascript
|
||||
// app/router.js
|
||||
import EmberRouter from '@ember/routing/router';
|
||||
import config from './config/environment';
|
||||
|
||||
export default class Router extends EmberRouter {
|
||||
location = config.locationType;
|
||||
rootURL = config.rootURL;
|
||||
}
|
||||
|
||||
Router.map(function () {
|
||||
this.route('about');
|
||||
this.route('dashboard');
|
||||
this.route('posts', function () {
|
||||
this.route('post', { path: '/:post_id' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/routes/application.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ApplicationRoute extends Route {
|
||||
@service router;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.router.on('routeDidChange', (transition) => {
|
||||
// Update document title
|
||||
const title = this.getPageTitle(transition.to);
|
||||
document.title = title;
|
||||
|
||||
// Announce to screen readers
|
||||
this.announceRouteChange(title);
|
||||
});
|
||||
}
|
||||
|
||||
getPageTitle(route) {
|
||||
// Get title from route metadata or generate it
|
||||
return route.metadata?.title || route.name;
|
||||
}
|
||||
|
||||
announceRouteChange(title) {
|
||||
const announcement = document.getElementById('route-announcement');
|
||||
if (announcement) {
|
||||
announcement.textContent = `Navigated to ${title}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/application.gjs
|
||||
<template>
|
||||
<div
|
||||
id="route-announcement"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
class="sr-only"
|
||||
></div>
|
||||
|
||||
{{outlet}}
|
||||
</template>
|
||||
```
|
||||
|
||||
```css
|
||||
/* app/styles/app.css */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative: Use ember-page-title with announcements:**
|
||||
|
||||
```bash
|
||||
ember install ember-page-title
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/dashboard.gjs
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
|
||||
<template>
|
||||
{{pageTitle "Dashboard"}}
|
||||
|
||||
<div class="dashboard">
|
||||
{{outlet}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Route announcements ensure screen reader users know when navigation occurs, improving the overall accessibility experience.
|
||||
|
||||
Reference: [Ember Accessibility - Page Titles](https://guides.emberjs.com/release/accessibility/page-template-considerations/)
|
||||
102
.agents/skills/ember-best-practices/rules/a11y-semantic-html.md
Normal file
102
.agents/skills/ember-best-practices/rules/a11y-semantic-html.md
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Semantic HTML and ARIA Attributes
|
||||
impact: HIGH
|
||||
impactDescription: Essential for screen reader users
|
||||
tags: accessibility, a11y, semantic-html, aria
|
||||
---
|
||||
|
||||
## Semantic HTML and ARIA Attributes
|
||||
|
||||
Use semantic HTML elements and proper ARIA attributes to make your application accessible to screen reader users. **The first rule of ARIA is to not use ARIA** - prefer native semantic HTML elements whenever possible.
|
||||
|
||||
**Key principle:** Native HTML elements have built-in keyboard support, roles, and behaviors. Only add ARIA when semantic HTML can't provide the needed functionality.
|
||||
|
||||
**Incorrect (divs with insufficient semantics):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/example.gjs
|
||||
<template>
|
||||
<div class="button" {{on "click" this.submit}}>
|
||||
Submit
|
||||
</div>
|
||||
|
||||
<div class="nav">
|
||||
<div class="nav-item">Home</div>
|
||||
<div class="nav-item">About</div>
|
||||
</div>
|
||||
|
||||
<div class="alert">
|
||||
{{this.message}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (semantic HTML with proper ARIA):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/example.gjs
|
||||
import { LinkTo } from '@ember/routing';
|
||||
|
||||
<template>
|
||||
<button type="submit" {{on "click" this.submit}}>
|
||||
Submit
|
||||
</button>
|
||||
|
||||
<nav aria-label="Main navigation">
|
||||
<ul>
|
||||
<li><LinkTo @route="index">Home</LinkTo></li>
|
||||
<li><LinkTo @route="about">About</LinkTo></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div role="alert" aria-live="polite" aria-atomic="true">
|
||||
{{this.message}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**For interactive custom elements:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/custom-button.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import XIcon from './x-icon';
|
||||
|
||||
class CustomButton extends Component {
|
||||
@action
|
||||
handleKeyDown(event) {
|
||||
// Support Enter and Space keys for keyboard users
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.handleClick();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleClick() {
|
||||
this.args.onClick?.();
|
||||
}
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
{{on "click" this.handleClick}}
|
||||
{{on "keydown" this.handleKeyDown}}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<XIcon />
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
Always use native semantic elements when possible. When creating custom interactive elements, ensure they're keyboard accessible and have proper ARIA attributes.
|
||||
|
||||
**References:**
|
||||
|
||||
- [ARIA Authoring Practices Guide (W3C)](https://www.w3.org/WAI/ARIA/apg/)
|
||||
- [Using ARIA (W3C)](https://www.w3.org/TR/using-aria/)
|
||||
- [ARIA in HTML (WHATWG)](https://html.spec.whatwg.org/multipage/aria.html#aria)
|
||||
- [Ember Accessibility Guide](https://guides.emberjs.com/release/accessibility/)
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: Use Ember Concurrency for User Input Concurrency
|
||||
impact: HIGH
|
||||
impactDescription: Better control of user-initiated async operations
|
||||
tags: ember-concurrency, tasks, user-input, concurrency-patterns
|
||||
---
|
||||
|
||||
## Use Ember Concurrency for User Input Concurrency
|
||||
|
||||
Use ember-concurrency for managing **user-initiated** async operations like search, form submission, and autocomplete. It provides automatic cancelation, debouncing, and prevents race conditions from user actions.
|
||||
|
||||
**Incorrect (manual async handling with race conditions):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/search.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class Search extends Component {
|
||||
@tracked results = [];
|
||||
@tracked isSearching = false;
|
||||
@tracked error = null;
|
||||
currentRequest = null;
|
||||
|
||||
@action
|
||||
async search(event) {
|
||||
const query = event.target.value;
|
||||
|
||||
// Manual cancelation - easy to get wrong
|
||||
if (this.currentRequest) {
|
||||
this.currentRequest.abort();
|
||||
}
|
||||
|
||||
this.isSearching = true;
|
||||
this.error = null;
|
||||
|
||||
const controller = new AbortController();
|
||||
this.currentRequest = controller;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${query}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
this.results = await response.json();
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
this.error = e.message;
|
||||
}
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<input {{on "input" this.search}} />
|
||||
{{#if this.isSearching}}Loading...{{/if}}
|
||||
{{#if this.error}}Error: {{this.error}}{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using ember-concurrency with task return values):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/search.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { restartableTask } from 'ember-concurrency';
|
||||
|
||||
class Search extends Component {
|
||||
// restartableTask automatically cancels previous searches
|
||||
// IMPORTANT: Return the value, don't set tracked state inside tasks
|
||||
searchTask = restartableTask(async (query) => {
|
||||
const response = await fetch(`/api/search?q=${query}`);
|
||||
return response.json(); // Return, don't set @tracked
|
||||
});
|
||||
|
||||
<template>
|
||||
<input {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />
|
||||
|
||||
{{#if this.searchTask.isRunning}}
|
||||
<div class="loading">Loading...</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.searchTask.last.isSuccessful}}
|
||||
<ul>
|
||||
{{#each this.searchTask.last.value as |result|}}
|
||||
<li>{{result.name}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.searchTask.last.isError}}
|
||||
<div class="error">{{this.searchTask.last.error.message}}</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**With debouncing for user typing:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/autocomplete.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { restartableTask, timeout } from 'ember-concurrency';
|
||||
|
||||
class Autocomplete extends Component {
|
||||
searchTask = restartableTask(async (query) => {
|
||||
// Debounce user typing - wait 300ms
|
||||
await timeout(300);
|
||||
|
||||
const response = await fetch(`/api/autocomplete?q=${query}`);
|
||||
return response.json(); // Return value, don't set tracked state
|
||||
});
|
||||
|
||||
<template>
|
||||
<input
|
||||
type="search"
|
||||
{{on "input" (fn this.searchTask.perform (pick "target.value"))}}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
|
||||
{{#if this.searchTask.isRunning}}
|
||||
<div class="spinner"></div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.searchTask.lastSuccessful}}
|
||||
<ul class="suggestions">
|
||||
{{#each this.searchTask.lastSuccessful.value as |item|}}
|
||||
<li>{{item.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Task modifiers for different user concurrency patterns:**
|
||||
|
||||
```glimmer-js
|
||||
import Component from '@glimmer/component';
|
||||
import { dropTask, enqueueTask, restartableTask } from 'ember-concurrency';
|
||||
|
||||
class FormActions extends Component {
|
||||
// dropTask: Prevents double-click - ignores new while running
|
||||
saveTask = dropTask(async (data) => {
|
||||
const response = await fetch('/api/save', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return response.json();
|
||||
});
|
||||
|
||||
// enqueueTask: Queues user actions sequentially
|
||||
processTask = enqueueTask(async (item) => {
|
||||
const response = await fetch('/api/process', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(item),
|
||||
});
|
||||
return response.json();
|
||||
});
|
||||
|
||||
// restartableTask: Cancels previous, starts new (for search)
|
||||
searchTask = restartableTask(async (query) => {
|
||||
const response = await fetch(`/api/search?q=${query}`);
|
||||
return response.json();
|
||||
});
|
||||
|
||||
<template>
|
||||
<button {{on "click" (fn this.saveTask.perform @data)}} disabled={{this.saveTask.isRunning}}>
|
||||
Save
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Key Principles for ember-concurrency:**
|
||||
|
||||
1. **User-initiated only** - Use for handling user actions, not component initialization
|
||||
2. **Return values** - Use `task.last.value`, never set `@tracked` state inside tasks
|
||||
3. **Avoid side effects** - Don't modify component state that's read during render inside tasks
|
||||
4. **Choose right modifier**:
|
||||
- `restartableTask` - User typing/search (cancel previous)
|
||||
- `dropTask` - Form submit/save (prevent double-click)
|
||||
- `enqueueTask` - Sequential processing (queue user actions)
|
||||
|
||||
**When NOT to use ember-concurrency:**
|
||||
|
||||
- ❌ Component initialization data loading (use `getPromiseState` instead)
|
||||
- ❌ Setting tracked state inside tasks (causes infinite render loops)
|
||||
- ❌ Route model hooks (return promises directly)
|
||||
- ❌ Simple async without user concurrency concerns (use async/await)
|
||||
|
||||
See **advanced-data-loading-with-ember-concurrency.md** for correct data loading patterns.
|
||||
|
||||
ember-concurrency provides automatic cancelation, derived state (isRunning, isIdle), and better patterns for **user-initiated** async operations.
|
||||
|
||||
Reference: [ember-concurrency](https://ember-concurrency.com/)
|
||||
@@ -0,0 +1,243 @@
|
||||
---
|
||||
title: Use Ember Concurrency Correctly - User Concurrency Not Data Loading
|
||||
impact: HIGH
|
||||
impactDescription: Prevents infinite render loops and improves performance
|
||||
tags: ember-concurrency, tasks, data-loading, anti-pattern
|
||||
---
|
||||
|
||||
## Use Ember Concurrency Correctly - User Concurrency Not Data Loading
|
||||
|
||||
ember-concurrency is designed for **user-initiated concurrency patterns** (debouncing, throttling, preventing double-clicks), not data loading. Use task return values, don't set tracked state inside tasks.
|
||||
|
||||
**Incorrect (using ember-concurrency for data loading with tracked state):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
class UserProfile extends Component {
|
||||
@tracked userData = null;
|
||||
@tracked error = null;
|
||||
|
||||
// WRONG: Setting tracked state inside task
|
||||
loadUserTask = task(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/users/${this.args.userId}`);
|
||||
this.userData = await response.json(); // Anti-pattern!
|
||||
} catch (e) {
|
||||
this.error = e; // Anti-pattern!
|
||||
}
|
||||
});
|
||||
|
||||
<template>
|
||||
{{#if this.loadUserTask.isRunning}}
|
||||
Loading...
|
||||
{{else if this.userData}}
|
||||
<h1>{{this.userData.name}}</h1>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Is Wrong:**
|
||||
|
||||
- Setting tracked state during render can cause infinite render loops
|
||||
- ember-concurrency adds overhead unnecessary for simple data loading
|
||||
- Makes component state harder to reason about
|
||||
- Can trigger multiple re-renders
|
||||
|
||||
**Correct (use getPromiseState from warp-drive/reactiveweb for data loading):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
import { getPromiseState } from '@warp-drive/reactiveweb';
|
||||
|
||||
class UserProfile extends Component {
|
||||
@cached
|
||||
get userData() {
|
||||
const promise = fetch(`/api/users/${this.args.userId}`).then((r) => r.json());
|
||||
return getPromiseState(promise);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.userData.isPending}}
|
||||
<div>Loading...</div>
|
||||
{{else if this.userData.isRejected}}
|
||||
<div>Error: {{this.userData.error.message}}</div>
|
||||
{{else if this.userData.isFulfilled}}
|
||||
<h1>{{this.userData.value.name}}</h1>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use ember-concurrency for USER input with derived data patterns):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/search.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { restartableTask, timeout } from 'ember-concurrency';
|
||||
import { on } from '@ember/modifier';
|
||||
import { pick } from 'ember-composable-helpers';
|
||||
|
||||
class Search extends Component {
|
||||
// CORRECT: For user-initiated search with debouncing
|
||||
// Use derived data from TaskInstance API - lastSuccessful
|
||||
searchTask = restartableTask(async (query) => {
|
||||
await timeout(300); // Debounce user typing
|
||||
const response = await fetch(`/api/search?q=${query}`);
|
||||
return response.json(); // Return value, don't set tracked state
|
||||
});
|
||||
|
||||
<template>
|
||||
<input type="search" {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />
|
||||
|
||||
{{! Use derived data from task state - no tracked properties needed }}
|
||||
{{#if this.searchTask.isRunning}}
|
||||
<div>Searching...</div>
|
||||
{{/if}}
|
||||
|
||||
{{! lastSuccessful persists previous results while new search runs }}
|
||||
{{#if this.searchTask.lastSuccessful}}
|
||||
<ul>
|
||||
{{#each this.searchTask.lastSuccessful.value as |result|}}
|
||||
<li>{{result.name}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
{{! Show error from most recent failed attempt }}
|
||||
{{#if this.searchTask.last.isError}}
|
||||
<div>Error: {{this.searchTask.last.error.message}}</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Good Use Cases for ember-concurrency:**
|
||||
|
||||
1. **User input debouncing** - prevent API spam from typing
|
||||
2. **Form submission** - prevent double-click submits with `dropTask`
|
||||
3. **Autocomplete** - restart previous searches as user types
|
||||
4. **Polling** - user-controlled refresh intervals
|
||||
5. **Multi-step wizards** - sequential async operations
|
||||
|
||||
```glimmer-js
|
||||
// app/components/form-submit.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { dropTask } from 'ember-concurrency';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
|
||||
class FormSubmit extends Component {
|
||||
// dropTask prevents double-submit - perfect for user actions
|
||||
submitTask = dropTask(async (formData) => {
|
||||
const response = await fetch('/api/save', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
return response.json();
|
||||
});
|
||||
|
||||
<template>
|
||||
<button
|
||||
{{on "click" (fn this.submitTask.perform @formData)}}
|
||||
disabled={{this.submitTask.isRunning}}
|
||||
>
|
||||
{{#if this.submitTask.isRunning}}
|
||||
Saving...
|
||||
{{else}}
|
||||
Save
|
||||
{{/if}}
|
||||
</button>
|
||||
|
||||
{{! Use lastSuccessful for success message - derived data }}
|
||||
{{#if this.submitTask.lastSuccessful}}
|
||||
<div>Saved successfully!</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.submitTask.last.isError}}
|
||||
<div>Error: {{this.submitTask.last.error.message}}</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Bad Use Cases for ember-concurrency:**
|
||||
|
||||
1. ❌ **Loading data on component init** - use `getPromiseState` instead
|
||||
2. ❌ **Route model hooks** - just return promises directly
|
||||
3. ❌ **Simple API calls** - async/await is sufficient
|
||||
4. ❌ **Setting tracked state inside tasks** - causes render loops
|
||||
|
||||
**Key Principles:**
|
||||
|
||||
- **Derive data, don't set it** - Use `task.lastSuccessful`, `task.last`, `task.isRunning` (derived from TaskInstance API)
|
||||
- **Use task return values** - Read from `task.lastSuccessful.value` or `task.last.value`, never set tracked state
|
||||
- **User-initiated only** - ember-concurrency is for handling user concurrency patterns
|
||||
- **Data loading** - Use `getPromiseState` from warp-drive/reactiveweb for non-user-initiated loading
|
||||
- **Avoid side effects** - Don't modify component state inside tasks that's read during render
|
||||
|
||||
**TaskInstance API for Derived Data:**
|
||||
|
||||
ember-concurrency provides a powerful derived data API through Task and TaskInstance:
|
||||
|
||||
- `task.last` - The most recent TaskInstance (successful or failed)
|
||||
- `task.lastSuccessful` - The most recent successful TaskInstance (persists during new attempts)
|
||||
- `task.isRunning` - Derived boolean if any instance is running
|
||||
- `taskInstance.value` - The returned value from the task
|
||||
- `taskInstance.isError` - Derived boolean if this instance failed
|
||||
- `taskInstance.error` - The error if this instance failed
|
||||
|
||||
This follows the **derived data pattern** - all state comes from the task itself, no tracked properties needed!
|
||||
|
||||
References:
|
||||
|
||||
- [TaskInstance API](https://ember-concurrency.com/api/TaskInstance.html)
|
||||
- [Task API](https://ember-concurrency.com/api/Task.html)
|
||||
|
||||
**Migration from tracked state pattern:**
|
||||
|
||||
```glimmer-js
|
||||
// BEFORE (anti-pattern - setting tracked state)
|
||||
class Bad extends Component {
|
||||
@tracked data = null;
|
||||
|
||||
fetchTask = task(async () => {
|
||||
this.data = await fetch('/api/data').then((r) => r.json());
|
||||
});
|
||||
|
||||
// template reads: {{this.data}}
|
||||
}
|
||||
|
||||
// AFTER (correct - using derived data from TaskInstance API)
|
||||
class Good extends Component {
|
||||
fetchTask = restartableTask(async () => {
|
||||
return fetch('/api/data').then((r) => r.json());
|
||||
});
|
||||
|
||||
// template reads: {{this.fetchTask.lastSuccessful.value}}
|
||||
// All state derived from task - no tracked properties!
|
||||
}
|
||||
|
||||
// Or better yet, for non-user-initiated loading:
|
||||
class Better extends Component {
|
||||
@cached
|
||||
get data() {
|
||||
return getPromiseState(fetch('/api/data').then((r) => r.json()));
|
||||
}
|
||||
|
||||
// template reads: {{#if this.data.isFulfilled}}{{this.data.value}}{{/if}}
|
||||
}
|
||||
```
|
||||
|
||||
ember-concurrency is a powerful tool for **user concurrency patterns**. For data loading, use `getPromiseState` instead.
|
||||
|
||||
Reference:
|
||||
|
||||
- [ember-concurrency](https://ember-concurrency.com/)
|
||||
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
|
||||
156
.agents/skills/ember-best-practices/rules/advanced-helpers.md
Normal file
156
.agents/skills/ember-best-practices/rules/advanced-helpers.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: Use Helper Functions for Reusable Logic
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Better code reuse and testability
|
||||
tags: helpers, templates, reusability, advanced
|
||||
---
|
||||
|
||||
## Use Helper Functions for Reusable Logic
|
||||
|
||||
Extract reusable template logic into helper functions that can be tested independently and used across templates.
|
||||
|
||||
**Incorrect (logic duplicated in components):**
|
||||
|
||||
```javascript
|
||||
// app/components/user-card.js
|
||||
class UserCard extends Component {
|
||||
get formattedDate() {
|
||||
const date = new Date(this.args.user.createdAt);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
// app/components/post-card.js - same logic duplicated!
|
||||
class PostCard extends Component {
|
||||
get formattedDate() {
|
||||
// Same implementation...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (reusable helper):**
|
||||
|
||||
For single-use helpers, keep them in the same file as the component:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/post-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
// Helper co-located in same file
|
||||
function formatRelativeDate(date) {
|
||||
const dateObj = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now - dateObj;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return dateObj.toLocaleDateString();
|
||||
}
|
||||
|
||||
class PostList extends Component {
|
||||
<template>
|
||||
{{#each @posts as |post|}}
|
||||
<article>
|
||||
<h2>{{post.title}}</h2>
|
||||
<time>{{formatRelativeDate post.createdAt}}</time>
|
||||
</article>
|
||||
{{/each}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
For helpers shared across multiple components in a feature, use a subdirectory:
|
||||
|
||||
```javascript
|
||||
// app/components/blog/format-relative-date.js
|
||||
export function formatRelativeDate(date) {
|
||||
const dateObj = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now - dateObj;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return dateObj.toLocaleDateString();
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative (shared helper in utils):**
|
||||
|
||||
For truly shared helpers used across the whole app, use `app/utils/`:
|
||||
|
||||
```javascript
|
||||
// app/utils/format-relative-date.js
|
||||
// Flat structure - use subpath-imports in package.json for nicer imports if needed
|
||||
export function formatRelativeDate(date) {
|
||||
const dateObj = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now - dateObj;
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
return dateObj.toLocaleDateString();
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Keep utils flat (`app/utils/format-relative-date.js`), not nested (`app/utils/date/format-relative-date.js`). If you need cleaner top-level imports, configure subpath-imports in package.json instead of nesting files.
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
import { formatRelativeDate } from '../utils/format-relative-date';
|
||||
|
||||
<template>
|
||||
<p>Joined: {{formatRelativeDate @user.createdAt}}</p>
|
||||
</template>
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/post-card.gjs
|
||||
import { formatRelativeDate } from '../utils/format-relative-date';
|
||||
|
||||
<template>
|
||||
<p>Posted: {{formatRelativeDate @post.createdAt}}</p>
|
||||
</template>
|
||||
```
|
||||
|
||||
**For helpers with state, use class-based helpers:**
|
||||
|
||||
```javascript
|
||||
// app/utils/helpers/format-currency.js
|
||||
export class FormatCurrencyHelper {
|
||||
constructor(owner) {
|
||||
this.intl = owner.lookup('service:intl');
|
||||
}
|
||||
|
||||
compute(amount, { currency = 'USD' } = {}) {
|
||||
return this.intl.formatNumber(amount, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Common helpers to create:**
|
||||
|
||||
- Date/time formatting
|
||||
- Number formatting
|
||||
- String manipulation
|
||||
- Array operations
|
||||
- Conditional logic
|
||||
|
||||
Helpers promote code reuse, are easier to test, and keep components focused on behavior.
|
||||
|
||||
Reference: [Ember Helpers](https://guides.emberjs.com/release/components/helper-functions/)
|
||||
129
.agents/skills/ember-best-practices/rules/advanced-modifiers.md
Normal file
129
.agents/skills/ember-best-practices/rules/advanced-modifiers.md
Normal file
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Use Modifiers for DOM Side Effects
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Better separation of concerns
|
||||
tags: modifiers, dom, lifecycle, advanced
|
||||
---
|
||||
|
||||
## Use Modifiers for DOM Side Effects
|
||||
|
||||
Use modifiers (element modifiers) to handle DOM side effects and lifecycle events in a reusable, composable way.
|
||||
|
||||
**Incorrect (manual DOM manipulation in component):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/chart.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class Chart extends Component {
|
||||
chartInstance = null;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
// Can't access element here - element doesn't exist yet!
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy();
|
||||
this.chartInstance?.destroy();
|
||||
}
|
||||
|
||||
<template>
|
||||
<canvas id="chart-canvas"></canvas>
|
||||
{{! Manual setup is error-prone and not reusable }}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (function modifier - preferred for simple side effects):**
|
||||
|
||||
```javascript
|
||||
// app/modifiers/chart.js
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element, [config]) => {
|
||||
// Initialize chart
|
||||
const chartInstance = new Chart(element, config);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
chartInstance.destroy();
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
**Also correct (class-based modifier for complex state):**
|
||||
|
||||
```javascript
|
||||
// app/modifiers/chart.js
|
||||
import Modifier from 'ember-modifier';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
|
||||
export default class ChartModifier extends Modifier {
|
||||
chartInstance = null;
|
||||
|
||||
modify(element, [config]) {
|
||||
// Cleanup previous instance if config changed
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.destroy();
|
||||
}
|
||||
|
||||
this.chartInstance = new Chart(element, config);
|
||||
|
||||
// Register cleanup
|
||||
registerDestructor(this, () => {
|
||||
this.chartInstance?.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/chart.gjs
|
||||
import chart from '../modifiers/chart';
|
||||
|
||||
<template>
|
||||
<canvas {{chart @config}}></canvas>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Use function modifiers** for simple side effects. Use class-based modifiers only when you need complex state management.
|
||||
|
||||
**For commonly needed modifiers, use ember-modifier helpers:**
|
||||
|
||||
```javascript
|
||||
// app/modifiers/autofocus.js
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element) => {
|
||||
element.focus();
|
||||
});
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/input-field.gjs
|
||||
import autofocus from '../modifiers/autofocus';
|
||||
|
||||
<template><input {{autofocus}} type="text" /></template>
|
||||
```
|
||||
|
||||
**Use ember-resize-observer-modifier for resize handling:**
|
||||
|
||||
```bash
|
||||
ember install ember-resize-observer-modifier
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/resizable.gjs
|
||||
import onResize from 'ember-resize-observer-modifier';
|
||||
|
||||
<template>
|
||||
<div {{onResize this.handleResize}}>
|
||||
Content that responds to size changes
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Modifiers provide a clean, reusable way to manage DOM side effects without coupling to specific components.
|
||||
|
||||
Reference: [Ember Modifiers](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/)
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: Use Reactive Collections from @ember/reactive/collections
|
||||
impact: HIGH
|
||||
impactDescription: Enables reactive arrays, maps, and sets
|
||||
tags: reactivity, tracked, collections, advanced
|
||||
---
|
||||
|
||||
## Use Reactive Collections from @ember/reactive/collections
|
||||
|
||||
Use reactive collections from `@ember/reactive/collections` to make arrays, Maps, and Sets reactive in Ember. Standard JavaScript collections don't trigger Ember's reactivity system when mutated—reactive collections solve this.
|
||||
|
||||
**The Problem:**
|
||||
Standard arrays, Maps, and Sets are not reactive in Ember when you mutate them. Changes won't trigger template updates.
|
||||
|
||||
**The Solution:**
|
||||
Use Ember's built-in reactive collections from `@ember/reactive/collections`.
|
||||
|
||||
### Reactive Arrays
|
||||
|
||||
**Incorrect (non-reactive array):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/todo-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class TodoList extends Component {
|
||||
@tracked todos = []; // ❌ Array mutations (push, splice, etc.) won't trigger updates
|
||||
|
||||
@action
|
||||
addTodo(text) {
|
||||
// This won't trigger a re-render!
|
||||
this.todos.push({ id: Date.now(), text });
|
||||
}
|
||||
|
||||
@action
|
||||
removeTodo(id) {
|
||||
// This also won't trigger a re-render!
|
||||
const index = this.todos.findIndex((t) => t.id === id);
|
||||
this.todos.splice(index, 1);
|
||||
}
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
{{#each this.todos as |todo|}}
|
||||
<li>
|
||||
{{todo.text}}
|
||||
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (reactive array with @ember/reactive/collections):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/todo-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { trackedArray } from '@ember/reactive/collections';
|
||||
|
||||
export default class TodoList extends Component {
|
||||
todos = trackedArray([]); // ✅ Mutations are reactive
|
||||
|
||||
@action
|
||||
addTodo(text) {
|
||||
// Now this triggers re-render!
|
||||
this.todos.push({ id: Date.now(), text });
|
||||
}
|
||||
|
||||
@action
|
||||
removeTodo(id) {
|
||||
// This also triggers re-render!
|
||||
const index = this.todos.findIndex((t) => t.id === id);
|
||||
this.todos.splice(index, 1);
|
||||
}
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
{{#each this.todos as |todo|}}
|
||||
<li>
|
||||
{{todo.text}}
|
||||
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Reactive Maps
|
||||
|
||||
Maps are useful for key-value stores with non-string keys:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-cache.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { trackedMap } from '@ember/reactive/collections';
|
||||
|
||||
export default class UserCache extends Component {
|
||||
userCache = trackedMap(); // key: userId, value: userData
|
||||
|
||||
@action
|
||||
cacheUser(userId, userData) {
|
||||
this.userCache.set(userId, userData);
|
||||
}
|
||||
|
||||
@action
|
||||
clearUser(userId) {
|
||||
this.userCache.delete(userId);
|
||||
}
|
||||
|
||||
get cachedUsers() {
|
||||
return Array.from(this.userCache.values());
|
||||
}
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
{{#each this.cachedUsers as |user|}}
|
||||
<li>{{user.name}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<p>Cache size: {{this.userCache.size}}</p>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Reactive Sets
|
||||
|
||||
Sets are useful for unique collections:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/tag-selector.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { trackedSet } from '@ember/reactive/collections';
|
||||
|
||||
export default class TagSelector extends Component {
|
||||
selectedTags = trackedSet();
|
||||
|
||||
@action
|
||||
toggleTag(tag) {
|
||||
if (this.selectedTags.has(tag)) {
|
||||
this.selectedTags.delete(tag);
|
||||
} else {
|
||||
this.selectedTags.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
get selectedCount() {
|
||||
return this.selectedTags.size;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>
|
||||
{{#each @availableTags as |tag|}}
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={{this.selectedTags.has tag}}
|
||||
{{on "change" (fn this.toggleTag tag)}}
|
||||
/>
|
||||
{{tag}}
|
||||
</label>
|
||||
{{/each}}
|
||||
</div>
|
||||
<p>Selected: {{this.selectedCount}} tags</p>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Each Type
|
||||
|
||||
| Type | Use Case |
|
||||
| -------------- | ------------------------------------------------------------------ |
|
||||
| `trackedArray` | Ordered lists that need mutation methods (push, pop, splice, etc.) |
|
||||
| `trackedMap` | Key-value pairs with non-string keys or when you need `size` |
|
||||
| `trackedSet` | Unique values, membership testing |
|
||||
|
||||
### Common Patterns
|
||||
|
||||
**Initialize with data:**
|
||||
|
||||
```javascript
|
||||
import { trackedArray, trackedMap, trackedSet } from '@ember/reactive/collections';
|
||||
|
||||
// Array
|
||||
const todos = trackedArray([
|
||||
{ id: 1, text: 'First' },
|
||||
{ id: 2, text: 'Second' },
|
||||
]);
|
||||
|
||||
// Map
|
||||
const userMap = trackedMap([
|
||||
[1, { name: 'Alice' }],
|
||||
[2, { name: 'Bob' }],
|
||||
]);
|
||||
|
||||
// Set
|
||||
const tags = trackedSet(['javascript', 'ember', 'web']);
|
||||
```
|
||||
|
||||
**Convert to plain JavaScript:**
|
||||
|
||||
```javascript
|
||||
// Array
|
||||
const plainArray = [...trackedArray];
|
||||
const plainArray2 = Array.from(trackedArray);
|
||||
|
||||
// Map
|
||||
const plainObject = Object.fromEntries(trackedMap);
|
||||
|
||||
// Set
|
||||
const plainArray3 = [...trackedSet];
|
||||
```
|
||||
|
||||
**Functional array methods still work:**
|
||||
|
||||
```javascript
|
||||
const todos = trackedArray([...]);
|
||||
|
||||
// All of these work and are reactive
|
||||
const completed = todos.filter(t => t.done);
|
||||
const titles = todos.map(t => t.title);
|
||||
const allDone = todos.every(t => t.done);
|
||||
const firstIncomplete = todos.find(t => !t.done);
|
||||
```
|
||||
|
||||
### Alternative: Immutable Updates
|
||||
|
||||
If you prefer immutability, you can use regular `@tracked` with reassignment:
|
||||
|
||||
```javascript
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class TodoList extends Component {
|
||||
@tracked todos = [];
|
||||
|
||||
@action
|
||||
addTodo(text) {
|
||||
// Reassignment is reactive
|
||||
this.todos = [...this.todos, { id: Date.now(), text }];
|
||||
}
|
||||
|
||||
@action
|
||||
removeTodo(id) {
|
||||
// Reassignment is reactive
|
||||
this.todos = this.todos.filter((t) => t.id !== id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use each approach:**
|
||||
|
||||
- Use reactive collections when you need mutable operations (better performance for large lists)
|
||||
- Use immutable updates when you want simpler mental model or need history/undo
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Don't mix approaches** - choose either reactive collections or immutable updates
|
||||
2. **Initialize in class field** - no need for constructor
|
||||
3. **Use appropriate type** - Map for key-value, Set for unique values, Array for ordered lists
|
||||
4. **Export from modules** if shared across components
|
||||
|
||||
Reactive collections from `@ember/reactive/collections` provide the best of both worlds: mutable operations with full reactivity. They're especially valuable for large lists or frequent updates where immutable updates would be expensive.
|
||||
|
||||
**References:**
|
||||
|
||||
- [Ember Reactivity System](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
|
||||
- [JavaScript Built-in Objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects)
|
||||
- [Reactive Collections RFC](https://github.com/emberjs/rfcs/blob/master/text/0869-reactive-collections.md)
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Avoid Importing Entire Addon Namespaces
|
||||
impact: CRITICAL
|
||||
impactDescription: 200-500ms import cost reduction
|
||||
tags: bundle, imports, tree-shaking, performance
|
||||
---
|
||||
|
||||
## Avoid Importing Entire Addon Namespaces
|
||||
|
||||
Import specific utilities and components directly rather than entire addon namespaces to enable better tree-shaking and reduce bundle size.
|
||||
|
||||
**Incorrect (imports entire namespace):**
|
||||
|
||||
```javascript
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
// OK - these are already optimized
|
||||
|
||||
// But avoid this pattern with utility libraries:
|
||||
import * as lodash from 'lodash';
|
||||
import * as moment from 'moment';
|
||||
|
||||
class My extends Component {
|
||||
someMethod() {
|
||||
return lodash.debounce(this.handler, 300);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (direct imports):**
|
||||
|
||||
```javascript
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import debounce from 'lodash/debounce';
|
||||
import dayjs from 'dayjs'; // moment alternative, smaller
|
||||
|
||||
class My extends Component {
|
||||
someMethod() {
|
||||
return debounce(this.handler, 300);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Even better (use Ember utilities when available):**
|
||||
|
||||
```javascript
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { debounce } from '@ember/runloop';
|
||||
|
||||
class My extends Component {
|
||||
someMethod() {
|
||||
return debounce(this, this.handler, 300);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Direct imports and using built-in Ember utilities reduce bundle size by avoiding unused code.
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Use Embroider Build Pipeline
|
||||
impact: CRITICAL
|
||||
impactDescription: Modern build system with better performance
|
||||
tags: bundle, embroider, build-performance, vite
|
||||
---
|
||||
|
||||
## Use Embroider Build Pipeline
|
||||
|
||||
Use Embroider, Ember's modern build pipeline, with Vite for faster builds, better tree-shaking, and smaller bundles.
|
||||
|
||||
**Incorrect (classic build pipeline):**
|
||||
|
||||
```javascript
|
||||
// ember-cli-build.js
|
||||
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
|
||||
|
||||
module.exports = function (defaults) {
|
||||
const app = new EmberApp(defaults, {});
|
||||
return app.toTree();
|
||||
};
|
||||
```
|
||||
|
||||
**Correct (Embroider with Vite):**
|
||||
|
||||
```javascript
|
||||
// ember-cli-build.js
|
||||
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
|
||||
const { compatBuild } = require('@embroider/compat');
|
||||
|
||||
module.exports = async function (defaults) {
|
||||
const { buildOnce } = await import('@embroider/vite');
|
||||
|
||||
let app = new EmberApp(defaults, {
|
||||
// Add options here
|
||||
});
|
||||
|
||||
return compatBuild(app, buildOnce);
|
||||
};
|
||||
```
|
||||
|
||||
**For stricter static analysis (optimized mode):**
|
||||
|
||||
```javascript
|
||||
// ember-cli-build.js
|
||||
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
|
||||
const { compatBuild } = require('@embroider/compat');
|
||||
|
||||
module.exports = async function (defaults) {
|
||||
const { buildOnce } = await import('@embroider/vite');
|
||||
|
||||
let app = new EmberApp(defaults, {
|
||||
// Add options here
|
||||
});
|
||||
|
||||
return compatBuild(app, buildOnce, {
|
||||
// Enable static analysis for better tree-shaking
|
||||
staticAddonTestSupportTrees: true,
|
||||
staticAddonTrees: true,
|
||||
staticHelpers: true,
|
||||
staticModifiers: true,
|
||||
staticComponents: true,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Embroider provides a modern build pipeline with Vite that offers faster builds and better optimization compared to the classic Ember CLI build system.
|
||||
|
||||
Reference: [Embroider Documentation](https://github.com/embroider-build/embroider)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Lazy Load Heavy Dependencies
|
||||
impact: CRITICAL
|
||||
impactDescription: 30-50% initial bundle reduction
|
||||
tags: bundle, lazy-loading, dynamic-imports, performance
|
||||
---
|
||||
|
||||
## Lazy Load Heavy Dependencies
|
||||
|
||||
Use dynamic imports to load heavy libraries only when needed, reducing initial bundle size.
|
||||
|
||||
**Incorrect (loaded upfront):**
|
||||
|
||||
```javascript
|
||||
import Component from '@glimmer/component';
|
||||
import Chart from 'chart.js/auto'; // 300KB library loaded immediately
|
||||
import hljs from 'highlight.js'; // 500KB library loaded immediately
|
||||
|
||||
class Dashboard extends Component {
|
||||
get showChart() {
|
||||
return this.args.hasData;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (lazy loaded with error/loading state handling):**
|
||||
|
||||
```glimmer-js
|
||||
import Component from '@glimmer/component';
|
||||
import { getPromiseState } from 'reactiveweb/promise';
|
||||
|
||||
class Dashboard extends Component {
|
||||
// Use getPromiseState to model promise state for error/loading handling
|
||||
chartLoader = getPromiseState(async () => {
|
||||
const { default: Chart } = await import('chart.js/auto');
|
||||
return Chart;
|
||||
});
|
||||
|
||||
highlighterLoader = getPromiseState(async () => {
|
||||
const { default: hljs } = await import('highlight.js');
|
||||
return hljs;
|
||||
});
|
||||
|
||||
loadChart = () => {
|
||||
// Triggers lazy load, handles loading/error states automatically
|
||||
return this.chartLoader.value;
|
||||
};
|
||||
|
||||
highlightCode = (code) => {
|
||||
const hljs = this.highlighterLoader.value;
|
||||
if (hljs) {
|
||||
return hljs.highlightAuto(code);
|
||||
}
|
||||
return code;
|
||||
};
|
||||
|
||||
<template>
|
||||
{{#if this.chartLoader.isLoading}}
|
||||
<p>Loading chart library...</p>
|
||||
{{else if this.chartLoader.isError}}
|
||||
<p>Error loading chart: {{this.chartLoader.error.message}}</p>
|
||||
{{else if this.chartLoader.isResolved}}
|
||||
<canvas {{on "click" this.loadChart}}></canvas>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Always model promise state (loading/error/resolved) using `getPromiseState` from `reactiveweb/promise` to handle slow networks and errors properly.
|
||||
|
||||
Dynamic imports reduce initial bundle size by 30-50%, improving Time to Interactive.
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Validate Component Arguments
|
||||
impact: MEDIUM
|
||||
impactDescription: Better error messages and type safety
|
||||
tags: components, validation, arguments, typescript
|
||||
---
|
||||
|
||||
## Validate Component Arguments
|
||||
|
||||
Validate component arguments for better error messages, documentation, and type safety.
|
||||
|
||||
**Incorrect (no argument validation):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserCard extends Component {
|
||||
<template>
|
||||
<div>
|
||||
<h3>{{@user.name}}</h3>
|
||||
<p>{{@user.email}}</p>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (with TypeScript signature):**
|
||||
|
||||
```glimmer-ts
|
||||
// app/components/user-card.gts
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
interface UserCardSignature {
|
||||
Args: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
onEdit?: (user: UserCardSignature['Args']['user']) => void;
|
||||
};
|
||||
Blocks: {
|
||||
default: [];
|
||||
};
|
||||
Element: HTMLDivElement;
|
||||
}
|
||||
|
||||
class UserCard extends Component<UserCardSignature> {
|
||||
<template>
|
||||
<div ...attributes>
|
||||
<h3>{{@user.name}}</h3>
|
||||
<p>{{@user.email}}</p>
|
||||
|
||||
{{#if @user.avatarUrl}}
|
||||
<img src={{@user.avatarUrl}} alt={{@user.name}} />
|
||||
{{/if}}
|
||||
|
||||
{{#if @onEdit}}
|
||||
<button {{on "click" (fn @onEdit @user)}}>Edit</button>
|
||||
{{/if}}
|
||||
|
||||
{{yield}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Runtime validation with assertions (using getters):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-table.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
class DataTable extends Component {
|
||||
// Use getters so validation runs on each access and catches arg changes
|
||||
get columns() {
|
||||
assert(
|
||||
'DataTable requires @columns argument',
|
||||
this.args.columns && Array.isArray(this.args.columns),
|
||||
);
|
||||
|
||||
assert(
|
||||
'@columns must be an array of objects with "key" and "label" properties',
|
||||
this.args.columns.every((col) => col.key && col.label),
|
||||
);
|
||||
|
||||
return this.args.columns;
|
||||
}
|
||||
|
||||
get rows() {
|
||||
assert('DataTable requires @rows argument', this.args.rows && Array.isArray(this.args.rows));
|
||||
|
||||
return this.args.rows;
|
||||
}
|
||||
|
||||
<template>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{{#each this.columns as |column|}}
|
||||
<th>{{column.label}}</th>
|
||||
{{/each}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each this.rows as |row|}}
|
||||
<tr>
|
||||
{{#each this.columns as |column|}}
|
||||
<td>{{get row column.key}}</td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Template-only component with TypeScript:**
|
||||
|
||||
```glimmer-ts
|
||||
// app/components/icon.gts
|
||||
import type { TOC } from '@ember/component/template-only';
|
||||
|
||||
interface IconSignature {
|
||||
Args: {
|
||||
name: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
};
|
||||
Element: HTMLSpanElement;
|
||||
}
|
||||
|
||||
const Icon: TOC<IconSignature> = <template>
|
||||
<span ...attributes></span>
|
||||
</template>;
|
||||
|
||||
export default Icon;
|
||||
```
|
||||
|
||||
**Documentation with JSDoc:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/modal.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
/**
|
||||
* Modal dialog component
|
||||
*
|
||||
* @param {Object} args
|
||||
* @param {boolean} args.isOpen - Controls modal visibility
|
||||
* @param {() => void} args.onClose - Called when modal should close
|
||||
* @param {string} [args.title] - Optional modal title
|
||||
* @param {string} [args.size='medium'] - Modal size: 'small', 'medium', 'large'
|
||||
*/
|
||||
class Modal extends Component {
|
||||
<template>
|
||||
{{#if @isOpen}}
|
||||
<div>
|
||||
{{#if @title}}
|
||||
<h2>{{@title}}</h2>
|
||||
{{/if}}
|
||||
{{yield}}
|
||||
<button {{on "click" @onClose}}>Close</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
Argument validation provides better error messages during development, serves as documentation, and enables better IDE support.
|
||||
|
||||
Reference: [TypeScript in Ember](https://guides.emberjs.com/release/typescript/)
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Avoid CSS Classes in Learning Examples
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Cleaner, more focused learning materials
|
||||
tags: documentation, examples, learning, css, classes
|
||||
---
|
||||
|
||||
## Avoid CSS Classes in Learning Examples
|
||||
|
||||
Don't add CSS classes to learning content and examples unless they provide actual value above the surrounding context. Classes add visual noise and distract from the concepts being taught.
|
||||
|
||||
**Incorrect (unnecessary classes in learning example):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export class UserCard extends Component {
|
||||
<template>
|
||||
<div class="user-card">
|
||||
<div class="user-card__header">
|
||||
<h3 class="user-card__name">{{@user.name}}</h3>
|
||||
<p class="user-card__email">{{@user.email}}</p>
|
||||
</div>
|
||||
|
||||
{{#if @user.avatarUrl}}
|
||||
<img class="user-card__avatar" src={{@user.avatarUrl}} alt={{@user.name}} />
|
||||
{{/if}}
|
||||
|
||||
{{#if @onEdit}}
|
||||
<button class="user-card__edit-button" {{on "click" (fn @onEdit @user)}}>
|
||||
Edit
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
<div class="user-card__content">
|
||||
{{yield}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Is Wrong:**
|
||||
|
||||
- Classes add visual clutter that obscures the actual concepts
|
||||
- Learners focus on naming conventions instead of the pattern being taught
|
||||
- Makes copy-paste more work (need to remove or change class names)
|
||||
- Implies these specific class names are required or best practice
|
||||
- Distracts from structural HTML and component logic
|
||||
|
||||
**Correct (focused on concepts):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export class UserCard extends Component {
|
||||
<template>
|
||||
<div ...attributes>
|
||||
<h3>{{@user.name}}</h3>
|
||||
<p>{{@user.email}}</p>
|
||||
|
||||
{{#if @user.avatarUrl}}
|
||||
<img src={{@user.avatarUrl}} alt={{@user.name}} />
|
||||
{{/if}}
|
||||
|
||||
{{#if @onEdit}}
|
||||
<button {{on "click" (fn @onEdit @user)}}>Edit</button>
|
||||
{{/if}}
|
||||
|
||||
{{yield}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- **Clarity**: Easier to understand the component structure
|
||||
- **Focus**: Reader attention stays on the concepts being taught
|
||||
- **Simplicity**: Less code to process mentally
|
||||
- **Flexibility**: Reader can add their own classes without conflict
|
||||
- **Reusability**: Examples are easier to adapt to real code
|
||||
|
||||
**When Classes ARE Appropriate in Examples:**
|
||||
|
||||
```glimmer-js
|
||||
// Example: Teaching about conditional classes
|
||||
export class StatusBadge extends Component {
|
||||
get statusClass() {
|
||||
return this.args.status === 'active' ? 'badge-success' : 'badge-error';
|
||||
}
|
||||
|
||||
<template>
|
||||
<span class={{this.statusClass}}>
|
||||
{{@status}}
|
||||
</span>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Example: Teaching about ...attributes for styling flexibility
|
||||
export class Card extends Component {
|
||||
<template>
|
||||
{{! Caller can add their own classes via ...attributes }}
|
||||
<div ...attributes>
|
||||
{{yield}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
||||
{{! Usage: <Card class="user-card">...</Card> }}
|
||||
```
|
||||
|
||||
**When to Include Classes:**
|
||||
|
||||
1. **Teaching class binding** - Example explicitly about conditional classes or class composition
|
||||
2. **Demonstrating ...attributes** - Showing how callers add classes
|
||||
3. **Accessibility** - Using classes for semantic meaning (e.g., `aria-*` helpers)
|
||||
4. **Critical to example** - Class name is essential to understanding (e.g., `selected`, `active`)
|
||||
|
||||
**Examples Where Classes Add Value:**
|
||||
|
||||
```glimmer-js
|
||||
// Good: Teaching about dynamic classes
|
||||
export class TabButton extends Component {
|
||||
<template>
|
||||
<button class={{if @isActive "active"}} {{on "click" @onClick}}>
|
||||
{{yield}}
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Good: Teaching about class composition
|
||||
import { cn } from 'ember-cn';
|
||||
|
||||
export class Button extends Component {
|
||||
<template>
|
||||
<button class={{cn "btn" (if @primary "btn-primary" "btn-secondary")}}>
|
||||
{{yield}}
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Default Stance:**
|
||||
|
||||
When writing learning examples or documentation:
|
||||
|
||||
1. **Start without classes** - Add them only if needed
|
||||
2. **Ask**: Does this class help explain the concept?
|
||||
3. **Remove** any decorative or structural classes that aren't essential
|
||||
4. **Use** `...attributes` to show styling flexibility
|
||||
|
||||
**Real-World Context:**
|
||||
|
||||
In production code, you'll have classes for styling. But in learning materials, strip them away unless they're teaching something specific about classes themselves.
|
||||
|
||||
**Common Violations:**
|
||||
|
||||
❌ BEM classes in examples (`user-card__header`)
|
||||
❌ Utility classes unless teaching utilities (`flex`, `mt-4`)
|
||||
❌ Semantic classes that don't teach anything (`container`, `wrapper`)
|
||||
❌ Design system classes unless teaching design system integration
|
||||
|
||||
**Summary:**
|
||||
|
||||
Keep learning examples focused on the concept being taught. CSS classes should appear only when they're essential to understanding the pattern or when demonstrating styling flexibility with `...attributes`.
|
||||
|
||||
Reference: [Ember Components Guide](https://guides.emberjs.com/release/components/)
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: Avoid Constructors in Components
|
||||
impact: HIGH
|
||||
impactDescription: Prevents infinite render loops and simplifies code
|
||||
tags: components, constructors, initialization, anti-pattern
|
||||
---
|
||||
|
||||
## Avoid Constructors in Components
|
||||
|
||||
**Strongly discourage constructor usage.** Modern Ember components rarely need constructors. Use class fields, @service decorators, and getPromiseState for initialization instead. Constructors with function calls that set tracked state can cause infinite render loops.
|
||||
|
||||
**Incorrect (using constructor):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
class UserProfile extends Component {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
// Anti-pattern: Manual service lookup
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.router = this.owner.lookup('service:router');
|
||||
|
||||
// Anti-pattern: Imperative initialization
|
||||
this.data = null;
|
||||
this.isLoading = false;
|
||||
this.error = null;
|
||||
|
||||
// Anti-pattern: Side effects in constructor
|
||||
this.loadUserData();
|
||||
}
|
||||
|
||||
async loadUserData() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
this.data = await this.store.request({
|
||||
url: `/users/${this.args.userId}`,
|
||||
});
|
||||
} catch (e) {
|
||||
this.error = e;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.isLoading}}
|
||||
<div>Loading...</div>
|
||||
{{else if this.error}}
|
||||
<div>Error: {{this.error.message}}</div>
|
||||
{{else if this.data}}
|
||||
<h1>{{this.data.name}}</h1>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use class fields and declarative async state):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { getRequestState } from '@warp-drive/ember';
|
||||
|
||||
class UserProfile extends Component {
|
||||
@service store;
|
||||
|
||||
@cached
|
||||
get userRequest() {
|
||||
return this.store.request({
|
||||
url: `/users/${this.args.userId}`,
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#let (getRequestState this.userRequest) as |state|}}
|
||||
{{#if state.isPending}}
|
||||
<div>Loading...</div>
|
||||
{{else if state.isError}}
|
||||
<div>Error loading user</div>
|
||||
{{else}}
|
||||
<h1>{{state.value.name}}</h1>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**When You Might Need a Constructor (Very Rare):**
|
||||
|
||||
Very rarely, you might need a constructor for truly exceptional cases. Even then, use modern patterns:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/complex-setup.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
class ComplexSetup extends Component {
|
||||
@service store;
|
||||
|
||||
@tracked state = null;
|
||||
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
|
||||
// Only if you absolutely must do something that can't be done with class fields
|
||||
// Even then, prefer resources or modifiers
|
||||
if (this.args.legacyInitMode) {
|
||||
this.initializeLegacyMode();
|
||||
}
|
||||
}
|
||||
|
||||
initializeLegacyMode() {
|
||||
// Rare edge case initialization
|
||||
}
|
||||
|
||||
<template>
|
||||
<!-- template -->
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Why Strongly Avoid Constructors:**
|
||||
|
||||
1. **Infinite Render Loops**: Setting tracked state in constructor that's read during render causes infinite loops
|
||||
2. **Service Injection**: Use `@service` decorator instead of `owner.lookup()`
|
||||
3. **Testability**: Class fields are easier to mock and test
|
||||
4. **Clarity**: Declarative class fields show state at a glance
|
||||
5. **Side Effects**: getPromiseState and modifiers handle side effects better
|
||||
6. **Memory Leaks**: getPromiseState auto-cleanup; constructor code doesn't
|
||||
7. **Reactivity**: Class fields integrate better with tracking
|
||||
8. **Initialization Order**: No need to worry about super() call timing
|
||||
9. **Argument Validation**: Constructor validation runs only once; use getters to catch arg changes
|
||||
|
||||
**Modern Alternatives:**
|
||||
|
||||
| Old Pattern | Modern Alternative |
|
||||
| -------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `constructor() { this.store = owner.lookup('service:store') }` | `@service store;` |
|
||||
| `constructor() { this.data = null; }` | `@tracked data = null;` |
|
||||
| `constructor() { this.loadData(); }` | Use `@cached get` with getPromiseState |
|
||||
| `constructor() { this.interval = setInterval(...) }` | Use modifier with registerDestructor |
|
||||
| `constructor() { this.subscription = ... }` | Use modifier or constructor with registerDestructor ONLY |
|
||||
|
||||
**Performance Impact:**
|
||||
|
||||
- **Before**: Constructor runs on every instantiation, manual cleanup risk, infinite loop danger
|
||||
- **After**: Class fields initialize efficiently, getPromiseState auto-cleanup, no render loops
|
||||
|
||||
**Strongly discourage constructors** - they add complexity and infinite render loop risks. Use declarative class fields and getPromiseState instead.
|
||||
|
||||
Reference:
|
||||
|
||||
- [Ember Octane Guide](https://guides.emberjs.com/release/upgrading/current-edition/)
|
||||
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
|
||||
@@ -0,0 +1,322 @@
|
||||
---
|
||||
title: Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
|
||||
impact: HIGH
|
||||
impactDescription: Prevents memory leaks and enforces modern patterns
|
||||
tags: components, lifecycle, anti-pattern, modifiers, derived-data
|
||||
---
|
||||
|
||||
## Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
|
||||
|
||||
**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.** These legacy helpers create coupling between templates and component lifecycle, making code harder to test and maintain. Modern Ember provides better alternatives through derived data and custom modifiers.
|
||||
|
||||
### Why These Are Problematic
|
||||
|
||||
1. **Memory Leaks**: Easy to forget cleanup, especially with `did-insert`
|
||||
2. **Tight Coupling**: Mixes template concerns with JavaScript logic
|
||||
3. **Poor Testability**: Lifecycle hooks are harder to unit test
|
||||
4. **Not Composable**: Can't be easily shared across components
|
||||
5. **Deprecated Pattern**: Not recommended in modern Ember
|
||||
|
||||
### Alternative 1: Use Derived Data
|
||||
|
||||
For computed values or reactive transformations, use getters and `@cached`:
|
||||
|
||||
**❌ Incorrect (did-update):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-greeting.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class UserGreeting extends Component {
|
||||
@tracked displayName = '';
|
||||
|
||||
@action
|
||||
updateDisplayName() {
|
||||
// Runs on every render - inefficient and error-prone
|
||||
this.displayName = `${this.args.user.firstName} ${this.args.user.lastName}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div {{did-update this.updateDisplayName @user}}>
|
||||
Hello,
|
||||
{{this.displayName}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Correct (derived data with getter):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-greeting.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserGreeting extends Component {
|
||||
// Automatically reactive - updates when args change
|
||||
get displayName() {
|
||||
return `${this.args.user.firstName} ${this.args.user.lastName}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>
|
||||
Hello,
|
||||
{{this.displayName}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Even better (use @cached for expensive computations):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-stats.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
class UserStats extends Component {
|
||||
@cached
|
||||
get sortedPosts() {
|
||||
// Expensive computation only runs when @posts changes
|
||||
return [...this.args.posts].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
@cached
|
||||
get statistics() {
|
||||
return {
|
||||
total: this.args.posts.length,
|
||||
published: this.args.posts.filter((p) => p.published).length,
|
||||
drafts: this.args.posts.filter((p) => !p.published).length,
|
||||
};
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p>Total: {{this.statistics.total}}</p>
|
||||
<p>Published: {{this.statistics.published}}</p>
|
||||
<p>Drafts: {{this.statistics.drafts}}</p>
|
||||
|
||||
<ul>
|
||||
{{#each this.sortedPosts as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Alternative 2: Use Custom Modifiers
|
||||
|
||||
For DOM side effects, element setup, or cleanup, use custom modifiers:
|
||||
|
||||
**❌ Incorrect (did-insert + will-destroy):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/chart.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class Chart extends Component {
|
||||
chartInstance = null;
|
||||
|
||||
@action
|
||||
setupChart(element) {
|
||||
this.chartInstance = new Chart(element, this.args.config);
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy();
|
||||
// Easy to forget cleanup!
|
||||
this.chartInstance?.destroy();
|
||||
}
|
||||
|
||||
<template>
|
||||
<canvas {{did-insert this.setupChart}}></canvas>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Correct (custom modifier with automatic cleanup):**
|
||||
|
||||
```javascript
|
||||
// app/modifiers/chart.js
|
||||
import { modifier } from 'ember-modifier';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
|
||||
export default modifier((element, [config]) => {
|
||||
// Setup
|
||||
const chartInstance = new Chart(element, config);
|
||||
|
||||
// Cleanup happens automatically
|
||||
registerDestructor(element, () => {
|
||||
chartInstance.destroy();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/chart.gjs
|
||||
import chart from '../modifiers/chart';
|
||||
|
||||
<template>
|
||||
<canvas {{chart @config}}></canvas>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Alternative 3: Use Resources for Lifecycle Management
|
||||
|
||||
For complex state management with automatic cleanup, use `ember-resources`:
|
||||
|
||||
**❌ Incorrect (did-insert for data fetching):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class UserProfile extends Component {
|
||||
@tracked userData = null;
|
||||
@tracked loading = true;
|
||||
controller = new AbortController();
|
||||
|
||||
@action
|
||||
async loadUser() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/users/${this.args.userId}`, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
this.userData = await response.json();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy();
|
||||
this.controller.abort(); // Easy to forget!
|
||||
}
|
||||
|
||||
<template>
|
||||
<div {{did-insert this.loadUser}}>
|
||||
{{#if this.loading}}
|
||||
Loading...
|
||||
{{else}}
|
||||
{{this.userData.name}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Correct (Resource with automatic cleanup):**
|
||||
|
||||
```javascript
|
||||
// app/resources/user-data.js
|
||||
import { Resource } from 'ember-resources';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class UserDataResource extends Resource {
|
||||
@tracked data = null;
|
||||
@tracked loading = true;
|
||||
controller = new AbortController();
|
||||
|
||||
modify(positional, named) {
|
||||
const [userId] = positional;
|
||||
this.loadUser(userId);
|
||||
}
|
||||
|
||||
async loadUser(userId) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
signal: this.controller.signal,
|
||||
});
|
||||
this.data = await response.json();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
willDestroy() {
|
||||
// Cleanup happens automatically
|
||||
this.controller.abort();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import UserDataResource from '../resources/user-data';
|
||||
|
||||
class UserProfile extends Component {
|
||||
userData = UserDataResource.from(this, () => [this.args.userId]);
|
||||
|
||||
<template>
|
||||
{{#if this.userData.loading}}
|
||||
Loading...
|
||||
{{else}}
|
||||
{{this.userData.data.name}}
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Each Alternative
|
||||
|
||||
| Use Case | Solution | Why |
|
||||
| ---------------- | ----------------------------------- | ----------------------------------------- |
|
||||
| Computed values | Getters + `@cached` | Reactive, efficient, no lifecycle needed |
|
||||
| DOM manipulation | Custom modifiers | Encapsulated, reusable, automatic cleanup |
|
||||
| Data fetching | getPromiseState from warp-drive | Declarative, automatic cleanup |
|
||||
| Event listeners | `{{on}}` modifier | Built-in, automatic cleanup |
|
||||
| Focus management | Custom modifier or ember-focus-trap | Proper lifecycle, accessibility |
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
If you have existing code using these hooks:
|
||||
|
||||
1. **Identify the purpose**: What is the hook doing?
|
||||
2. **Choose the right alternative**:
|
||||
- Deriving data? → Use getters/`@cached`
|
||||
- DOM setup/teardown? → Use a custom modifier
|
||||
- Async data loading? → Use getPromiseState from warp-drive
|
||||
3. **Test thoroughly**: Ensure cleanup happens correctly
|
||||
4. **Remove the legacy hook**: Delete `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}`
|
||||
|
||||
### Performance Benefits
|
||||
|
||||
Modern alternatives provide better performance:
|
||||
|
||||
- **Getters**: Only compute when dependencies change
|
||||
- **@cached**: Memoizes expensive computations
|
||||
- **Modifiers**: Scoped to specific elements, composable
|
||||
- **getPromiseState**: Declarative data loading, automatic cleanup
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
|
||||
❌ **Don't use `willDestroy()` for cleanup when a modifier would work**
|
||||
❌ **Don't use `@action` + `did-insert` when a getter would suffice**
|
||||
❌ **Don't manually track changes when `@cached` handles it automatically**
|
||||
❌ **Don't forget `registerDestructor` in custom modifiers**
|
||||
|
||||
### Summary
|
||||
|
||||
Modern Ember provides superior alternatives to legacy lifecycle hooks:
|
||||
|
||||
- **Derived Data**: Use getters and `@cached` for reactive computations
|
||||
- **DOM Side Effects**: Use custom modifiers with `registerDestructor`
|
||||
- **Async Data Loading**: Use getPromiseState from warp-drive/reactiveweb
|
||||
- **Better Code**: More testable, composable, and maintainable
|
||||
|
||||
**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.**
|
||||
|
||||
Reference:
|
||||
|
||||
- [Ember Modifiers](https://github.com/ember-modifier/ember-modifier)
|
||||
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
|
||||
- [Glimmer Tracking](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Use @cached for Expensive Getters
|
||||
impact: HIGH
|
||||
impactDescription: 50-90% reduction in recomputation
|
||||
tags: components, performance, caching, tracked
|
||||
---
|
||||
|
||||
## Use @cached for Expensive Getters
|
||||
|
||||
Use `@cached` from `@glimmer/tracking` to memoize expensive computations that depend on tracked properties. The cached value is automatically invalidated when dependencies change.
|
||||
|
||||
**Incorrect (recomputes on every access):**
|
||||
|
||||
```javascript
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class DataTable extends Component {
|
||||
get filteredAndSortedData() {
|
||||
// Expensive: runs on every access, even if nothing changed
|
||||
return this.args.data
|
||||
.filter((item) => item.status === this.args.filter)
|
||||
.sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
|
||||
.map((item) => this.transformItem(item));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (cached computation):**
|
||||
|
||||
```javascript
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
class DataTable extends Component {
|
||||
@cached
|
||||
get filteredAndSortedData() {
|
||||
// Computed once per unique combination of dependencies
|
||||
return this.args.data
|
||||
.filter((item) => item.status === this.args.filter)
|
||||
.sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
|
||||
.map((item) => this.transformItem(item));
|
||||
}
|
||||
|
||||
transformItem(item) {
|
||||
// Expensive transformation
|
||||
return { ...item, computed: this.expensiveCalculation(item) };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`@cached` memoizes the getter result and only recomputes when tracked dependencies change, providing 50-90% reduction in unnecessary work.
|
||||
|
||||
Reference: [@cached decorator](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/#toc_caching)
|
||||
@@ -0,0 +1,324 @@
|
||||
---
|
||||
title: Use Class Fields for Component Composition
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Better composition and initialization patterns
|
||||
tags: components, class-fields, composition, initialization
|
||||
---
|
||||
|
||||
## Use Class Fields for Component Composition
|
||||
|
||||
Use class fields for clean component composition, initialization, and dependency injection patterns. Tracked class fields should be **roots of state** - representing the minimal independent state that your component owns. In most apps, you should have very few tracked fields.
|
||||
|
||||
**Incorrect (imperative initialization, scattered state):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-manager.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
class DataManager extends Component {
|
||||
@service store;
|
||||
@service router;
|
||||
|
||||
// Scattered state management - hard to track relationships
|
||||
@tracked currentUser = null;
|
||||
@tracked isLoading = false;
|
||||
@tracked error = null;
|
||||
|
||||
loadData = async () => {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
this.currentUser = await this.store.request({ url: '/users/me' });
|
||||
} catch (e) {
|
||||
this.error = e;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
<template>
|
||||
<div>{{this.currentUser.name}}</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (class fields with proper patterns):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-manager.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { service } from '@ember/service';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
import { getPromiseState } from '@warp-drive/reactiveweb';
|
||||
|
||||
class DataManager extends Component {
|
||||
// Service injection as class fields
|
||||
@service store;
|
||||
@service router;
|
||||
|
||||
// Tracked state as class fields - this is a "root of state"
|
||||
// Most components should have very few of these
|
||||
@tracked selectedFilter = 'all';
|
||||
|
||||
// Data loading with getPromiseState
|
||||
@cached
|
||||
get currentUser() {
|
||||
const promise = this.store.request({
|
||||
url: '/users/me',
|
||||
});
|
||||
return getPromiseState(promise);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.currentUser.isFulfilled}}
|
||||
<div>{{this.currentUser.value.name}}</div>
|
||||
{{else if this.currentUser.isRejected}}
|
||||
<div>Error: {{this.currentUser.error.message}}</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Understanding "roots of state":**
|
||||
|
||||
Tracked fields should represent **independent state** that your component owns - not derived data or loaded data. Examples of good tracked fields:
|
||||
|
||||
- User selections (selected tab, filter option)
|
||||
- UI state (is modal open, is expanded)
|
||||
- Form input values (not yet persisted)
|
||||
|
||||
In most apps, you'll have very few tracked fields because most data comes from arguments, services, or computed getters.
|
||||
|
||||
**Composition through class field assignment:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/form-container.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { TrackedObject } from 'tracked-built-ins';
|
||||
|
||||
class FormContainer extends Component {
|
||||
// Compose form state
|
||||
@tracked formData = new TrackedObject({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
preferences: {
|
||||
newsletter: false,
|
||||
notifications: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Compose validation state
|
||||
@tracked errors = new TrackedObject({});
|
||||
|
||||
// Compose UI state
|
||||
@tracked ui = new TrackedObject({
|
||||
isSubmitting: false,
|
||||
isDirty: false,
|
||||
showErrors: false,
|
||||
});
|
||||
|
||||
// Computed field based on composed state
|
||||
get isValid() {
|
||||
return Object.keys(this.errors).length === 0 && this.formData.email && this.formData.firstName;
|
||||
}
|
||||
|
||||
get canSubmit() {
|
||||
return this.isValid && !this.ui.isSubmitting && this.ui.isDirty;
|
||||
}
|
||||
|
||||
updateField = (field, value) => {
|
||||
this.formData[field] = value;
|
||||
this.ui.isDirty = true;
|
||||
this.validate(field, value);
|
||||
};
|
||||
|
||||
validate(field, value) {
|
||||
if (field === 'email' && !value.includes('@')) {
|
||||
this.errors.email = 'Invalid email';
|
||||
} else {
|
||||
delete this.errors[field];
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<form>
|
||||
<input
|
||||
value={{this.formData.firstName}}
|
||||
{{on "input" (pick "target.value" (fn this.updateField "firstName"))}}
|
||||
/>
|
||||
|
||||
<button disabled={{not this.canSubmit}}>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Mixin-like composition with class fields:**
|
||||
|
||||
```javascript
|
||||
// app/utils/pagination-mixin.js
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export class PaginationState {
|
||||
@tracked page = 1;
|
||||
@tracked perPage = 20;
|
||||
|
||||
get offset() {
|
||||
return (this.page - 1) * this.perPage;
|
||||
}
|
||||
|
||||
nextPage = () => {
|
||||
this.page++;
|
||||
};
|
||||
|
||||
prevPage = () => {
|
||||
if (this.page > 1) this.page--;
|
||||
};
|
||||
|
||||
goToPage = (page) => {
|
||||
this.page = page;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/paginated-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
import { PaginationState } from '../utils/pagination-mixin';
|
||||
|
||||
class PaginatedList extends Component {
|
||||
// Compose pagination functionality
|
||||
pagination = new PaginationState();
|
||||
|
||||
@cached
|
||||
get paginatedItems() {
|
||||
const start = this.pagination.offset;
|
||||
const end = start + this.pagination.perPage;
|
||||
return this.args.items.slice(start, end);
|
||||
}
|
||||
|
||||
get totalPages() {
|
||||
return Math.ceil(this.args.items.length / this.pagination.perPage);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="list">
|
||||
{{#each this.paginatedItems as |item|}}
|
||||
<div>{{item.name}}</div>
|
||||
{{/each}}
|
||||
|
||||
<div class="pagination">
|
||||
<button {{on "click" this.pagination.prevPage}} disabled={{eq this.pagination.page 1}}>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span>Page {{this.pagination.page}} of {{this.totalPages}}</span>
|
||||
|
||||
<button
|
||||
{{on "click" this.pagination.nextPage}}
|
||||
disabled={{eq this.pagination.page this.totalPages}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Shareable state objects:**
|
||||
|
||||
```javascript
|
||||
// app/utils/selection-state.js
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { TrackedSet } from 'tracked-built-ins';
|
||||
|
||||
export class SelectionState {
|
||||
@tracked selectedIds = new TrackedSet();
|
||||
|
||||
get count() {
|
||||
return this.selectedIds.size;
|
||||
}
|
||||
|
||||
get hasSelection() {
|
||||
return this.selectedIds.size > 0;
|
||||
}
|
||||
|
||||
isSelected(id) {
|
||||
return this.selectedIds.has(id);
|
||||
}
|
||||
|
||||
toggle = (id) => {
|
||||
if (this.selectedIds.has(id)) {
|
||||
this.selectedIds.delete(id);
|
||||
} else {
|
||||
this.selectedIds.add(id);
|
||||
}
|
||||
};
|
||||
|
||||
selectAll = (items) => {
|
||||
items.forEach((item) => this.selectedIds.add(item.id));
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
this.selectedIds.clear();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/selectable-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { SelectionState } from '../utils/selection-state';
|
||||
|
||||
class SelectableList extends Component {
|
||||
// Compose selection behavior
|
||||
selection = new SelectionState();
|
||||
|
||||
get selectedItems() {
|
||||
return this.args.items.filter((item) => this.selection.isSelected(item.id));
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<button {{on "click" (fn this.selection.selectAll @items)}}>
|
||||
Select All
|
||||
</button>
|
||||
<button {{on "click" this.selection.clear}}>
|
||||
Clear
|
||||
</button>
|
||||
<span>{{this.selection.count}} selected</span>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{{#each @items as |item|}}
|
||||
<li class={{if (this.selection.isSelected item.id) "selected"}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={{this.selection.isSelected item.id}}
|
||||
{{on "change" (fn this.selection.toggle item.id)}}
|
||||
/>
|
||||
{{item.name}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{#if this.selection.hasSelection}}
|
||||
<div class="actions">
|
||||
<button>Delete {{this.selection.count}} items</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
Class fields provide clean composition patterns, better initialization, and shareable state objects that can be tested independently.
|
||||
|
||||
Reference: [JavaScript Class Fields](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Public_class_fields)
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
title: Use Component Composition Patterns
|
||||
impact: HIGH
|
||||
impactDescription: Better code reuse and maintainability
|
||||
tags: components, composition, yield, blocks, contextual-components
|
||||
---
|
||||
|
||||
## Use Component Composition Patterns
|
||||
|
||||
Use component composition with yield blocks, named blocks, and contextual components for flexible, reusable UI patterns.
|
||||
|
||||
**Named blocks** are for invocation consistency in design systems where you **don't want the caller to have full markup control**. They provide structured extension points while maintaining design system constraints - the same concept as named slots in other frameworks.
|
||||
|
||||
**Incorrect (monolithic component):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserCard extends Component {
|
||||
<template>
|
||||
<div class="user-card">
|
||||
<div class="header">
|
||||
<img src={{@user.avatar}} alt={{@user.name}} />
|
||||
<h3>{{@user.name}}</h3>
|
||||
<p>{{@user.email}}</p>
|
||||
</div>
|
||||
|
||||
{{#if @showActions}}
|
||||
<div class="actions">
|
||||
<button {{on "click" @onEdit}}>Edit</button>
|
||||
<button {{on "click" @onDelete}}>Delete</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if @showStats}}
|
||||
<div class="stats">
|
||||
<span>Posts: {{@user.postCount}}</span>
|
||||
<span>Followers: {{@user.followers}}</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (composable with named blocks):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserCard extends Component {
|
||||
<template>
|
||||
<div class="user-card" ...attributes>
|
||||
{{#if (has-block "header")}}
|
||||
{{yield to="header"}}
|
||||
{{else}}
|
||||
<div class="header">
|
||||
<img src={{@user.avatar}} alt={{@user.name}} />
|
||||
<h3>{{@user.name}}</h3>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{yield @user to="default"}}
|
||||
|
||||
{{#if (has-block "actions")}}
|
||||
<div class="actions">
|
||||
{{yield @user to="actions"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (has-block "footer")}}
|
||||
<div class="footer">
|
||||
{{yield @user to="footer"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Usage with flexible composition:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-list.gjs
|
||||
import UserCard from './user-card';
|
||||
|
||||
<template>
|
||||
{{#each @users as |user|}}
|
||||
<UserCard @user={{user}}>
|
||||
<:header>
|
||||
<div class="custom-header">
|
||||
<span class="badge">{{user.role}}</span>
|
||||
<h3>{{user.name}}</h3>
|
||||
</div>
|
||||
</:header>
|
||||
|
||||
<:default as |u|>
|
||||
<p class="bio">{{u.bio}}</p>
|
||||
<p class="email">{{u.email}}</p>
|
||||
</:default>
|
||||
|
||||
<:actions as |u|>
|
||||
<button {{on "click" (fn @onEdit u)}}>Edit</button>
|
||||
<button {{on "click" (fn @onDelete u)}}>Delete</button>
|
||||
</:actions>
|
||||
|
||||
<:footer as |u|>
|
||||
<div class="stats">
|
||||
Posts:
|
||||
{{u.postCount}}
|
||||
| Followers:
|
||||
{{u.followers}}
|
||||
</div>
|
||||
</:footer>
|
||||
</UserCard>
|
||||
{{/each}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**Contextual components pattern:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-table.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { hash } from '@ember/helper';
|
||||
|
||||
class HeaderCell extends Component {
|
||||
<template>
|
||||
<th class="sortable" {{on "click" @onSort}}>
|
||||
{{yield}}
|
||||
{{#if @sorted}}
|
||||
<span class="sort-icon">{{if @ascending "↑" "↓"}}</span>
|
||||
{{/if}}
|
||||
</th>
|
||||
</template>
|
||||
}
|
||||
|
||||
class Row extends Component {
|
||||
<template>
|
||||
<tr class={{if @selected "selected"}}>
|
||||
{{yield}}
|
||||
</tr>
|
||||
</template>
|
||||
}
|
||||
|
||||
class Cell extends Component {
|
||||
<template>
|
||||
<td>{{yield}}</td>
|
||||
</template>
|
||||
}
|
||||
|
||||
class DataTable extends Component {
|
||||
<template>
|
||||
<table class="data-table">
|
||||
{{yield (hash Header=HeaderCell Row=Row Cell=Cell)}}
|
||||
</table>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Using contextual components:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/users-table.gjs
|
||||
import DataTable from './data-table';
|
||||
|
||||
<template>
|
||||
<DataTable as |Table|>
|
||||
<thead>
|
||||
<tr>
|
||||
<Table.Header @onSort={{fn @onSort "name"}}>Name</Table.Header>
|
||||
<Table.Header @onSort={{fn @onSort "email"}}>Email</Table.Header>
|
||||
<Table.Header @onSort={{fn @onSort "role"}}>Role</Table.Header>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each @users as |user|}}
|
||||
<Table.Row @selected={{eq @selectedId user.id}}>
|
||||
<Table.Cell>{{user.name}}</Table.Cell>
|
||||
<Table.Cell>{{user.email}}</Table.Cell>
|
||||
<Table.Cell>{{user.role}}</Table.Cell>
|
||||
</Table.Row>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Renderless component pattern:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/dropdown.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { hash } from '@ember/helper';
|
||||
|
||||
class Dropdown extends Component {
|
||||
@tracked isOpen = false;
|
||||
|
||||
@action
|
||||
toggle() {
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
<template>{{yield (hash isOpen=this.isOpen toggle=this.toggle close=this.close)}}</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Usage
|
||||
import Dropdown from './dropdown';
|
||||
|
||||
<template>
|
||||
<Dropdown as |dd|>
|
||||
<button {{on "click" dd.toggle}}>
|
||||
Menu
|
||||
{{if dd.isOpen "▲" "▼"}}
|
||||
</button>
|
||||
|
||||
{{#if dd.isOpen}}
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="#" {{on "click" dd.close}}>Profile</a></li>
|
||||
<li><a href="#" {{on "click" dd.close}}>Settings</a></li>
|
||||
<li><a href="#" {{on "click" dd.close}}>Logout</a></li>
|
||||
</ul>
|
||||
{{/if}}
|
||||
</Dropdown>
|
||||
</template>
|
||||
```
|
||||
|
||||
Component composition provides flexibility, reusability, and clean separation of concerns while maintaining type safety and clarity.
|
||||
|
||||
Reference: [Ember Components - Block Parameters](https://guides.emberjs.com/release/components/block-content/)
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
title: Use Native Forms with Platform Validation
|
||||
impact: HIGH
|
||||
impactDescription: Reduces JavaScript form complexity and improves built-in a11y
|
||||
tags: components, forms, validation, accessibility, platform
|
||||
---
|
||||
|
||||
## Use Native Forms with Platform Validation
|
||||
|
||||
Rely on native `<form>` elements and the browser's Constraint Validation API instead of reinventing form handling with JavaScript. The platform is really good at forms.
|
||||
|
||||
## Problem
|
||||
|
||||
Over-engineering forms with JavaScript when native browser features provide validation, accessibility, and UX patterns for free.
|
||||
|
||||
**Incorrect (Too much JavaScript):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/signup-form.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
class SignupForm extends Component {
|
||||
@tracked email = '';
|
||||
@tracked emailError = '';
|
||||
|
||||
validateEmail = () => {
|
||||
// ❌ Reinventing email validation
|
||||
if (!this.email.includes('@')) {
|
||||
this.emailError = 'Invalid email';
|
||||
}
|
||||
};
|
||||
|
||||
handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
if (this.emailError) return;
|
||||
// Submit logic
|
||||
};
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={{this.email}}
|
||||
{{on "input" this.updateEmail}}
|
||||
{{on "blur" this.validateEmail}}
|
||||
/>
|
||||
{{#if this.emailError}}
|
||||
<span class="error">{{this.emailError}}</span>
|
||||
{{/if}}
|
||||
<button type="button" {{on "click" this.handleSubmit}}>Submit</button>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Solution: Let the Platform Do the Work
|
||||
|
||||
Use native `<form>` with proper input types and browser validation:
|
||||
|
||||
**Correct (Native form with platform validation):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/signup-form.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class SignupForm extends Component {
|
||||
@tracked validationErrors = null;
|
||||
|
||||
handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
|
||||
// ✅ Use native checkValidity()
|
||||
if (!form.checkValidity()) {
|
||||
// Show native validation messages
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Use FormData API - no tracked state needed!
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
this.args.onSubmit(data);
|
||||
};
|
||||
|
||||
<template>
|
||||
<form {{on "submit" this.handleSubmit}}>
|
||||
{{! ✅ Browser handles validation automatically }}
|
||||
<input type="email" name="email" required placeholder="email@example.com" />
|
||||
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
minlength="8"
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
|
||||
<button type="submit">Sign Up</button>
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Performance: -15KB** (no validation libraries needed)
|
||||
**Accessibility: +100%** (native form semantics and error announcements)
|
||||
**Code: -50%** (let the platform handle it)
|
||||
|
||||
## Custom Validation Messages with Constraint Validation API
|
||||
|
||||
Access and display native validation state in your component:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/validated-form.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class ValidatedForm extends Component {
|
||||
@tracked errors = new Map();
|
||||
|
||||
handleInput = (event) => {
|
||||
const input = event.target;
|
||||
|
||||
// ✅ Access Constraint Validation API
|
||||
if (!input.validity.valid) {
|
||||
this.errors.set(input.name, input.validationMessage);
|
||||
} else {
|
||||
this.errors.delete(input.name);
|
||||
}
|
||||
};
|
||||
|
||||
handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
// Trigger native validation UI
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
this.args.onSubmit(Object.fromEntries(formData));
|
||||
};
|
||||
|
||||
<template>
|
||||
<form {{on "submit" this.handleSubmit}}>
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" name="email" required {{on "input" this.handleInput}} />
|
||||
{{#if (this.errors.get "email")}}
|
||||
<span class="error" role="alert">
|
||||
{{this.errors.get "email"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="age">Age</label>
|
||||
<input
|
||||
id="age"
|
||||
type="number"
|
||||
name="age"
|
||||
min="18"
|
||||
max="120"
|
||||
required
|
||||
{{on "input" this.handleInput}}
|
||||
/>
|
||||
{{#if (this.errors.get "age")}}
|
||||
<span class="error" role="alert">
|
||||
{{this.errors.get "age"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Constraint Validation API Properties
|
||||
|
||||
The browser provides rich validation state via `input.validity`:
|
||||
|
||||
```javascript
|
||||
handleInput = (event) => {
|
||||
const input = event.target;
|
||||
const validity = input.validity;
|
||||
|
||||
// Check specific validation states:
|
||||
if (validity.valueMissing) {
|
||||
// required field is empty
|
||||
}
|
||||
if (validity.typeMismatch) {
|
||||
// type="email" but value isn't email format
|
||||
}
|
||||
if (validity.tooShort || validity.tooLong) {
|
||||
// minlength/maxlength violated
|
||||
}
|
||||
if (validity.rangeUnderflow || validity.rangeOverflow) {
|
||||
// min/max violated
|
||||
}
|
||||
if (validity.patternMismatch) {
|
||||
// pattern attribute not matched
|
||||
}
|
||||
|
||||
// Or use the aggregated validationMessage:
|
||||
if (!validity.valid) {
|
||||
this.showError(input.name, input.validationMessage);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Custom Validation with setCustomValidity
|
||||
|
||||
For business logic validation beyond HTML5 constraints:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/password-match-form.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class PasswordMatchForm extends Component {
|
||||
validatePasswordMatch = (event) => {
|
||||
const form = event.target.form;
|
||||
const password = form.querySelector('[name="password"]');
|
||||
const confirm = form.querySelector('[name="confirm"]');
|
||||
|
||||
// ✅ Use setCustomValidity for custom validation
|
||||
if (password.value !== confirm.value) {
|
||||
confirm.setCustomValidity('Passwords must match');
|
||||
} else {
|
||||
confirm.setCustomValidity(''); // Clear custom error
|
||||
}
|
||||
};
|
||||
|
||||
handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
this.args.onSubmit(Object.fromEntries(formData));
|
||||
};
|
||||
|
||||
<template>
|
||||
<form {{on "submit" this.handleSubmit}}>
|
||||
<input type="password" name="password" required minlength="8" placeholder="Password" />
|
||||
|
||||
<input
|
||||
type="password"
|
||||
name="confirm"
|
||||
required
|
||||
placeholder="Confirm password"
|
||||
{{on "input" this.validatePasswordMatch}}
|
||||
/>
|
||||
|
||||
<button type="submit">Create Account</button>
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## When You Need Controlled State
|
||||
|
||||
Use controlled patterns when you need real-time interactivity that isn't form submission:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/live-search.gjs - Controlled state needed for instant search
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class LiveSearch extends Component {
|
||||
@tracked query = '';
|
||||
|
||||
updateQuery = (event) => {
|
||||
this.query = event.target.value;
|
||||
// Instant search as user types
|
||||
this.args.onSearch?.(this.query);
|
||||
};
|
||||
|
||||
<template>
|
||||
{{! Controlled state justified - need instant feedback }}
|
||||
<input
|
||||
type="search"
|
||||
value={{this.query}}
|
||||
{{on "input" this.updateQuery}}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
{{#if this.query}}
|
||||
<p>Searching for: {{this.query}}</p>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Use controlled state when you need:**
|
||||
|
||||
- Real-time validation display as user types
|
||||
- Character counters
|
||||
- Live search/filtering
|
||||
- Multi-step forms where state drives UI
|
||||
- Form state that affects other components
|
||||
|
||||
**Use native forms when:**
|
||||
|
||||
- Simple submit-and-validate workflows
|
||||
- Standard HTML5 validation is sufficient
|
||||
- You want browser-native UX and accessibility
|
||||
- Simpler code and less JavaScript is better
|
||||
|
||||
## References
|
||||
|
||||
- [MDN: Constraint Validation API](https://developer.mozilla.org/en-US/docs/Web/API/Constraint_validation)
|
||||
- [MDN: FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
|
||||
- [MDN: Form Validation](https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation)
|
||||
- [Ember Guides: Event Handling](https://guides.emberjs.com/release/components/component-state-and-actions/)
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
title: Component File Naming and Export Conventions
|
||||
impact: HIGH
|
||||
impactDescription: Enforces consistent component structure and predictable imports
|
||||
tags: components, naming, file-conventions, gjs, strict-mode
|
||||
---
|
||||
|
||||
## Component File Naming and Export Conventions
|
||||
|
||||
### Rule
|
||||
|
||||
Follow modern Ember component file conventions: use `.gjs`/`.gts` files with `<template>` tags (never `.hbs` files), use kebab-case filenames, match class names to file names (in PascalCase), do not use the `Component` suffix in class names, and avoid `export default` in .gjs/.gts component files.
|
||||
This export guidance applies to `.gjs`/`.gts` component files only. If your app still uses `.hbs`, keep default exports for resolver-facing invokables used there (or use a named export plus default alias in hybrid codebases).
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```handlebars
|
||||
{{! app/components/user-card.hbs - WRONG: Using .hbs file }}
|
||||
<div class='user-card'>
|
||||
{{@name}}
|
||||
</div>
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.js - WRONG: Separate .js and .hbs files
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export class UserCard extends Component {
|
||||
// Logic here
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs - WRONG: Component suffix
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export class UserCardComponent extends Component {
|
||||
<template>
|
||||
<div class="user-card">
|
||||
{{@name}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/UserProfile.gjs - WRONG: PascalCase filename
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export class UserProfile extends Component {
|
||||
<template>
|
||||
<div class="profile">
|
||||
{{@name}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs - CORRECT: kebab-case filename, no Component suffix, no default export
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export class UserCard extends Component {
|
||||
<template>
|
||||
<div class="user-card">
|
||||
{{@name}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs - CORRECT: All conventions followed
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export class UserProfile extends Component {
|
||||
@service session;
|
||||
|
||||
<template>
|
||||
<div class="profile">
|
||||
<h1>{{@name}}</h1>
|
||||
{{#if this.session.isAuthenticated}}
|
||||
<button>Edit Profile</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Why
|
||||
|
||||
**Never use .hbs files:**
|
||||
|
||||
- `.gjs`/`.gts` files with `<template>` tags are the modern standard
|
||||
- Co-located templates and logic in a single file improve maintainability
|
||||
- Better tooling support (type checking, imports, refactoring)
|
||||
- Enables strict mode and proper scope
|
||||
- Avoid split between `.js` and `.hbs` files which makes components harder to understand
|
||||
|
||||
**Filename conventions:**
|
||||
|
||||
- Kebab-case filenames (`user-card.gjs`, not `UserCard.gjs`) follow web component standards and Ember conventions
|
||||
- Predictable: component name maps directly to filename (UserCard → user-card.gjs)
|
||||
- Avoids filesystem case-sensitivity issues across platforms
|
||||
|
||||
**Class naming:**
|
||||
|
||||
- No "Component" suffix - it's redundant (extends Component already declares the type)
|
||||
- PascalCase class name matches the capitalized component invocation: `<UserCard />`
|
||||
- Cleaner code: `UserCard` vs `UserCardComponent`
|
||||
|
||||
**No default export:**
|
||||
|
||||
- Modern .gjs/.gts files don't need `export default`
|
||||
- The template compiler automatically exports the component
|
||||
- Simpler syntax, less boilerplate
|
||||
- Consistent with strict-mode semantics
|
||||
|
||||
## Naming Pattern Reference
|
||||
|
||||
| Filename | Class Name | Template Invocation |
|
||||
| --------------------- | ---------------------- | -------------------- |
|
||||
| `user-card.gjs` | `class UserCard` | `<UserCard />` |
|
||||
| `loading-spinner.gjs` | `class LoadingSpinner` | `<LoadingSpinner />` |
|
||||
| `nav-bar.gjs` | `class NavBar` | `<NavBar />` |
|
||||
| `todo-list.gjs` | `class TodoList` | `<TodoList />` |
|
||||
| `search-input.gjs` | `class SearchInput` | `<SearchInput />` |
|
||||
|
||||
**Conversion rule:**
|
||||
|
||||
- Filename: all lowercase, words separated by hyphens
|
||||
- Class: PascalCase, same words, no hyphens
|
||||
- `user-card.gjs` → `class UserCard`
|
||||
|
||||
## Special Cases
|
||||
|
||||
**Template-only components:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/simple-card.gjs - Template-only, no class needed
|
||||
<template>
|
||||
<div class="card">
|
||||
{{yield}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Components in subdirectories:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/ui/button.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export class Button extends Component {
|
||||
<template>
|
||||
<button type="button">
|
||||
{{yield}}
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
|
||||
// Usage: <Ui::Button />
|
||||
```
|
||||
|
||||
**Nested namespaces:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/admin/user/profile-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export class ProfileCard extends Component {
|
||||
<template>
|
||||
<div class="admin-profile">
|
||||
{{@user.name}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
|
||||
// Usage: <Admin::User::ProfileCard />
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
**Positive:**
|
||||
|
||||
- ⚡️ Cleaner, more maintainable code
|
||||
- 🎯 Predictable mapping between files and classes
|
||||
- 🌐 Follows web standards (kebab-case)
|
||||
- 📦 Smaller bundle size (less export overhead)
|
||||
- 🚀 Better alignment with modern Ember/Glimmer
|
||||
|
||||
**Negative:**
|
||||
|
||||
- None - this is the modern standard
|
||||
|
||||
## Metrics
|
||||
|
||||
- **Code clarity**: +30% (shorter, clearer names)
|
||||
- **Bundle size**: -5-10 bytes per component (no export overhead)
|
||||
- **Developer experience**: Improved (predictable naming)
|
||||
|
||||
## References
|
||||
|
||||
- [Ember Components Guide](https://guides.emberjs.com/release/components/)
|
||||
- [Glimmer Components](https://github.com/glimmerjs/glimmer.js)
|
||||
- [Template Tag Format RFC](https://github.com/emberjs/rfcs/pull/779)
|
||||
- [Strict Mode Semantics](https://github.com/emberjs/rfcs/blob/master/text/0496-handlebars-strict-mode.md)
|
||||
|
||||
## Related Rules
|
||||
|
||||
- component-use-glimmer.md - Modern Glimmer component patterns
|
||||
- component-strict-mode.md - Template-only components and strict mode
|
||||
- route-templates.md - Route file naming conventions
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
title: Prevent Memory Leaks in Components
|
||||
impact: HIGH
|
||||
impactDescription: Avoid memory leaks and resource exhaustion
|
||||
tags: memory, cleanup, lifecycle, performance
|
||||
---
|
||||
|
||||
## Prevent Memory Leaks in Components
|
||||
|
||||
Properly clean up event listeners, timers, and subscriptions to prevent memory leaks.
|
||||
|
||||
**Incorrect (no cleanup):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/live-clock.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
class LiveClock extends Component {
|
||||
@tracked time = new Date();
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
// Memory leak: interval never cleared
|
||||
setInterval(() => {
|
||||
this.time = new Date();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>{{this.time}}</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (proper cleanup with registerDestructor):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/live-clock.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
|
||||
class LiveClock extends Component {
|
||||
@tracked time = new Date();
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
this.time = new Date();
|
||||
}, 1000);
|
||||
|
||||
// Proper cleanup
|
||||
registerDestructor(this, () => {
|
||||
clearInterval(intervalId);
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>{{this.time}}</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Event listener cleanup:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/window-size.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
|
||||
class WindowSize extends Component {
|
||||
@tracked width = window.innerWidth;
|
||||
@tracked height = window.innerHeight;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
const handleResize = () => {
|
||||
this.width = window.innerWidth;
|
||||
this.height = window.innerHeight;
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
registerDestructor(this, () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>Window: {{this.width}} x {{this.height}}</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Using modifiers for automatic cleanup:**
|
||||
|
||||
```javascript
|
||||
// app/modifiers/window-listener.js
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element, [eventName, handler]) => {
|
||||
window.addEventListener(eventName, handler);
|
||||
|
||||
// Automatic cleanup when element is removed
|
||||
return () => {
|
||||
window.removeEventListener(eventName, handler);
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/resize-aware.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import windowListener from '../modifiers/window-listener';
|
||||
|
||||
class ResizeAware extends Component {
|
||||
@tracked size = { width: 0, height: 0 };
|
||||
|
||||
handleResize = () => {
|
||||
this.size = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
};
|
||||
|
||||
<template>
|
||||
<div {{windowListener "resize" this.handleResize}}>
|
||||
{{this.size.width}}
|
||||
x
|
||||
{{this.size.height}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Abort controller for fetch requests:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-loader.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
|
||||
class DataLoader extends Component {
|
||||
@tracked data = null;
|
||||
abortController = new AbortController();
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.loadData();
|
||||
|
||||
registerDestructor(this, () => {
|
||||
this.abortController.abort();
|
||||
});
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const response = await fetch('/api/data', {
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
this.data = await response.json();
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Failed to load data:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.data}}
|
||||
<div>{{this.data.content}}</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Using ember-resources for automatic cleanup:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/websocket-data.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { resource } from 'ember-resources';
|
||||
|
||||
class WebsocketData extends Component {
|
||||
messages = resource(({ on }) => {
|
||||
const messages = [];
|
||||
const ws = new WebSocket('wss://example.com/socket');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
messages.push(event.data);
|
||||
};
|
||||
|
||||
// Automatic cleanup
|
||||
on.cleanup(() => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
return messages;
|
||||
});
|
||||
|
||||
<template>
|
||||
{{#each this.messages.value as |message|}}
|
||||
<div>{{message}}</div>
|
||||
{{/each}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
Always clean up timers, event listeners, subscriptions, and pending requests to prevent memory leaks and performance degradation.
|
||||
|
||||
Reference: [Ember Destroyable](https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Avoid Unnecessary Tracking
|
||||
impact: HIGH
|
||||
impactDescription: 20-40% fewer invalidations
|
||||
tags: components, tracked, performance, reactivity
|
||||
---
|
||||
|
||||
## Avoid Unnecessary Tracking
|
||||
|
||||
Only mark properties as `@tracked` if they need to trigger re-renders when changed. Overusing `@tracked` causes unnecessary invalidations and re-renders.
|
||||
|
||||
**Incorrect (everything tracked):**
|
||||
|
||||
```javascript
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class Form extends Component {
|
||||
@tracked firstName = ''; // Used in template ✓
|
||||
@tracked lastName = ''; // Used in template ✓
|
||||
@tracked _formId = Date.now(); // Internal, never rendered ✗
|
||||
@tracked _validationCache = new Map(); // Internal state ✗
|
||||
|
||||
@action
|
||||
validate() {
|
||||
this._validationCache.set('firstName', this.firstName.length > 0);
|
||||
// Unnecessary re-render triggered
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (selective tracking):**
|
||||
|
||||
```javascript
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class Form extends Component {
|
||||
@tracked firstName = ''; // Rendered in template
|
||||
@tracked lastName = ''; // Rendered in template
|
||||
@tracked isValid = false; // Rendered status
|
||||
|
||||
_formId = Date.now(); // Not tracked - internal only
|
||||
_validationCache = new Map(); // Not tracked - internal state
|
||||
|
||||
@action
|
||||
validate() {
|
||||
this._validationCache.set('firstName', this.firstName.length > 0);
|
||||
this.isValid = this._validationCache.get('firstName');
|
||||
// Only re-renders when isValid changes
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only track properties that directly affect the template or other tracked getters to minimize unnecessary re-renders.
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
title: Use {{on}} Modifier for Event Handling
|
||||
impact: MEDIUM
|
||||
impactDescription: Better memory management and clarity
|
||||
tags: events, modifiers, on, performance
|
||||
---
|
||||
|
||||
## Use {{on}} Modifier for Event Handling
|
||||
|
||||
Use the `{{on}}` modifier for event handling instead of traditional action handlers for better memory management and clearer code.
|
||||
|
||||
**Incorrect (traditional action attribute):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/button.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class Button extends Component {
|
||||
@action
|
||||
handleClick() {
|
||||
this.args.onClick?.();
|
||||
}
|
||||
|
||||
<template>
|
||||
<button onclick={{this.handleClick}}>
|
||||
{{@label}}
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using {{on}} modifier):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/button.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class Button extends Component {
|
||||
handleClick = () => {
|
||||
this.args.onClick?.();
|
||||
};
|
||||
|
||||
<template>
|
||||
<button {{on "click" this.handleClick}}>
|
||||
{{@label}}
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**With event options:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/scroll-tracker.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class ScrollTracker extends Component {
|
||||
handleScroll = (event) => {
|
||||
console.log('Scroll position:', event.target.scrollTop);
|
||||
};
|
||||
|
||||
<template>
|
||||
<div class="scrollable" {{on "scroll" this.handleScroll passive=true}}>
|
||||
{{yield}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Multiple event handlers:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/input-field.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class InputField extends Component {
|
||||
@tracked isFocused = false;
|
||||
|
||||
handleFocus = () => {
|
||||
this.isFocused = true;
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.isFocused = false;
|
||||
};
|
||||
|
||||
handleInput = (event) => {
|
||||
this.args.onInput?.(event.target.value);
|
||||
};
|
||||
|
||||
<template>
|
||||
<input
|
||||
type="text"
|
||||
class={{if this.isFocused "focused"}}
|
||||
{{on "focus" this.handleFocus}}
|
||||
{{on "blur" this.handleBlur}}
|
||||
{{on "input" this.handleInput}}
|
||||
value={{@value}}
|
||||
/>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Using fn helper for arguments:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/item-list.gjs
|
||||
import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
{{#each @items as |item|}}
|
||||
<li>
|
||||
{{item.name}}
|
||||
<button {{on "click" (fn @onDelete item.id)}}>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
The `{{on}}` modifier properly cleans up event listeners, supports event options (passive, capture, once), and makes event handling more explicit.
|
||||
|
||||
Reference: [Ember Modifiers - on](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_event-handlers)
|
||||
@@ -0,0 +1,292 @@
|
||||
---
|
||||
title: Build Reactive Chains with Dependent Getters
|
||||
impact: HIGH
|
||||
impactDescription: Clear data flow and automatic reactivity
|
||||
tags: reactivity, getters, tracked, derived-state, composition
|
||||
---
|
||||
|
||||
## Build Reactive Chains with Dependent Getters
|
||||
|
||||
Create reactive chains where getters depend on other getters or tracked properties for clear, maintainable data derivation.
|
||||
|
||||
**Incorrect (imperative updates):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/shopping-cart.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class ShoppingCart extends Component {
|
||||
@tracked items = [];
|
||||
@tracked subtotal = 0;
|
||||
@tracked tax = 0;
|
||||
@tracked shipping = 0;
|
||||
@tracked total = 0;
|
||||
|
||||
@action
|
||||
addItem(item) {
|
||||
this.items = [...this.items, item];
|
||||
this.recalculate();
|
||||
}
|
||||
|
||||
@action
|
||||
removeItem(index) {
|
||||
this.items = this.items.filter((_, i) => i !== index);
|
||||
this.recalculate();
|
||||
}
|
||||
|
||||
recalculate() {
|
||||
this.subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
|
||||
this.tax = this.subtotal * 0.08;
|
||||
this.shipping = this.subtotal > 50 ? 0 : 5.99;
|
||||
this.total = this.subtotal + this.tax + this.shipping;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="cart">
|
||||
<div>Subtotal: ${{this.subtotal}}</div>
|
||||
<div>Tax: ${{this.tax}}</div>
|
||||
<div>Shipping: ${{this.shipping}}</div>
|
||||
<div>Total: ${{this.total}}</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (reactive getter chains):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/shopping-cart.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { TrackedArray } from 'tracked-built-ins';
|
||||
|
||||
class ShoppingCart extends Component {
|
||||
@tracked items = new TrackedArray([]);
|
||||
|
||||
// Base calculation
|
||||
get subtotal() {
|
||||
return this.items.reduce((sum, item) => sum + item.price, 0);
|
||||
}
|
||||
|
||||
// Depends on subtotal
|
||||
get tax() {
|
||||
return this.subtotal * 0.08;
|
||||
}
|
||||
|
||||
// Depends on subtotal
|
||||
get shipping() {
|
||||
return this.subtotal > 50 ? 0 : 5.99;
|
||||
}
|
||||
|
||||
// Depends on subtotal, tax, and shipping
|
||||
get total() {
|
||||
return this.subtotal + this.tax + this.shipping;
|
||||
}
|
||||
|
||||
// Derived from total
|
||||
get formattedTotal() {
|
||||
return `$${this.total.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Multiple dependencies
|
||||
get discount() {
|
||||
if (this.items.length >= 5) return this.subtotal * 0.1;
|
||||
if (this.subtotal > 100) return this.subtotal * 0.05;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Depends on total and discount
|
||||
get finalTotal() {
|
||||
return this.total - this.discount;
|
||||
}
|
||||
|
||||
@action
|
||||
addItem(item) {
|
||||
this.items.push(item);
|
||||
// All getters automatically update!
|
||||
}
|
||||
|
||||
@action
|
||||
removeItem(index) {
|
||||
this.items.splice(index, 1);
|
||||
// All getters automatically update!
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="cart">
|
||||
<div>Items: {{this.items.length}}</div>
|
||||
<div>Subtotal: ${{this.subtotal.toFixed 2}}</div>
|
||||
<div>Tax: ${{this.tax.toFixed 2}}</div>
|
||||
<div>Shipping: ${{this.shipping.toFixed 2}}</div>
|
||||
{{#if this.discount}}
|
||||
<div class="discount">Discount: -${{this.discount.toFixed 2}}</div>
|
||||
{{/if}}
|
||||
<div class="total">Total: {{this.formattedTotal}}</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Complex reactive chains with @cached:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-analysis.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
class DataAnalysis extends Component {
|
||||
// Base data
|
||||
get rawData() {
|
||||
return this.args.data || [];
|
||||
}
|
||||
|
||||
// Level 1: Filter
|
||||
@cached
|
||||
get validData() {
|
||||
return this.rawData.filter((item) => item.value != null);
|
||||
}
|
||||
|
||||
// Level 2: Transform (depends on validData)
|
||||
@cached
|
||||
get normalizedData() {
|
||||
const max = Math.max(...this.validData.map((d) => d.value));
|
||||
return this.validData.map((item) => ({
|
||||
...item,
|
||||
normalized: item.value / max,
|
||||
}));
|
||||
}
|
||||
|
||||
// Level 2: Statistics (depends on validData)
|
||||
@cached
|
||||
get statistics() {
|
||||
const values = this.validData.map((d) => d.value);
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
const mean = sum / values.length;
|
||||
const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / values.length;
|
||||
|
||||
return {
|
||||
count: values.length,
|
||||
sum,
|
||||
mean,
|
||||
stdDev: Math.sqrt(variance),
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
};
|
||||
}
|
||||
|
||||
// Level 3: Depends on normalizedData and statistics
|
||||
@cached
|
||||
get outliers() {
|
||||
const threshold = this.statistics.mean + 2 * this.statistics.stdDev;
|
||||
return this.normalizedData.filter((item) => item.value > threshold);
|
||||
}
|
||||
|
||||
// Level 3: Depends on statistics
|
||||
get qualityScore() {
|
||||
const validRatio = this.validData.length / this.rawData.length;
|
||||
const outlierRatio = this.outliers.length / this.validData.length;
|
||||
return validRatio * 0.7 + (1 - outlierRatio) * 0.3;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="analysis">
|
||||
<h3>Data Quality: {{this.qualityScore.toFixed 2}}</h3>
|
||||
<div>Valid: {{this.validData.length}} / {{this.rawData.length}}</div>
|
||||
<div>Mean: {{this.statistics.mean.toFixed 2}}</div>
|
||||
<div>Std Dev: {{this.statistics.stdDev.toFixed 2}}</div>
|
||||
<div>Outliers: {{this.outliers.length}}</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Combining multiple tracked sources:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/filtered-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
class FilteredList extends Component {
|
||||
@tracked searchTerm = '';
|
||||
@tracked selectedCategory = 'all';
|
||||
@tracked sortDirection = 'asc';
|
||||
|
||||
// Depends on args.items and searchTerm
|
||||
@cached
|
||||
get searchFiltered() {
|
||||
if (!this.searchTerm) return this.args.items;
|
||||
|
||||
const term = this.searchTerm.toLowerCase();
|
||||
return this.args.items.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(term) || item.description?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
// Depends on searchFiltered and selectedCategory
|
||||
@cached
|
||||
get categoryFiltered() {
|
||||
if (this.selectedCategory === 'all') return this.searchFiltered;
|
||||
|
||||
return this.searchFiltered.filter((item) => item.category === this.selectedCategory);
|
||||
}
|
||||
|
||||
// Depends on categoryFiltered and sortDirection
|
||||
@cached
|
||||
get sorted() {
|
||||
const items = [...this.categoryFiltered];
|
||||
const direction = this.sortDirection === 'asc' ? 1 : -1;
|
||||
|
||||
return items.sort((a, b) => direction * a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Final result
|
||||
get items() {
|
||||
return this.sorted;
|
||||
}
|
||||
|
||||
// Metadata derived from chain
|
||||
get resultsCount() {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
get hasFilters() {
|
||||
return this.searchTerm || this.selectedCategory !== 'all';
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="filtered-list">
|
||||
<input
|
||||
type="search"
|
||||
value={{this.searchTerm}}
|
||||
{{on "input" (pick "target.value" (set this "searchTerm"))}}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={{this.selectedCategory}}
|
||||
{{on "change" (pick "target.value" (set this "selectedCategory"))}}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{{#each @categories as |cat|}}
|
||||
<option value={{cat}}>{{cat}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
|
||||
<p>Showing {{this.resultsCount}} results</p>
|
||||
|
||||
{{#each this.items as |item|}}
|
||||
<div>{{item.name}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
Reactive getter chains provide automatic updates, clear data dependencies, and better performance through intelligent caching with @cached.
|
||||
|
||||
Reference: [Glimmer Tracking](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Use Strict Mode and Template-Only Components
|
||||
impact: HIGH
|
||||
impactDescription: Better type safety and simpler components
|
||||
tags: strict-mode, template-only, components, gjs
|
||||
---
|
||||
|
||||
## Use Strict Mode and Template-Only Components
|
||||
|
||||
Use strict mode and template-only components for simpler, safer code with better tooling support.
|
||||
|
||||
**Incorrect (JavaScript component for simple templates):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserCard extends Component {
|
||||
<template>
|
||||
<div class="user-card">
|
||||
<h3>{{@user.name}}</h3>
|
||||
<p>{{@user.email}}</p>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (template-only component):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
<template>
|
||||
<div class="user-card">
|
||||
<h3>{{@user.name}}</h3>
|
||||
<p>{{@user.email}}</p>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**With TypeScript for better type safety:**
|
||||
|
||||
```glimmer-ts
|
||||
// app/components/user-card.gts
|
||||
import type { TOC } from '@ember/component/template-only';
|
||||
|
||||
interface UserCardSignature {
|
||||
Args: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const UserCard: TOC<UserCardSignature> = <template>
|
||||
<div class="user-card">
|
||||
<h3>{{@user.name}}</h3>
|
||||
<p>{{@user.email}}</p>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default UserCard;
|
||||
```
|
||||
|
||||
**Enable strict mode in your app:**
|
||||
|
||||
```javascript
|
||||
// ember-cli-build.js
|
||||
'use strict';
|
||||
|
||||
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
|
||||
|
||||
module.exports = function (defaults) {
|
||||
const app = new EmberApp(defaults, {
|
||||
'ember-cli-babel': {
|
||||
enableTypeScriptTransform: true,
|
||||
},
|
||||
});
|
||||
|
||||
return app.toTree();
|
||||
};
|
||||
```
|
||||
|
||||
Template-only components are lighter, more performant, and easier to understand. Strict mode provides better error messages and prevents common mistakes.
|
||||
|
||||
Reference: [Ember Strict Mode](https://guides.emberjs.com/release/upgrading/current-edition/templates/)
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Use Tracked Toolbox for Complex State
|
||||
impact: HIGH
|
||||
impactDescription: Cleaner state management
|
||||
tags: components, tracked, state-management, performance
|
||||
---
|
||||
|
||||
## Use Tracked Toolbox for Complex State
|
||||
|
||||
For complex state patterns like maps, sets, and arrays that need fine-grained reactivity, use tracked-toolbox utilities instead of marking entire structures as @tracked.
|
||||
|
||||
**Incorrect (tracking entire structures):**
|
||||
|
||||
```javascript
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
class TodoList extends Component {
|
||||
@tracked items = []; // Entire array replaced on every change
|
||||
|
||||
addItem = (item) => {
|
||||
// Creates new array, invalidates all consumers
|
||||
this.items = [...this.items, item];
|
||||
};
|
||||
|
||||
removeItem = (index) => {
|
||||
// Creates new array again
|
||||
this.items = this.items.filter((_, i) => i !== index);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using tracked-toolbox):**
|
||||
|
||||
```javascript
|
||||
import Component from '@glimmer/component';
|
||||
import { TrackedArray } from 'tracked-built-ins';
|
||||
|
||||
class TodoList extends Component {
|
||||
items = new TrackedArray([]);
|
||||
|
||||
// Use arrow functions for methods used in templates (no @action needed)
|
||||
addItem = (item) => {
|
||||
// Efficiently adds to tracked array
|
||||
this.items.push(item);
|
||||
};
|
||||
|
||||
removeItem = (index) => {
|
||||
// Efficiently removes from tracked array
|
||||
this.items.splice(index, 1);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Also useful for Maps and Sets:**
|
||||
|
||||
```javascript
|
||||
import { TrackedMap, TrackedSet } from 'tracked-built-ins';
|
||||
|
||||
class Cache extends Component {
|
||||
cache = new TrackedMap(); // Fine-grained reactivity per key
|
||||
selected = new TrackedSet(); // Fine-grained reactivity per item
|
||||
}
|
||||
```
|
||||
|
||||
tracked-built-ins provides fine-grained reactivity and better performance than replacing entire structures.
|
||||
|
||||
Reference: [tracked-built-ins](https://github.com/tracked-tools/tracked-built-ins)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Use Glimmer Components Over Classic Components
|
||||
impact: HIGH
|
||||
impactDescription: 30-50% faster rendering
|
||||
tags: components, glimmer, performance, reactivity
|
||||
---
|
||||
|
||||
## Use Glimmer Components Over Classic Components
|
||||
|
||||
Glimmer components are lighter, faster, and have a simpler lifecycle than classic Ember components. They don't have two-way bindings or element lifecycle hooks, making them more predictable and performant.
|
||||
|
||||
**Incorrect (classic component):**
|
||||
|
||||
```javascript
|
||||
// app/components/user-card.js
|
||||
import Component from '@ember/component';
|
||||
import { computed } from '@ember/object';
|
||||
|
||||
export default Component.extend({
|
||||
tagName: 'div',
|
||||
classNames: ['user-card'],
|
||||
|
||||
fullName: computed('user.{firstName,lastName}', function () {
|
||||
return `${this.user.firstName} ${this.user.lastName}`;
|
||||
}),
|
||||
|
||||
didInsertElement() {
|
||||
this._super(...arguments);
|
||||
// Complex lifecycle management
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (Glimmer component):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserCard extends Component {
|
||||
get fullName() {
|
||||
return `${this.args.user.firstName} ${this.args.user.lastName}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="user-card">
|
||||
<h3>{{this.fullName}}</h3>
|
||||
<p>{{@user.email}}</p>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
Glimmer components are 30-50% faster, have cleaner APIs, and integrate better with tracked properties.
|
||||
|
||||
Reference: [Glimmer Components](https://guides.emberjs.com/release/components/component-state-and-actions/)
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
title: Prefer Named Exports, Fallback to Default for Implicit Template Lookup
|
||||
impact: LOW
|
||||
impactDescription: Clear export contracts across .hbs and template-tag codebases
|
||||
tags: exports, hbs, gjs, interop, code-organization
|
||||
---
|
||||
|
||||
## Prefer Named Exports, Fallback to Default for Implicit Template Lookup
|
||||
|
||||
Use named exports for shared modules imported directly in JS/TS (utilities, constants, pure functions). If a module should be invokable from `.hbs` templates via implicit lookup, provide a default export. In hybrid `.gjs`/`.hbs` projects, a practical pattern is a named export plus a default export alias.
|
||||
|
||||
**Incorrect (default export in a shared utility module):**
|
||||
|
||||
```javascript
|
||||
// app/utils/format-date.js
|
||||
export default function formatDate(date) {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (named export in a shared utility module):**
|
||||
|
||||
```javascript
|
||||
// app/utils/format-date.js
|
||||
export function formatDate(date) {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (hybrid `.gjs`/`.hbs` named export + default alias):**
|
||||
|
||||
```javascript
|
||||
// app/helpers/format-date.js
|
||||
import { helper } from '@ember/component/helper';
|
||||
|
||||
export const formatDate = helper(([value]) => {
|
||||
return new Date(value).toLocaleDateString();
|
||||
});
|
||||
|
||||
export default formatDate;
|
||||
```
|
||||
|
||||
## Where Named Exports Are Preferred
|
||||
|
||||
Use named exports when the module is imported directly by other modules and is not resolved via implicit template lookup.
|
||||
|
||||
**Example (utility module with multiple named exports):**
|
||||
|
||||
```javascript
|
||||
// app/utils/validators.js
|
||||
export function isEmail(value) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
export function isPhoneNumber(value) {
|
||||
return /^\d{3}-\d{3}-\d{4}$/.test(value);
|
||||
}
|
||||
```
|
||||
|
||||
Benefits:
|
||||
|
||||
1. Explicit import contracts
|
||||
2. Better refactor safety (symbol rename tracking)
|
||||
3. Better tree-shaking for utility modules
|
||||
4. Easier multi-export module organization
|
||||
|
||||
## Where Default Exports Are Required
|
||||
|
||||
Use default exports for modules consumed through resolver/template lookup.
|
||||
If your project uses `.hbs`, invokables that should be accessible from templates should provide `export default`.
|
||||
In hybrid `.gjs`/`.hbs` codebases, use named exports plus a default export alias where you want both explicit imports and template compatibility.
|
||||
|
||||
**Service:**
|
||||
|
||||
```javascript
|
||||
// app/services/auth.js
|
||||
import Service from '@ember/service';
|
||||
|
||||
export default class AuthService extends Service {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Route:**
|
||||
|
||||
```javascript
|
||||
// app/routes/dashboard.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class DashboardRoute extends Route {
|
||||
@service store;
|
||||
|
||||
model() {
|
||||
return this.store.findAll('dashboard-item');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Modifier (when invoked from `.hbs`):**
|
||||
|
||||
```javascript
|
||||
// app/modifiers/focus.js
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element) => {
|
||||
element.focus();
|
||||
});
|
||||
```
|
||||
|
||||
**Template (`.gjs`):**
|
||||
|
||||
```glimmer-js
|
||||
// app/templates/dashboard.gjs
|
||||
<template>
|
||||
<h1>Dashboard</h1>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Template (`.gts`):**
|
||||
|
||||
```glimmer-ts
|
||||
// app/templates/dashboard.gts
|
||||
import type { TOC } from '@ember/component/template-only';
|
||||
|
||||
interface Signature {
|
||||
Args: {
|
||||
model: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export default <template>
|
||||
<h1>Dashboard</h1>
|
||||
</template> satisfies TOC<Signature>;
|
||||
```
|
||||
|
||||
Template-tag files must resolve via a module default export in convention-based and `import.meta.glob` flows.
|
||||
For `app/templates/*.gjs`, the default export is implicit after compilation.
|
||||
|
||||
## Strict Resolver Nuance
|
||||
|
||||
With `ember-strict-application-resolver`, you can register explicit module values in `App.modules`:
|
||||
|
||||
**Strict resolver explicit modules registration:**
|
||||
|
||||
```ts
|
||||
modules = {
|
||||
'./services/manual': { default: ManualService },
|
||||
'./services/manual-shorthand': ManualService,
|
||||
};
|
||||
```
|
||||
|
||||
In that explicit shorthand case, a direct value works without a default-exported module object.
|
||||
This is an explicit registration escape hatch and does not replace default-export requirements for `.hbs`-invokable modules.
|
||||
|
||||
## Rule of Thumb
|
||||
|
||||
1. If a module should be invokable from `.hbs`, provide a default export.
|
||||
2. In hybrid `.gjs`/`.hbs` projects, use named export + default alias for resolver-facing modules.
|
||||
3. Strict resolver explicit `modules` entries may use direct shorthand values where appropriate.
|
||||
4. Plain shared modules (`app/utils`, shared constants, reusable pure functions): prefer named exports.
|
||||
5. Template-tag components (`.gjs`/`.gts`): follow the component file-conventions rule and use named class exports.
|
||||
|
||||
## References
|
||||
|
||||
- [ES Modules Best Practices](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)
|
||||
- [ember-strict-application-resolver](https://github.com/ember-cli/ember-strict-application-resolver)
|
||||
- [ember-resolver](https://github.com/ember-cli/ember-resolver)
|
||||
@@ -0,0 +1,362 @@
|
||||
---
|
||||
title: Use Helper Libraries Effectively
|
||||
impact: MEDIUM
|
||||
impactDescription: Reduces custom helper maintenance and keeps templates concise
|
||||
tags: templates, helpers, ember-truth-helpers, ember-composable-helpers
|
||||
---
|
||||
|
||||
## Use Helper Libraries Effectively
|
||||
|
||||
Leverage community helper libraries to write cleaner templates and avoid creating unnecessary custom helpers for common operations.
|
||||
|
||||
## Problem
|
||||
|
||||
Reinventing common functionality with custom helpers adds maintenance burden and bundle size when well-maintained helper libraries already provide the needed functionality.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```glimmer-js
|
||||
// app/utils/is-equal.js - Unnecessary custom helper
|
||||
export function isEqual(a, b) {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
// app/components/user-badge.gjs
|
||||
import { isEqual } from '../utils/is-equal';
|
||||
|
||||
class UserBadge extends Component {
|
||||
<template>
|
||||
{{#if (isEqual @user.role "admin")}}
|
||||
<span class="badge">Admin</span>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
**Note:** These helpers will be built into Ember 7 core, but currently require installing the respective addon packages.
|
||||
|
||||
**Installation:**
|
||||
|
||||
```bash
|
||||
npm install ember-truth-helpers ember-composable-helpers
|
||||
```
|
||||
|
||||
Use helper libraries like `ember-truth-helpers` and `ember-composable-helpers`:
|
||||
|
||||
**Correct:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-badge.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { eq } from 'ember-truth-helpers';
|
||||
|
||||
class UserBadge extends Component {
|
||||
<template>
|
||||
{{! eq helper from ember-truth-helpers }}
|
||||
{{#if (eq @user.role "admin")}}
|
||||
<span class="badge">Admin</span>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Comparison Helpers (ember-truth-helpers)
|
||||
|
||||
**Installation:** `npm install ember-truth-helpers`
|
||||
|
||||
```glimmer-js
|
||||
// app/components/comparison-examples.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { eq, not, and, or, lt, lte, gt, gte } from 'ember-truth-helpers';
|
||||
|
||||
class ComparisonExamples extends Component {
|
||||
<template>
|
||||
{{! Equality }}
|
||||
{{#if (eq @status "active")}}Active{{/if}}
|
||||
|
||||
{{! Negation }}
|
||||
{{#if (not @isDeleted)}}Visible{{/if}}
|
||||
|
||||
{{! Logical AND }}
|
||||
{{#if (and @isPremium @hasAccess)}}Premium Content{{/if}}
|
||||
|
||||
{{! Logical OR }}
|
||||
{{#if (or @isAdmin @isModerator)}}Moderation Tools{{/if}}
|
||||
|
||||
{{! Comparisons }}
|
||||
{{#if (gt @score 100)}}High Score!{{/if}}
|
||||
{{#if (lte @attempts 3)}}Try again{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Array and Object Helpers (ember-composable-helpers)
|
||||
|
||||
**Installation:** `npm install ember-composable-helpers`
|
||||
|
||||
```glimmer-js
|
||||
// app/components/collection-helpers.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { array, hash } from 'ember-composable-helpers/helpers';
|
||||
import { get } from 'ember-composable-helpers/helpers';
|
||||
|
||||
class CollectionHelpers extends Component {
|
||||
<template>
|
||||
{{! Create array inline }}
|
||||
{{#each (array "apple" "banana" "cherry") as |fruit|}}
|
||||
<li>{{fruit}}</li>
|
||||
{{/each}}
|
||||
|
||||
{{! Create object inline }}
|
||||
{{#let (hash name="John" age=30 active=true) as |user|}}
|
||||
<p>{{user.name}} is {{user.age}} years old</p>
|
||||
{{/let}}
|
||||
|
||||
{{! Dynamic property access }}
|
||||
<p>{{get @user @propertyName}}</p>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## String Helpers
|
||||
|
||||
```glimmer-js
|
||||
// app/components/string-helpers.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { concat } from '@ember/helper'; // Built-in to Ember
|
||||
|
||||
class StringHelpers extends Component {
|
||||
<template>
|
||||
{{! Concatenate strings }}
|
||||
<p class={{concat "user-" @user.id "-card"}}>
|
||||
{{concat @user.firstName " " @user.lastName}}
|
||||
</p>
|
||||
|
||||
{{! With dynamic values }}
|
||||
<img
|
||||
src={{concat "/images/" @category "/" @filename ".jpg"}}
|
||||
alt={{concat "Image of " @title}}
|
||||
/>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Action Helpers (fn)
|
||||
|
||||
```glimmer-js
|
||||
// app/components/action-helpers.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { fn } from '@ember/helper'; // Built-in to Ember
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class ActionHelpers extends Component {
|
||||
updateValue = (field, event) => {
|
||||
this.args.onChange(field, event.target.value);
|
||||
};
|
||||
|
||||
deleteItem = (id) => {
|
||||
this.args.onDelete(id);
|
||||
};
|
||||
|
||||
<template>
|
||||
{{! Partial application with fn }}
|
||||
<input {{on "input" (fn this.updateValue "email")}} />
|
||||
|
||||
{{#each @items as |item|}}
|
||||
<li>
|
||||
{{item.name}}
|
||||
<button {{on "click" (fn this.deleteItem item.id)}}>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Helpers (if/unless)
|
||||
|
||||
```glimmer-js
|
||||
// app/components/conditional-inline.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { if as ifHelper } from '@ember/helper'; // Built-in to Ember
|
||||
|
||||
class ConditionalInline extends Component {
|
||||
<template>
|
||||
{{! Ternary-like behavior }}
|
||||
<span class={{ifHelper @isActive "active" "inactive"}}>
|
||||
{{@user.name}}
|
||||
</span>
|
||||
|
||||
{{! Conditional attribute }}
|
||||
<button disabled={{ifHelper @isProcessing true}}>
|
||||
{{ifHelper @isProcessing "Processing..." "Submit"}}
|
||||
</button>
|
||||
|
||||
{{! With default value }}
|
||||
<p>{{ifHelper @description @description "No description provided"}}</p>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Practical Combinations
|
||||
|
||||
**Dynamic Classes:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/dynamic-classes.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { concat, if as ifHelper } from '@ember/helper'; // Built-in to Ember
|
||||
import { and, not } from 'ember-truth-helpers';
|
||||
|
||||
class DynamicClasses extends Component {
|
||||
<template>
|
||||
<div
|
||||
class={{concat
|
||||
"card "
|
||||
(ifHelper @isPremium "premium ")
|
||||
(ifHelper (and @isNew (not @isRead)) "unread ")
|
||||
@customClass
|
||||
}}
|
||||
>
|
||||
<h3>{{@title}}</h3>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**List Filtering:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/filtered-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
import { fn, concat } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import { eq } from 'ember-truth-helpers';
|
||||
import { array } from 'ember-composable-helpers/helpers';
|
||||
|
||||
class FilteredList extends Component {
|
||||
@tracked filter = 'all';
|
||||
|
||||
@cached
|
||||
get filteredItems() {
|
||||
if (this.filter === 'all') return this.args.items;
|
||||
return this.args.items.filter((item) => item.status === this.filter);
|
||||
}
|
||||
|
||||
<template>
|
||||
<select {{on "change" (fn (mut this.filter) target.value)}}>
|
||||
{{#each (array "all" "active" "pending" "completed") as |option|}}
|
||||
<option value={{option}} selected={{eq this.filter option}}>
|
||||
{{option}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
|
||||
{{#each this.filteredItems as |item|}}
|
||||
<div class={{concat "item " item.status}}>
|
||||
{{item.name}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Complex Example
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { concat, if as ifHelper, fn } from '@ember/helper'; // Built-in to Ember
|
||||
import { eq, not, and, or } from 'ember-truth-helpers';
|
||||
import { hash, array, get } from 'ember-composable-helpers/helpers';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
class UserProfileCard extends Component {
|
||||
updateField = (field, value) => {
|
||||
this.args.onUpdate(field, value);
|
||||
};
|
||||
|
||||
<template>
|
||||
<div
|
||||
class={{concat
|
||||
"profile-card "
|
||||
(ifHelper @user.isPremium "premium ")
|
||||
(ifHelper (and @user.isOnline (not @user.isAway)) "online ")
|
||||
}}
|
||||
>
|
||||
<h2>{{concat @user.firstName " " @user.lastName}}</h2>
|
||||
|
||||
{{#if (or (eq @user.role "admin") (eq @user.role "moderator"))}}
|
||||
<span class="badge">
|
||||
{{get (hash admin="Administrator" moderator="Moderator") @user.role}}
|
||||
</span>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and @canEdit (not @user.locked))}}
|
||||
<div class="actions">
|
||||
{{#each (array "profile" "settings" "privacy") as |section|}}
|
||||
<button {{on "click" (fn this.updateField "activeSection" section)}}>
|
||||
Edit
|
||||
{{section}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<p class={{ifHelper @user.verified "verified" "unverified"}}>
|
||||
{{ifHelper @user.bio @user.bio "No bio provided"}}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Library helpers**: ~0% overhead (compiled into efficient bytecode)
|
||||
- **Custom helpers**: 5-15% overhead per helper call
|
||||
- **Inline logic**: Cleaner templates, better tree-shaking
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Library helpers**: For all common operations (equality, logic, arrays, strings)
|
||||
- **Custom helpers**: Only for domain-specific logic not covered by library helpers
|
||||
- **Component logic**: For complex operations that need @cached or multiple dependencies
|
||||
|
||||
## Complete Helper Reference
|
||||
|
||||
**Note:** These helpers will be built into Ember 7 core. Until then:
|
||||
|
||||
**Actually Built-in to Ember (from `@ember/helper`):**
|
||||
|
||||
- `concat` - Concatenate strings
|
||||
- `fn` - Partial application / bind arguments
|
||||
- `if` - Ternary-like conditional value
|
||||
- `mut` - Create settable binding (use sparingly)
|
||||
|
||||
**From `ember-truth-helpers` package:**
|
||||
|
||||
- `eq` - Equality (===)
|
||||
- `not` - Negation (!)
|
||||
- `and` - Logical AND
|
||||
- `or` - Logical OR
|
||||
- `lt`, `lte`, `gt`, `gte` - Numeric comparisons
|
||||
|
||||
**From `ember-composable-helpers` package:**
|
||||
|
||||
- `array` - Create array inline
|
||||
- `hash` - Create object inline
|
||||
- `get` - Dynamic property access
|
||||
|
||||
## References
|
||||
|
||||
- [Ember Built-in Helpers](https://guides.emberjs.com/release/templates/built-in-helpers/)
|
||||
- [Template Helpers API](https://api.emberjs.com/ember/release/modules/@ember%2Fhelper)
|
||||
- [fn Helper Guide](https://guides.emberjs.com/release/components/helper-functions/)
|
||||
- [ember-truth-helpers](https://github.com/jmurphyau/ember-truth-helpers)
|
||||
- [ember-composable-helpers](https://github.com/DockYard/ember-composable-helpers)
|
||||
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/)
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: No helper() Wrapper for Plain Functions
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Simpler code, better performance
|
||||
tags: helpers, templates, modern-ember
|
||||
---
|
||||
|
||||
## No helper() Wrapper for Plain Functions
|
||||
|
||||
In modern Ember, plain functions can be used directly as helpers without wrapping them with `helper()`. The `helper()` wrapper is legacy and adds unnecessary complexity.
|
||||
|
||||
**Incorrect (using helper() wrapper):**
|
||||
|
||||
```javascript
|
||||
// app/utils/format-date.js
|
||||
import { helper } from '@ember/component/helper';
|
||||
|
||||
function formatDate([date]) {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
export default helper(formatDate);
|
||||
```
|
||||
|
||||
**Correct (plain function):**
|
||||
|
||||
```javascript
|
||||
// app/utils/format-date.js
|
||||
export function formatDate(date) {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in templates:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/post-card.gjs
|
||||
import { formatDate } from '../utils/format-date';
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h2>{{@post.title}}</h2>
|
||||
<time>{{formatDate @post.publishedAt}}</time>
|
||||
</article>
|
||||
</template>
|
||||
```
|
||||
|
||||
**With Multiple Arguments:**
|
||||
|
||||
```javascript
|
||||
// app/utils/format-currency.js
|
||||
export function formatCurrency(amount, currency = 'USD') {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/price.gjs
|
||||
import { formatCurrency } from '../utils/format-currency';
|
||||
|
||||
<template>
|
||||
<span class="price">
|
||||
{{formatCurrency @amount @currency}}
|
||||
</span>
|
||||
</template>
|
||||
```
|
||||
|
||||
**For Helpers that Need Services (use class-based):**
|
||||
|
||||
When you need dependency injection, use a class instead of `helper()`:
|
||||
|
||||
```javascript
|
||||
// app/utils/format-relative-time.js
|
||||
export class FormatRelativeTime {
|
||||
constructor(owner) {
|
||||
this.intl = owner.lookup('service:intl');
|
||||
}
|
||||
|
||||
compute(date) {
|
||||
return this.intl.formatRelative(date);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why Avoid helper():**
|
||||
|
||||
1. **Simpler**: Plain functions are easier to understand
|
||||
2. **Standard JavaScript**: No Ember-specific wrapper needed
|
||||
3. **Better Testing**: Plain functions are easier to test
|
||||
4. **Performance**: No wrapper overhead
|
||||
5. **Modern Pattern**: Aligns with modern Ember conventions
|
||||
|
||||
**Migration from helper():**
|
||||
|
||||
```javascript
|
||||
// Before
|
||||
import { helper } from '@ember/component/helper';
|
||||
|
||||
function capitalize([text]) {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
export default helper(capitalize);
|
||||
|
||||
// After
|
||||
export function capitalize(text) {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
```
|
||||
|
||||
**Common Helper Patterns:**
|
||||
|
||||
```javascript
|
||||
// app/utils/string-helpers.js
|
||||
export function capitalize(text) {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
export function truncate(text, length = 50) {
|
||||
if (text.length <= length) return text;
|
||||
return text.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
export function pluralize(count, singular, plural) {
|
||||
return count === 1 ? singular : plural;
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Usage
|
||||
import { capitalize, truncate, pluralize } from '../utils/string-helpers';
|
||||
|
||||
<template>
|
||||
<h1>{{capitalize @title}}</h1>
|
||||
<p>{{truncate @description 100}}</p>
|
||||
<span>{{@count}} {{pluralize @count "item" "items"}}</span>
|
||||
</template>
|
||||
```
|
||||
|
||||
Plain functions are the modern way to create helpers in Ember. Only use classes when you need dependency injection.
|
||||
|
||||
Reference: [Ember Helpers - Plain Functions](https://guides.emberjs.com/release/components/helper-functions/)
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: Use {{on}} Modifier Instead of Event Handler Properties
|
||||
impact: MEDIUM
|
||||
impactDescription: Better performance and clearer event handling
|
||||
tags: performance, events, modifiers, best-practices
|
||||
---
|
||||
|
||||
## Use {{on}} Modifier Instead of Event Handler Properties
|
||||
|
||||
Always use the `{{on}}` modifier for event handling instead of HTML event handler properties. The `{{on}}` modifier provides better memory management, automatic cleanup, and clearer intent.
|
||||
|
||||
**Why {{on}} is Better:**
|
||||
|
||||
- Automatic cleanup when element is removed (prevents memory leaks)
|
||||
- Supports event options (`capture`, `passive`, `once`)
|
||||
- More explicit and searchable in templates
|
||||
|
||||
**Incorrect (HTML event properties):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/button.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class Button extends Component {
|
||||
@action
|
||||
handleClick() {
|
||||
console.log('clicked');
|
||||
}
|
||||
|
||||
<template>
|
||||
<button onclick={{this.handleClick}}>
|
||||
Click Me
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct ({{on}} modifier):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/button.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class Button extends Component {
|
||||
@action
|
||||
handleClick() {
|
||||
console.log('clicked');
|
||||
}
|
||||
|
||||
<template>
|
||||
<button {{on "click" this.handleClick}}>
|
||||
Click Me
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Event Options
|
||||
|
||||
The `{{on}}` modifier supports standard event listener options:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/scrollable.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class Scrollable extends Component {
|
||||
@action
|
||||
handleScroll(event) {
|
||||
console.log('scrolled', event.target.scrollTop);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! passive: true improves scroll performance }}
|
||||
<div {{on "scroll" this.handleScroll passive=true}}>
|
||||
{{yield}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Available options:**
|
||||
|
||||
- `capture` - Use capture phase instead of bubble phase
|
||||
- `once` - Remove listener after first invocation
|
||||
- `passive` - Indicates handler won't call `preventDefault()` (better scroll performance)
|
||||
|
||||
### Handling Multiple Events
|
||||
|
||||
```glimmer-js
|
||||
// app/components/input-field.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class InputField extends Component {
|
||||
@action
|
||||
handleFocus() {
|
||||
console.log('focused');
|
||||
}
|
||||
|
||||
@action
|
||||
handleBlur() {
|
||||
console.log('blurred');
|
||||
}
|
||||
|
||||
@action
|
||||
handleInput(event) {
|
||||
this.args.onChange?.(event.target.value);
|
||||
}
|
||||
|
||||
<template>
|
||||
<input
|
||||
type="text"
|
||||
value={{@value}}
|
||||
{{on "focus" this.handleFocus}}
|
||||
{{on "blur" this.handleBlur}}
|
||||
{{on "input" this.handleInput}}
|
||||
/>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Preventing Default and Stopping Propagation
|
||||
|
||||
Handle these in your action, not in the template:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/form.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class Form extends Component {
|
||||
@action
|
||||
handleSubmit(event) {
|
||||
event.preventDefault(); // Prevent page reload
|
||||
event.stopPropagation(); // Stop event bubbling if needed
|
||||
|
||||
this.args.onSubmit?.(/* form data */);
|
||||
}
|
||||
|
||||
<template>
|
||||
<form {{on "submit" this.handleSubmit}}>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Events
|
||||
|
||||
```glimmer-js
|
||||
// app/components/keyboard-nav.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class KeyboardNav extends Component {
|
||||
@action
|
||||
handleKeyDown(event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.args.onActivate?.();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
this.args.onCancel?.();
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div role="button" tabindex="0" {{on "keydown" this.handleKeyDown}}>
|
||||
{{yield}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Tip: Event Delegation
|
||||
|
||||
For lists with many items, use event delegation on the parent:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/todo-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class TodoList extends Component {
|
||||
@action
|
||||
handleClick(event) {
|
||||
// Find which todo was clicked
|
||||
const todoId = event.target.closest('[data-todo-id]')?.dataset.todoId;
|
||||
if (todoId) {
|
||||
this.args.onTodoClick?.(todoId);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! Single listener for all todos - better than one per item }}
|
||||
<ul {{on "click" this.handleClick}}>
|
||||
{{#each @todos as |todo|}}
|
||||
<li data-todo-id={{todo.id}}>
|
||||
{{todo.title}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
**❌ Don't bind directly without @action:**
|
||||
|
||||
```glimmer-js
|
||||
// This won't work - loses 'this' context
|
||||
<button {{on "click" this.myMethod}}>Bad</button>
|
||||
```
|
||||
|
||||
**✅ Use @action decorator:**
|
||||
|
||||
```glimmer-js
|
||||
@action
|
||||
myMethod() {
|
||||
// 'this' is correctly bound
|
||||
}
|
||||
|
||||
<button {{on "click" this.myMethod}}>Good</button>
|
||||
```
|
||||
|
||||
**❌ Don't use string event handlers:**
|
||||
|
||||
```glimmer-js
|
||||
{{! Security risk and doesn't work in strict mode }}
|
||||
<button onclick="handleClick()">Bad</button>
|
||||
```
|
||||
|
||||
Always use the `{{on}}` modifier for cleaner, safer, and more performant event handling in Ember applications.
|
||||
|
||||
**References:**
|
||||
|
||||
- [Ember Modifiers Guide](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/)
|
||||
- [{{on}} Modifier RFC](https://github.com/emberjs/rfcs/blob/master/text/0471-on-modifier.md)
|
||||
- [Event Listener Options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#parameters)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Use Route-Based Code Splitting
|
||||
impact: CRITICAL
|
||||
impactDescription: 30-70% initial bundle reduction
|
||||
tags: routes, lazy-loading, embroider, bundle-size
|
||||
---
|
||||
|
||||
## Use Route-Based Code Splitting
|
||||
|
||||
With Embroider's route-based code splitting, routes and their components are automatically split into separate chunks, loaded only when needed.
|
||||
|
||||
**Incorrect (everything in main bundle):**
|
||||
|
||||
```javascript
|
||||
// ember-cli-build.js
|
||||
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
|
||||
|
||||
module.exports = function (defaults) {
|
||||
const app = new EmberApp(defaults, {
|
||||
// No optimization
|
||||
});
|
||||
|
||||
return app.toTree();
|
||||
};
|
||||
```
|
||||
|
||||
**Correct (Embroider with Vite and route splitting):**
|
||||
|
||||
```javascript
|
||||
// ember-cli-build.js
|
||||
const { Vite } = require('@embroider/vite');
|
||||
|
||||
module.exports = require('@embroider/compat').compatBuild(app, Vite, {
|
||||
staticAddonTestSupportTrees: true,
|
||||
staticAddonTrees: true,
|
||||
staticHelpers: true,
|
||||
staticModifiers: true,
|
||||
staticComponents: true,
|
||||
splitAtRoutes: ['admin', 'reports', 'settings'], // Routes to split
|
||||
});
|
||||
```
|
||||
|
||||
Embroider with `splitAtRoutes` creates separate bundles for specified routes, reducing initial load time by 30-70%.
|
||||
|
||||
Reference: [Embroider Documentation](https://github.com/embroider-build/embroider)
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Use Loading Substates for Better UX
|
||||
impact: CRITICAL
|
||||
impactDescription: Perceived performance improvement
|
||||
tags: routes, loading, ux, performance
|
||||
---
|
||||
|
||||
## Use Loading Substates for Better UX
|
||||
|
||||
Implement loading substates to show immediate feedback while data loads, preventing blank screens and improving perceived performance.
|
||||
|
||||
**Incorrect (no loading state):**
|
||||
|
||||
```javascript
|
||||
// app/routes/posts.js
|
||||
export default class PostsRoute extends Route {
|
||||
async model() {
|
||||
return this.store.request({ url: '/posts' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (with loading substate):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/posts-loading.gjs
|
||||
import { LoadingSpinner } from './loading-spinner';
|
||||
|
||||
<template>
|
||||
<div class="loading-spinner" role="status" aria-live="polite">
|
||||
<span class="sr-only">Loading posts...</span>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/routes/posts.js
|
||||
export default class PostsRoute extends Route {
|
||||
model() {
|
||||
// Return promise directly - Ember will show posts-loading template
|
||||
return this.store.request({ url: '/posts' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ember automatically renders `{route-name}-loading` route templates while the model promise resolves, providing better UX without extra code.
|
||||
230
.agents/skills/ember-best-practices/rules/route-model-caching.md
Normal file
230
.agents/skills/ember-best-practices/rules/route-model-caching.md
Normal file
@@ -0,0 +1,230 @@
|
||||
---
|
||||
title: Implement Smart Route Model Caching
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Reduce redundant API calls and improve UX
|
||||
tags: routes, caching, performance, model
|
||||
---
|
||||
|
||||
## Implement Smart Route Model Caching
|
||||
|
||||
Implement intelligent model caching strategies to reduce redundant API calls and improve user experience.
|
||||
|
||||
**Incorrect (always fetches fresh data):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/post.gjs
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class PostRoute extends Route {
|
||||
@service store;
|
||||
|
||||
model(params) {
|
||||
// Always makes API call, even if we just loaded this post
|
||||
return this.store.request({ url: `/posts/${params.post_id}` });
|
||||
}
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{@model.title}}</h1>
|
||||
<div>{{@model.content}}</div>
|
||||
</article>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (with smart caching):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/post.gjs
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class PostRoute extends Route {
|
||||
@service store;
|
||||
|
||||
model(params) {
|
||||
// Check cache first
|
||||
const cached = this.store.cache.peek({
|
||||
type: 'post',
|
||||
id: params.post_id,
|
||||
});
|
||||
|
||||
// Return cached if fresh (less than 5 minutes old)
|
||||
if (cached && this.isCacheFresh(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
return this.store.request({
|
||||
url: `/posts/${params.post_id}`,
|
||||
options: { reload: true },
|
||||
});
|
||||
}
|
||||
|
||||
isCacheFresh(record) {
|
||||
const cacheTime = record.meta?.cachedAt || 0;
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
return Date.now() - cacheTime < fiveMinutes;
|
||||
}
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{@model.title}}</h1>
|
||||
<div>{{@model.content}}</div>
|
||||
</article>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Service-based caching layer:**
|
||||
|
||||
```javascript
|
||||
// app/services/post-cache.js
|
||||
import Service from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
import { TrackedMap } from 'tracked-built-ins';
|
||||
|
||||
export default class PostCacheService extends Service {
|
||||
@service store;
|
||||
|
||||
cache = new TrackedMap();
|
||||
cacheTimes = new Map();
|
||||
cacheTimeout = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
async getPost(id, { forceRefresh = false } = {}) {
|
||||
const now = Date.now();
|
||||
const cacheTime = this.cacheTimes.get(id) || 0;
|
||||
const isFresh = now - cacheTime < this.cacheTimeout;
|
||||
|
||||
if (!forceRefresh && isFresh && this.cache.has(id)) {
|
||||
return this.cache.get(id);
|
||||
}
|
||||
|
||||
const post = await this.store.request({ url: `/posts/${id}` });
|
||||
|
||||
this.cache.set(id, post);
|
||||
this.cacheTimes.set(id, now);
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
invalidate(id) {
|
||||
this.cache.delete(id);
|
||||
this.cacheTimes.delete(id);
|
||||
}
|
||||
|
||||
invalidateAll() {
|
||||
this.cache.clear();
|
||||
this.cacheTimes.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/post.gjs
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class PostRoute extends Route {
|
||||
@service postCache;
|
||||
|
||||
model(params) {
|
||||
return this.postCache.getPost(params.post_id);
|
||||
}
|
||||
|
||||
// Refresh data when returning to route
|
||||
async activate() {
|
||||
super.activate(...arguments);
|
||||
const params = this.paramsFor('post');
|
||||
await this.postCache.getPost(params.post_id, { forceRefresh: true });
|
||||
}
|
||||
|
||||
<template>
|
||||
<article>
|
||||
<h1>{{@model.title}}</h1>
|
||||
<div>{{@model.content}}</div>
|
||||
</article>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Using query params for cache control:**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/posts.gjs
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class PostsRoute extends Route {
|
||||
@service store;
|
||||
|
||||
queryParams = {
|
||||
refresh: { refreshModel: true },
|
||||
};
|
||||
|
||||
model(params) {
|
||||
const options = params.refresh ? { reload: true } : { backgroundReload: true };
|
||||
|
||||
return this.store.request({
|
||||
url: '/posts',
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="posts">
|
||||
<button {{on "click" (fn this.refresh)}}>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Background refresh pattern:**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/dashboard.gjs
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class DashboardRoute extends Route {
|
||||
@service store;
|
||||
|
||||
async model() {
|
||||
// Return cached data immediately
|
||||
const cached = this.store.cache.peek({ type: 'dashboard' });
|
||||
|
||||
// Refresh in background
|
||||
this.store.request({
|
||||
url: '/dashboard',
|
||||
options: { backgroundReload: true },
|
||||
});
|
||||
|
||||
return cached || this.store.request({ url: '/dashboard' });
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h1>Dashboard</h1>
|
||||
<div>Stats: {{@model.stats}}</div>
|
||||
</div>
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh.
|
||||
|
||||
Reference: [WarpDrive Caching](https://warp-drive.io/)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Parallel Data Loading in Model Hooks
|
||||
impact: CRITICAL
|
||||
impactDescription: 2-10× improvement
|
||||
tags: routes, data-fetching, parallelization, performance
|
||||
---
|
||||
|
||||
## Parallel Data Loading in Model Hooks
|
||||
|
||||
When fetching multiple independent data sources in a route's model hook, use `Promise.all()` or RSVP.hash() to load them in parallel instead of sequentially.
|
||||
`export default` in these route examples is intentional because route modules are discovered through resolver lookup. In hybrid `.gjs`/`.hbs` codebases, keep route defaults and add named exports only when you need explicit imports elsewhere.
|
||||
|
||||
**Incorrect (sequential loading, 3 round trips):**
|
||||
|
||||
```javascript
|
||||
// app/routes/dashboard.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class DashboardRoute extends Route {
|
||||
@service store;
|
||||
|
||||
async model() {
|
||||
const user = await this.store.request({ url: '/users/me' });
|
||||
const posts = await this.store.request({ url: '/posts?recent=true' });
|
||||
const notifications = await this.store.request({ url: '/notifications?unread=true' });
|
||||
|
||||
return { user, posts, notifications };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (parallel loading, 1 round trip):**
|
||||
|
||||
```javascript
|
||||
// app/routes/dashboard.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
export default class DashboardRoute extends Route {
|
||||
@service store;
|
||||
|
||||
model() {
|
||||
return hash({
|
||||
user: this.store.request({ url: '/users/me' }),
|
||||
posts: this.store.request({ url: '/posts?recent=true' }),
|
||||
notifications: this.store.request({ url: '/notifications?unread=true' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Using `hash()` from RSVP allows Ember to resolve all promises concurrently, significantly reducing load time.
|
||||
105
.agents/skills/ember-best-practices/rules/route-templates.md
Normal file
105
.agents/skills/ember-best-practices/rules/route-templates.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Use Route Templates with Co-located Syntax
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Better code organization and maintainability
|
||||
tags: routes, templates, gjs, co-location
|
||||
---
|
||||
|
||||
## Use Route Templates with Co-located Syntax
|
||||
|
||||
Use co-located route templates with modern gjs syntax for better organization and maintainability.
|
||||
|
||||
**Incorrect (separate template file - old pattern):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/posts.js (separate file)
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class PostsRoute extends Route {
|
||||
model() {
|
||||
return this.store.request({ url: '/posts' });
|
||||
}
|
||||
}
|
||||
|
||||
// app/templates/posts.gjs (separate template file)
|
||||
<template>
|
||||
<h1>Posts</h1>
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (co-located route template):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/posts.gjs
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class PostsRoute extends Route {
|
||||
model() {
|
||||
return this.store.request({ url: '/posts' });
|
||||
}
|
||||
|
||||
<template>
|
||||
<h1>Posts</h1>
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
{{outlet}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**With loading and error states:**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/posts.gjs
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class PostsRoute extends Route {
|
||||
@service store;
|
||||
|
||||
model() {
|
||||
return this.store.request({ url: '/posts' });
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="posts-page">
|
||||
<h1>Posts</h1>
|
||||
|
||||
{{#if @model}}
|
||||
<ul>
|
||||
{{#each @model as |post|}}
|
||||
<li>{{post.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
{{outlet}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Template-only routes:**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/about.gjs
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<h1>About Us</h1>
|
||||
<p>Welcome to our application!</p>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Co-located route templates keep route logic and presentation together, making the codebase easier to navigate and maintain.
|
||||
|
||||
Reference: [Ember Routes](https://guides.emberjs.com/release/routing/)
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: Cache API Responses in Services
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 50-90% reduction in duplicate requests
|
||||
tags: services, caching, performance, api
|
||||
---
|
||||
|
||||
## Cache API Responses in Services
|
||||
|
||||
Cache API responses in services to avoid duplicate network requests. Use tracked properties to make the cache reactive.
|
||||
|
||||
**Incorrect (no caching):**
|
||||
|
||||
```javascript
|
||||
// app/services/user.js
|
||||
import Service from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class UserService extends Service {
|
||||
@service store;
|
||||
|
||||
async getCurrentUser() {
|
||||
// Fetches from API every time
|
||||
return this.store.request({ url: '/users/me' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (with caching):**
|
||||
|
||||
```javascript
|
||||
// app/services/user.js
|
||||
import Service from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { TrackedMap } from 'tracked-built-ins';
|
||||
|
||||
export default class UserService extends Service {
|
||||
@service store;
|
||||
|
||||
@tracked currentUser = null;
|
||||
cache = new TrackedMap();
|
||||
|
||||
async getCurrentUser() {
|
||||
if (!this.currentUser) {
|
||||
const response = await this.store.request({ url: '/users/me' });
|
||||
this.currentUser = response.content.data;
|
||||
}
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
async getUser(id) {
|
||||
if (!this.cache.has(id)) {
|
||||
const response = await this.store.request({ url: `/users/${id}` });
|
||||
this.cache.set(id, response.content.data);
|
||||
}
|
||||
return this.cache.get(id);
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.currentUser = null;
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For time-based cache invalidation:**
|
||||
|
||||
```javascript
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class DataService extends Service {
|
||||
@tracked _cache = null;
|
||||
_cacheTimestamp = null;
|
||||
_cacheDuration = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
async getData() {
|
||||
const now = Date.now();
|
||||
const isCacheValid =
|
||||
this._cache && this._cacheTimestamp && now - this._cacheTimestamp < this._cacheDuration;
|
||||
|
||||
if (!isCacheValid) {
|
||||
this._cache = await this.fetchData();
|
||||
this._cacheTimestamp = now;
|
||||
}
|
||||
|
||||
return this._cache;
|
||||
}
|
||||
|
||||
async fetchData() {
|
||||
const response = await fetch('/api/data');
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Caching in services prevents duplicate API requests and improves performance significantly.
|
||||
@@ -0,0 +1,342 @@
|
||||
---
|
||||
title: Implement Robust Data Requesting Patterns
|
||||
impact: HIGH
|
||||
impactDescription: Prevents request waterfalls and race conditions in data flows
|
||||
tags: services, data-fetching, concurrency, cancellation, reliability
|
||||
---
|
||||
|
||||
## Implement Robust Data Requesting Patterns
|
||||
|
||||
Use proper patterns for data fetching including parallel requests, error handling, request cancellation, and retry logic.
|
||||
`export default` in route/service snippets below is intentional because these modules are commonly resolved by convention and referenced from templates. In hybrid `.gjs`/`.hbs` codebases, you can pair named exports with a default alias where needed.
|
||||
|
||||
## Problem
|
||||
|
||||
Naive data fetching creates waterfall requests, doesn't handle errors properly, and can cause race conditions or memory leaks from uncanceled requests.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```javascript
|
||||
// app/routes/dashboard.js
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default class DashboardRoute extends Route {
|
||||
async model() {
|
||||
// Sequential waterfall - slow!
|
||||
const user = await this.store.request({ url: '/users/me' });
|
||||
const posts = await this.store.request({ url: '/posts' });
|
||||
const notifications = await this.store.request({ url: '/notifications' });
|
||||
|
||||
// No error handling
|
||||
// No cancellation
|
||||
return { user, posts, notifications };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Solution: Parallel Requests
|
||||
|
||||
Use `RSVP.hash` or `Promise.all` for parallel loading:
|
||||
|
||||
**Correct (parallelized model loading):**
|
||||
|
||||
```javascript
|
||||
// app/routes/dashboard.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
export default class DashboardRoute extends Route {
|
||||
async model() {
|
||||
return hash({
|
||||
user: this.store.request({ url: '/users/me' }),
|
||||
posts: this.store.request({ url: '/posts?recent=true' }),
|
||||
notifications: this.store.request({ url: '/notifications?unread=true' }),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling Pattern
|
||||
|
||||
Handle errors gracefully with fallbacks:
|
||||
|
||||
```javascript
|
||||
// app/services/api.js
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class ApiService extends Service {
|
||||
@service store;
|
||||
@tracked lastError = null;
|
||||
|
||||
async fetchWithFallback(url, fallback = null) {
|
||||
try {
|
||||
const response = await this.store.request({ url });
|
||||
this.lastError = null;
|
||||
return response.content;
|
||||
} catch (error) {
|
||||
this.lastError = error.message;
|
||||
console.error(`API Error fetching ${url}:`, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchWithRetry(url, { maxRetries = 3, delay = 1000 } = {}) {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await this.store.request({ url });
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries - 1) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Request Cancellation with AbortController
|
||||
|
||||
Prevent race conditions by canceling stale requests:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/search-results.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { restartableTask, timeout } from 'ember-concurrency';
|
||||
|
||||
class SearchResults extends Component {
|
||||
@service store;
|
||||
@tracked results = [];
|
||||
|
||||
// Automatically cancels previous searches
|
||||
@restartableTask
|
||||
*searchTask(query) {
|
||||
yield timeout(300); // Debounce
|
||||
|
||||
try {
|
||||
const response = yield this.store.request({
|
||||
url: `/search?q=${encodeURIComponent(query)}`,
|
||||
});
|
||||
this.results = response.content;
|
||||
} catch (error) {
|
||||
if (error.name !== 'TaskCancelation') {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<input
|
||||
type="search"
|
||||
{{on "input" (fn this.searchTask.perform @value)}}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
|
||||
{{#if this.searchTask.isRunning}}
|
||||
<div class="loading">Searching...</div>
|
||||
{{else}}
|
||||
<ul>
|
||||
{{#each this.results as |result|}}
|
||||
<li>{{result.title}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Manual AbortController Pattern
|
||||
|
||||
For non-ember-concurrency scenarios:
|
||||
|
||||
```javascript
|
||||
// app/services/data-fetcher.js
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
|
||||
export default class DataFetcherService extends Service {
|
||||
@service store;
|
||||
@tracked data = null;
|
||||
@tracked isLoading = false;
|
||||
|
||||
abortController = null;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
registerDestructor(this, () => {
|
||||
this.abortController?.abort();
|
||||
});
|
||||
}
|
||||
|
||||
async fetch(url) {
|
||||
// Cancel previous request
|
||||
this.abortController?.abort();
|
||||
this.abortController = new AbortController();
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
// Note: WarpDrive handles AbortSignal internally
|
||||
const response = await this.store.request({
|
||||
url,
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
this.data = response.content;
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependent Requests Pattern
|
||||
|
||||
When requests depend on previous results:
|
||||
|
||||
```javascript
|
||||
// app/routes/post.js
|
||||
import Route from '@ember/routing/route';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
export default class PostRoute extends Route {
|
||||
async model({ post_id }) {
|
||||
// First fetch the post
|
||||
const post = await this.store.request({
|
||||
url: `/posts/${post_id}`,
|
||||
});
|
||||
|
||||
// Then fetch related data in parallel
|
||||
return hash({
|
||||
post,
|
||||
author: this.store.request({
|
||||
url: `/users/${post.content.authorId}`,
|
||||
}),
|
||||
comments: this.store.request({
|
||||
url: `/posts/${post_id}/comments`,
|
||||
}),
|
||||
relatedPosts: this.store.request({
|
||||
url: `/posts/${post_id}/related`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Polling Pattern
|
||||
|
||||
For real-time data updates:
|
||||
|
||||
```javascript
|
||||
// app/services/live-data.js
|
||||
import Service, { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
|
||||
export default class LiveDataService extends Service {
|
||||
@service store;
|
||||
@tracked data = null;
|
||||
|
||||
intervalId = null;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
registerDestructor(this, () => {
|
||||
this.stopPolling();
|
||||
});
|
||||
}
|
||||
|
||||
startPolling(url, interval = 5000) {
|
||||
this.stopPolling();
|
||||
|
||||
this.poll(url); // Initial fetch
|
||||
this.intervalId = setInterval(() => this.poll(url), interval);
|
||||
}
|
||||
|
||||
async poll(url) {
|
||||
try {
|
||||
const response = await this.store.request({ url });
|
||||
this.data = response.content;
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
stopPolling() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Batch Requests
|
||||
|
||||
Optimize multiple similar requests:
|
||||
|
||||
```javascript
|
||||
// app/services/batch-loader.js
|
||||
import Service, { service } from '@ember/service';
|
||||
|
||||
export default class BatchLoaderService extends Service {
|
||||
@service store;
|
||||
|
||||
pendingIds = new Set();
|
||||
batchTimeout = null;
|
||||
|
||||
async loadUser(id) {
|
||||
this.pendingIds.add(id);
|
||||
|
||||
if (!this.batchTimeout) {
|
||||
this.batchTimeout = setTimeout(() => this.executeBatch(), 50);
|
||||
}
|
||||
|
||||
// Return a promise that resolves when batch completes
|
||||
return new Promise((resolve) => {
|
||||
this.registerCallback(id, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async executeBatch() {
|
||||
const ids = Array.from(this.pendingIds);
|
||||
this.pendingIds.clear();
|
||||
this.batchTimeout = null;
|
||||
|
||||
const response = await this.store.request({
|
||||
url: `/users?ids=${ids.join(',')}`,
|
||||
});
|
||||
|
||||
// Resolve all pending promises
|
||||
response.content.forEach((user) => {
|
||||
this.resolveCallback(user.id, user);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Parallel requests (RSVP.hash)**: 60-80% faster than sequential
|
||||
- **Request cancellation**: Prevents memory leaks and race conditions
|
||||
- **Retry logic**: Improves reliability with < 5% overhead
|
||||
- **Batch loading**: 40-70% reduction in requests
|
||||
|
||||
## When to Use
|
||||
|
||||
- **RSVP.hash**: Independent data that can load in parallel
|
||||
- **ember-concurrency**: Search, autocomplete, or user-driven requests
|
||||
- **AbortController**: Long-running requests that may become stale
|
||||
- **Retry logic**: Critical data with transient network issues
|
||||
- **Batch loading**: Loading many similar items (N+1 scenarios)
|
||||
|
||||
## References
|
||||
|
||||
- [WarpDrive Documentation](https://warp-drive.io/)
|
||||
- [ember-concurrency](https://ember-concurrency.com/)
|
||||
- [RSVP.js](https://github.com/tildeio/rsvp.js)
|
||||
- [AbortController MDN](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Optimize WarpDrive Queries
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 40-70% reduction in API calls
|
||||
tags: warp-drive, performance, api, optimization
|
||||
---
|
||||
|
||||
## Optimize WarpDrive Queries
|
||||
|
||||
Use WarpDrive's request features effectively to reduce API calls and load only the data you need.
|
||||
|
||||
**Incorrect (multiple queries, overfetching):**
|
||||
|
||||
```javascript
|
||||
// app/routes/posts.js
|
||||
export default class PostsRoute extends Route {
|
||||
@service store;
|
||||
|
||||
async model() {
|
||||
// Loads all posts (could be thousands)
|
||||
const response = await this.store.request({ url: '/posts' });
|
||||
const posts = response.content.data;
|
||||
|
||||
// Then filters in memory
|
||||
return posts.filter((post) => post.attributes.status === 'published');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (filtered query with pagination):**
|
||||
|
||||
```javascript
|
||||
// app/routes/posts.js
|
||||
export default class PostsRoute extends Route {
|
||||
@service store;
|
||||
|
||||
queryParams = {
|
||||
page: { refreshModel: true },
|
||||
filter: { refreshModel: true },
|
||||
};
|
||||
|
||||
model(params) {
|
||||
// Server-side filtering and pagination
|
||||
return this.store.request({
|
||||
url: '/posts',
|
||||
data: {
|
||||
filter: {
|
||||
status: 'published',
|
||||
},
|
||||
page: {
|
||||
number: params.page || 1,
|
||||
size: 20,
|
||||
},
|
||||
include: 'author', // Sideload related data
|
||||
fields: {
|
||||
// Sparse fieldsets
|
||||
posts: 'title,excerpt,publishedAt,author',
|
||||
users: 'name,avatar',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use request with includes for single records:**
|
||||
|
||||
```javascript
|
||||
// app/routes/post.js
|
||||
export default class PostRoute extends Route {
|
||||
@service store;
|
||||
|
||||
model(params) {
|
||||
return this.store.request({
|
||||
url: `/posts/${params.post_id}`,
|
||||
data: {
|
||||
include: 'author,comments.user', // Nested relationships
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For frequently accessed data, use cache lookups:**
|
||||
|
||||
```javascript
|
||||
// app/components/user-badge.js
|
||||
class UserBadge extends Component {
|
||||
@service store;
|
||||
|
||||
get user() {
|
||||
// Check cache first, avoiding API call if already loaded
|
||||
const cached = this.store.cache.peek({
|
||||
type: 'user',
|
||||
id: this.args.userId,
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Only fetch if not in cache
|
||||
return this.store.request({
|
||||
url: `/users/${this.args.userId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use request options for custom queries:**
|
||||
|
||||
```javascript
|
||||
model() {
|
||||
return this.store.request({
|
||||
url: '/posts',
|
||||
data: {
|
||||
include: 'author,tags',
|
||||
customParam: 'value'
|
||||
},
|
||||
options: {
|
||||
reload: true // Bypass cache
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Efficient WarpDrive usage reduces network overhead and improves application performance significantly.
|
||||
|
||||
Reference: [WarpDrive Documentation](https://warp-drive.io/)
|
||||
@@ -0,0 +1,460 @@
|
||||
---
|
||||
title: Manage Service Owner and Linkage Patterns
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Better service organization and dependency management
|
||||
tags: services, owner, linkage, dependency-injection, architecture
|
||||
---
|
||||
|
||||
## Manage Service Owner and Linkage Patterns
|
||||
|
||||
Understand how to manage service linkage, owner passing, and alternative service organization patterns beyond the traditional app/services directory.
|
||||
|
||||
### Owner and Linkage Fundamentals
|
||||
|
||||
**Incorrect (manual service instantiation):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import ApiService from '../services/api';
|
||||
|
||||
class UserProfile extends Component {
|
||||
// ❌ Creates orphaned instance without owner
|
||||
api = new ApiService();
|
||||
|
||||
async loadUser() {
|
||||
// Won't have access to other services or owner features
|
||||
return this.api.fetch('/user/me');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>{{@user.name}}</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (proper service injection with owner):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
class UserProfile extends Component {
|
||||
// ✅ Proper injection with owner linkage
|
||||
@service api;
|
||||
|
||||
async loadUser() {
|
||||
// Has full owner context and can inject other services
|
||||
return this.api.fetch('/user/me');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>{{@user.name}}</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Owner Passing (Without Libraries)
|
||||
|
||||
**Creating instances with owner:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-processor.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { getOwner, setOwner } from '@ember/application';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
class DataTransformer {
|
||||
@service store;
|
||||
|
||||
transform(data) {
|
||||
// Can use injected services because it has an owner
|
||||
return this.store.request({ url: '/transform', data });
|
||||
}
|
||||
}
|
||||
|
||||
class DataProcessor extends Component {
|
||||
@service('store') storeService;
|
||||
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
|
||||
// Manual instantiation with owner linkage
|
||||
this.transformer = new DataTransformer();
|
||||
setOwner(this.transformer, getOwner(this));
|
||||
}
|
||||
|
||||
processData(data) {
|
||||
// transformer can now access services
|
||||
return this.transformer.transform(data);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>Processing...</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Factory pattern with owner:**
|
||||
|
||||
```javascript
|
||||
// app/utils/logger-factory.js
|
||||
import { getOwner } from '@ember/application';
|
||||
|
||||
class Logger {
|
||||
constructor(owner, context) {
|
||||
this.owner = owner;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
get config() {
|
||||
// Access configuration service via owner
|
||||
return getOwner(this).lookup('service:config');
|
||||
}
|
||||
|
||||
log(message) {
|
||||
if (this.config.enableLogging) {
|
||||
console.log(`[${this.context}]`, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(owner, context) {
|
||||
return new Logger(owner, context);
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Usage in component
|
||||
import Component from '@glimmer/component';
|
||||
import { getOwner } from '@ember/application';
|
||||
import { createLogger } from '../utils/logger-factory';
|
||||
|
||||
class My extends Component {
|
||||
logger = createLogger(getOwner(this), 'MyComponent');
|
||||
|
||||
performAction() {
|
||||
this.logger.log('Action performed');
|
||||
}
|
||||
|
||||
<template>
|
||||
<button {{on "click" this.performAction}}>Do Something</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Owner Passing with Modern Libraries
|
||||
|
||||
**Using reactiveweb's link() for ownership and destruction:**
|
||||
|
||||
The `link()` function from `reactiveweb` provides both ownership transfer and automatic destruction linkage.
|
||||
|
||||
```glimmer-js
|
||||
// app/components/advanced-form.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { link } from 'reactiveweb/link';
|
||||
|
||||
class ValidationService {
|
||||
validate(data) {
|
||||
// Validation logic
|
||||
return data.email && data.email.includes('@');
|
||||
}
|
||||
}
|
||||
|
||||
class FormStateManager {
|
||||
data = { email: '' };
|
||||
|
||||
updateEmail(value) {
|
||||
this.data.email = value;
|
||||
}
|
||||
}
|
||||
|
||||
export class AdvancedForm extends Component {
|
||||
// link() handles both owner and destruction automatically
|
||||
validation = link(this, () => new ValidationService());
|
||||
formState = link(this, () => new FormStateManager());
|
||||
|
||||
get isValid() {
|
||||
return this.validation.validate(this.formState.data);
|
||||
}
|
||||
|
||||
<template>
|
||||
<form>
|
||||
<input value={{this.formState.data.email}} />
|
||||
{{#if (not this.isValid)}}
|
||||
<span>Invalid form</span>
|
||||
{{/if}}
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Why use link():**
|
||||
|
||||
- Automatically transfers owner from parent to child instance
|
||||
- Registers destructor so child is cleaned up when parent is destroyed
|
||||
- No manual `setOwner` or `registerDestructor` calls needed
|
||||
- See [RFC #1067](https://github.com/emberjs/rfcs/pull/1067) for the proposal and reasoning
|
||||
- Documentation: https://reactive.nullvoxpopuli.com/functions/link.link.html
|
||||
|
||||
### Services Outside app/services Directory
|
||||
|
||||
**Using createService from ember-primitives:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/analytics-tracker.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { createService } from 'ember-primitives/utils';
|
||||
|
||||
// Define service logic as a plain function
|
||||
function AnalyticsService() {
|
||||
let events = [];
|
||||
|
||||
return {
|
||||
get events() {
|
||||
return events;
|
||||
},
|
||||
|
||||
track(event) {
|
||||
events.push({ ...event, timestamp: Date.now() });
|
||||
|
||||
// Send to analytics endpoint
|
||||
fetch('/analytics', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class AnalyticsTracker extends Component {
|
||||
// createService handles owner linkage and cleanup automatically
|
||||
analytics = createService(this, AnalyticsService);
|
||||
|
||||
<template>
|
||||
<div>Tracking {{this.analytics.events.length}} events</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Why createService:**
|
||||
|
||||
- No need to extend Service class
|
||||
- Automatic owner linkage and cleanup
|
||||
- Simpler than manual setOwner/registerDestructor
|
||||
- Documentation: https://ce1d7e18.ember-primitives.pages.dev/6-utils/createService.md
|
||||
|
||||
**Co-located services with components:**
|
||||
|
||||
```javascript
|
||||
// app/components/shopping-cart/service.js
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { TrackedArray } from 'tracked-built-ins';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export class CartService extends Service {
|
||||
@tracked items = new TrackedArray([]);
|
||||
|
||||
get total() {
|
||||
return this.items.reduce((sum, item) => sum + item.price, 0);
|
||||
}
|
||||
|
||||
@action
|
||||
addItem(item) {
|
||||
this.items.push(item);
|
||||
}
|
||||
|
||||
@action
|
||||
removeItem(id) {
|
||||
const index = this.items.findIndex((item) => item.id === id);
|
||||
if (index > -1) this.items.splice(index, 1);
|
||||
}
|
||||
|
||||
@action
|
||||
clear() {
|
||||
this.items.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/shopping-cart/index.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { getOwner, setOwner } from '@ember/application';
|
||||
import { CartService } from './service';
|
||||
|
||||
class ShoppingCart extends Component {
|
||||
cart = (() => {
|
||||
const instance = new CartService();
|
||||
setOwner(instance, getOwner(this));
|
||||
return instance;
|
||||
})();
|
||||
|
||||
<template>
|
||||
<div class="cart">
|
||||
<h3>Cart ({{this.cart.items.length}} items)</h3>
|
||||
<div>Total: ${{this.cart.total}}</div>
|
||||
|
||||
{{#each this.cart.items as |item|}}
|
||||
<div class="cart-item">
|
||||
{{item.name}}
|
||||
- ${{item.price}}
|
||||
<button {{on "click" (fn this.cart.removeItem item.id)}}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
<button {{on "click" this.cart.clear}}>Clear Cart</button>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Service-like utilities in utils/ directory:**
|
||||
|
||||
```javascript
|
||||
// app/utils/notification-manager.js
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { TrackedArray } from 'tracked-built-ins';
|
||||
import { setOwner } from '@ember/application';
|
||||
|
||||
export class NotificationManager {
|
||||
@tracked notifications = new TrackedArray([]);
|
||||
|
||||
constructor(owner) {
|
||||
setOwner(this, owner);
|
||||
}
|
||||
|
||||
@action
|
||||
add(message, type = 'info') {
|
||||
const notification = {
|
||||
id: Math.random().toString(36),
|
||||
message,
|
||||
type,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.notifications.push(notification);
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
setTimeout(() => this.dismiss(notification.id), 5000);
|
||||
}
|
||||
|
||||
@action
|
||||
dismiss(id) {
|
||||
const index = this.notifications.findIndex((n) => n.id === id);
|
||||
if (index > -1) this.notifications.splice(index, 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/notification-container.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { getOwner } from '@ember/application';
|
||||
import { NotificationManager } from '../utils/notification-manager';
|
||||
|
||||
class NotificationContainer extends Component {
|
||||
notifications = new NotificationManager(getOwner(this));
|
||||
|
||||
<template>
|
||||
<div class="notifications">
|
||||
{{#each this.notifications.notifications as |notif|}}
|
||||
<div class="notification notification-{{notif.type}}">
|
||||
{{notif.message}}
|
||||
<button {{on "click" (fn this.notifications.dismiss notif.id)}}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
{{! Example usage }}
|
||||
<button {{on "click" (fn this.notifications.add "Success!" "success")}}>
|
||||
Show Notification
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Registering Custom Services Dynamically
|
||||
|
||||
**Runtime service registration:**
|
||||
|
||||
```javascript
|
||||
// app/instance-initializers/dynamic-services.js
|
||||
export function initialize(appInstance) {
|
||||
// Register service dynamically without app/services file
|
||||
appInstance.register(
|
||||
'service:feature-flags',
|
||||
class FeatureFlagsService {
|
||||
flags = {
|
||||
newDashboard: true,
|
||||
betaFeatures: false,
|
||||
};
|
||||
|
||||
isEnabled(flag) {
|
||||
return this.flags[flag] || false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Make it a singleton
|
||||
appInstance.inject('route', 'featureFlags', 'service:feature-flags');
|
||||
appInstance.inject('component', 'featureFlags', 'service:feature-flags');
|
||||
}
|
||||
|
||||
export default {
|
||||
initialize,
|
||||
};
|
||||
```
|
||||
|
||||
**Using registered services:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/feature-gated.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
class FeatureGated extends Component {
|
||||
@service featureFlags;
|
||||
|
||||
get shouldShow() {
|
||||
return this.featureFlags.isEnabled(this.args.feature);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.shouldShow}}
|
||||
{{yield}}
|
||||
{{else}}
|
||||
<div class="feature-disabled">This feature is not available</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use @service decorator** for app/services - cleanest and most maintainable
|
||||
2. **Use link() from reactiveweb** for ownership and destruction linkage
|
||||
3. **Use createService from ember-primitives** for component-scoped services without extending Service class
|
||||
4. **Manual owner passing** for utilities that need occasional service access
|
||||
5. **Co-located services** for component-specific state that doesn't need global access
|
||||
6. **Runtime registration** for dynamic services or testing scenarios
|
||||
7. **Always use setOwner** when manually instantiating classes that need services
|
||||
|
||||
### When to Use Each Pattern
|
||||
|
||||
- **app/services**: Global singletons needed across the app
|
||||
- **link() from reactiveweb**: When you need both owner and destruction linkage
|
||||
- **createService from ember-primitives**: Component-scoped services without Service class
|
||||
- **Co-located services**: Component-specific state, not needed elsewhere
|
||||
- **Utils with owner**: Stateless utilities that occasionally need config/services
|
||||
- **Runtime registration**: Dynamic configuration, feature flags, testing
|
||||
|
||||
Reference: [Ember Owner API](https://api.emberjs.com/ember/release/functions/@ember%2Fapplication/getOwner), [Dependency Injection](https://guides.emberjs.com/release/applications/dependency-injection/), [reactiveweb link()](https://reactive.nullvoxpopuli.com/functions/link.link.html), [ember-primitives createService](https://ce1d7e18.ember-primitives.pages.dev/6-utils/createService.md)
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Use Services for Shared State
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Better state management and reusability
|
||||
tags: services, state-management, dependency-injection
|
||||
---
|
||||
|
||||
## Use Services for Shared State
|
||||
|
||||
Use services to manage shared state across components and routes instead of passing data through multiple layers or duplicating state.
|
||||
|
||||
**Incorrect (prop drilling):**
|
||||
|
||||
```glimmer-js
|
||||
// app/routes/dashboard.gjs
|
||||
export default class DashboardRoute extends Route {
|
||||
model() {
|
||||
return { currentTheme: 'dark' };
|
||||
}
|
||||
|
||||
<template>
|
||||
<Header @theme={{@model.currentTheme}} />
|
||||
<Sidebar @theme={{@model.currentTheme}} />
|
||||
<MainContent @theme={{@model.currentTheme}} />
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using service):**
|
||||
|
||||
```javascript
|
||||
// app/services/theme.js
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class ThemeService extends Service {
|
||||
@tracked currentTheme = 'dark';
|
||||
|
||||
@action
|
||||
setTheme(theme) {
|
||||
this.currentTheme = theme;
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
@action
|
||||
loadTheme() {
|
||||
this.currentTheme = localStorage.getItem('theme') || 'dark';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/components/header.js
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
class Header extends Component {
|
||||
@service theme;
|
||||
|
||||
// Access theme.currentTheme directly
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// app/components/sidebar.js
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
class Sidebar extends Component {
|
||||
@service theme;
|
||||
|
||||
// Access theme.currentTheme directly
|
||||
}
|
||||
```
|
||||
|
||||
Services provide centralized state management with automatic reactivity through tracked properties.
|
||||
|
||||
**For complex state, consider using Ember Data or ember-orbit:**
|
||||
|
||||
```javascript
|
||||
// app/services/cart.js
|
||||
import Service from '@ember/service';
|
||||
import { service } from '@ember/service';
|
||||
import { TrackedArray } from 'tracked-built-ins';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class CartService extends Service {
|
||||
@service store;
|
||||
|
||||
items = new TrackedArray([]);
|
||||
|
||||
@cached
|
||||
get total() {
|
||||
return this.items.reduce((sum, item) => sum + item.price, 0);
|
||||
}
|
||||
|
||||
@cached
|
||||
get itemCount() {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
@action
|
||||
addItem(item) {
|
||||
this.items.push(item);
|
||||
}
|
||||
|
||||
@action
|
||||
removeItem(item) {
|
||||
const index = this.items.indexOf(item);
|
||||
if (index > -1) {
|
||||
this.items.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Ember Services](https://guides.emberjs.com/release/services/)
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: Avoid Heavy Computation in Templates
|
||||
impact: MEDIUM
|
||||
impactDescription: 40-60% reduction in render time
|
||||
tags: templates, performance, getters, helpers
|
||||
---
|
||||
|
||||
## Avoid Heavy Computation in Templates
|
||||
|
||||
Move expensive computations from templates to cached getters in the component class or in-scope functions for template-only components. Templates should only display data, not compute it. Keep templates easy for humans to read by minimizing nested function invocations.
|
||||
|
||||
**Why this matters:**
|
||||
|
||||
- Templates should be easy to read and understand
|
||||
- Nested function calls create cognitive overhead
|
||||
- Computations should be cached and reused, not recalculated on every render
|
||||
- Template-only components (without `this`) need alternative patterns
|
||||
|
||||
**Incorrect (heavy computation in template):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/stats.gjs
|
||||
import { sum, map, div, max, multiply, sortBy } from '../helpers/math';
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p>Total: {{sum (map @items "price")}}</p>
|
||||
<p>Average: {{div (sum (map @items "price")) @items.length}}</p>
|
||||
<p>Max: {{max (map @items "price")}}</p>
|
||||
|
||||
{{#each (sortBy "name" @items) as |item|}}
|
||||
<div>{{item.name}}: {{multiply item.price item.quantity}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (computation in component with cached getters):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/stats.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
export class Stats extends Component {
|
||||
// @cached is useful when getters are accessed multiple times
|
||||
// For single access, regular getters are sufficient
|
||||
|
||||
@cached
|
||||
get total() {
|
||||
return this.args.items.reduce((sum, item) => sum + item.price, 0);
|
||||
}
|
||||
|
||||
get average() {
|
||||
// No @cached needed if only accessed once in template
|
||||
return this.args.items.length > 0 ? this.total / this.args.items.length : 0;
|
||||
}
|
||||
|
||||
get maxPrice() {
|
||||
return Math.max(...this.args.items.map((item) => item.price));
|
||||
}
|
||||
|
||||
@cached
|
||||
get sortedItems() {
|
||||
// @cached useful here as it's used by itemsWithTotal
|
||||
return [...this.args.items].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
@cached
|
||||
get itemsWithTotal() {
|
||||
// @cached useful as accessed multiple times in {{#each}}
|
||||
return this.sortedItems.map((item) => ({
|
||||
...item,
|
||||
total: item.price * item.quantity,
|
||||
}));
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<p>Total: {{this.total}}</p>
|
||||
<p>Average: {{this.average}}</p>
|
||||
<p>Max: {{this.maxPrice}}</p>
|
||||
|
||||
{{#each this.itemsWithTotal key="id" as |item|}}
|
||||
<div>{{item.name}}: {{item.total}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Note on @cached**: Use `@cached` when a getter is accessed multiple times (like in `{{#each}}` loops or by other getters). For getters accessed only once, regular getters are sufficient and avoid unnecessary memoization overhead.
|
||||
|
||||
Moving computations to getters ensures they run only when dependencies change, not on every render. Templates remain clean and readable.
|
||||
@@ -0,0 +1,280 @@
|
||||
---
|
||||
title: Optimize Conditional Rendering
|
||||
impact: HIGH
|
||||
impactDescription: Reduces unnecessary rerenders in dynamic template branches
|
||||
tags: templates, conditionals, rendering, performance, glimmer
|
||||
---
|
||||
|
||||
## Optimize Conditional Rendering
|
||||
|
||||
Use efficient conditional rendering patterns to minimize unnecessary DOM updates and improve rendering performance.
|
||||
|
||||
## Problem
|
||||
|
||||
Inefficient conditional logic causes excessive re-renders, creates complex template code, and can lead to poor performance in lists and dynamic UIs.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserList extends Component {
|
||||
<template>
|
||||
{{#each @users as |user|}}
|
||||
<div class="user">
|
||||
{{! Recomputes every time}}
|
||||
{{#if (eq user.role "admin")}}
|
||||
<span class="badge admin">{{user.name}} (Admin)</span>
|
||||
{{/if}}
|
||||
{{#if (eq user.role "moderator")}}
|
||||
<span class="badge mod">{{user.name}} (Mod)</span>
|
||||
{{/if}}
|
||||
{{#if (eq user.role "user")}}
|
||||
<span>{{user.name}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
Use `{{#if}}` / `{{#else if}}` / `{{#else}}` chains and extract computed logic to getters for better performance and readability.
|
||||
|
||||
**Correct:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserList extends Component {
|
||||
<template>
|
||||
{{#each @users as |user|}}
|
||||
<div class="user">
|
||||
{{#if (eq user.role "admin")}}
|
||||
<span class="badge admin">{{user.name}} (Admin)</span>
|
||||
{{else if (eq user.role "moderator")}}
|
||||
<span class="badge mod">{{user.name}} (Mod)</span>
|
||||
{{else}}
|
||||
<span>{{user.name}}</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Extracted Logic Pattern
|
||||
|
||||
For complex conditions, use getters:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
class UserCard extends Component {
|
||||
@cached
|
||||
get isActive() {
|
||||
return this.args.user.status === 'active' && this.args.user.lastLoginDays < 30;
|
||||
}
|
||||
|
||||
@cached
|
||||
get showActions() {
|
||||
return this.args.canEdit && !this.args.user.locked && this.isActive;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="user-card">
|
||||
<h3>{{@user.name}}</h3>
|
||||
|
||||
{{#if this.isActive}}
|
||||
<span class="status active">Active</span>
|
||||
{{else}}
|
||||
<span class="status inactive">Inactive</span>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.showActions}}
|
||||
<div class="actions">
|
||||
<button>Edit</button>
|
||||
<button>Delete</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Conditional Lists
|
||||
|
||||
Use `{{#if}}` to guard `{{#each}}` and avoid rendering empty states:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/task-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class TaskList extends Component {
|
||||
get hasTasks() {
|
||||
return this.args.tasks?.length > 0;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.hasTasks}}
|
||||
<ul class="task-list">
|
||||
{{#each @tasks as |task|}}
|
||||
<li>
|
||||
{{task.title}}
|
||||
{{#if task.completed}}
|
||||
<span class="done">✓</span>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty-state">No tasks yet</p>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Avoid Nested Conditionals
|
||||
|
||||
**Bad:**
|
||||
|
||||
```glimmer-js
|
||||
{{#if @user}}
|
||||
{{#if @user.isPremium}}
|
||||
{{#if @user.hasAccess}}
|
||||
<PremiumContent />
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
```
|
||||
|
||||
**Good:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/content-gate.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
class ContentGate extends Component {
|
||||
@cached
|
||||
get canViewPremium() {
|
||||
return this.args.user?.isPremium && this.args.user?.hasAccess;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.canViewPremium}}
|
||||
<PremiumContent />
|
||||
{{else}}
|
||||
<UpgradeCTA />
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Component Switching Pattern
|
||||
|
||||
Use conditional rendering for component selection:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/media-viewer.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import ImageViewer from './image-viewer';
|
||||
import VideoPlayer from './video-player';
|
||||
import AudioPlayer from './audio-player';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
class MediaViewer extends Component {
|
||||
@cached
|
||||
get mediaType() {
|
||||
return this.args.media?.type;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if (eq this.mediaType "image")}}
|
||||
<ImageViewer @src={{@media.url}} />
|
||||
{{else if (eq this.mediaType "video")}}
|
||||
<VideoPlayer @src={{@media.url}} />
|
||||
{{else if (eq this.mediaType "audio")}}
|
||||
<AudioPlayer @src={{@media.url}} />
|
||||
{{else}}
|
||||
<p>Unsupported media type</p>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Loading States
|
||||
|
||||
Pattern for async data with loading/error states:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-display.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { Resource } from 'ember-resources';
|
||||
import { resource } from 'ember-resources';
|
||||
|
||||
class DataResource extends Resource {
|
||||
@tracked data = null;
|
||||
@tracked isLoading = true;
|
||||
@tracked error = null;
|
||||
|
||||
modify(positional, named) {
|
||||
this.fetchData(named.url);
|
||||
}
|
||||
|
||||
async fetchData(url) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
this.data = await response.json();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DataDisplay extends Component {
|
||||
@resource data = DataResource.from(() => ({
|
||||
url: this.args.url,
|
||||
}));
|
||||
|
||||
<template>
|
||||
{{#if this.data.isLoading}}
|
||||
<div class="loading">Loading...</div>
|
||||
{{else if this.data.error}}
|
||||
<div class="error">Error: {{this.data.error}}</div>
|
||||
{{else}}
|
||||
<div class="content">
|
||||
{{this.data.data}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Chained if/else**: 40-60% faster than multiple independent {{#if}} blocks
|
||||
- **Extracted getters**: ~20% faster for complex conditions (cached)
|
||||
- **Component switching**: Same performance as {{#if}} but better code organization
|
||||
|
||||
## When to Use
|
||||
|
||||
- **{{#if}}/{{#else}}**: For simple true/false conditions
|
||||
- **Extracted getters**: For complex or reused conditions
|
||||
- **Component switching**: For different component types based on state
|
||||
- **Guard clauses**: To avoid rendering large subtrees when not needed
|
||||
|
||||
## References
|
||||
|
||||
- [Ember Guides - Conditionals](https://guides.emberjs.com/release/components/conditional-content/)
|
||||
- [Glimmer VM Performance](https://github.com/glimmerjs/glimmer-vm)
|
||||
- [@cached decorator](https://api.emberjs.com/ember/release/functions/@glimmer%2Ftracking/cached)
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: Use {{#each}} with @key for Lists
|
||||
impact: MEDIUM
|
||||
impactDescription: 50-100% faster list updates
|
||||
tags: templates, each, performance, rendering
|
||||
---
|
||||
|
||||
## Use {{#each}} with @key for Lists
|
||||
|
||||
Use the `key=` parameter with `{{#each}}` when objects are recreated between renders (e.g., via `.map()` or fresh API data). The default behavior uses object identity (`@identity`), which works when object references are stable.
|
||||
|
||||
**Incorrect (no key):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-list.gjs
|
||||
import UserCard from './user-card';
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
{{#each this.users as |user|}}
|
||||
<li>
|
||||
<UserCard @user={{user}} />
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (with key):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-list.gjs
|
||||
import UserCard from './user-card';
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
{{#each this.users key="id" as |user|}}
|
||||
<li>
|
||||
<UserCard @user={{user}} />
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>
|
||||
```
|
||||
|
||||
**For arrays of primitives (strings, numbers):**
|
||||
|
||||
`@identity` is the default, so you rarely need to specify it explicitly. It compares items by value for primitives.
|
||||
|
||||
```glimmer-js
|
||||
// app/components/tag-list.gjs
|
||||
<template>
|
||||
{{! @identity is implicit, no need to write it }}
|
||||
{{#each this.tags as |tag|}}
|
||||
<span class="tag">{{tag}}</span>
|
||||
{{/each}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**For complex scenarios with @index:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/item-list.gjs
|
||||
<template>
|
||||
{{#each this.items key="@index" as |item index|}}
|
||||
<div data-index={{index}}>
|
||||
{{item.name}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</template>
|
||||
```
|
||||
|
||||
Using proper keys allows Ember's rendering engine to efficiently update, reorder, and remove items without re-rendering the entire list.
|
||||
|
||||
**When to use `key=`:**
|
||||
|
||||
- Objects recreated between renders (`.map()`, generators, fresh API responses) → use `key="id"` or similar
|
||||
- High-frequency updates (animations, real-time data) → always specify a key
|
||||
- Stable object references (Apollo cache, Ember Data) → default `@identity` is fine
|
||||
- Items never reorder → `key="@index"` is acceptable
|
||||
|
||||
**Performance comparison (dbmon benchmark, 40 rows at 60fps):**
|
||||
|
||||
- Without key (objects recreated): Destroys/recreates DOM every frame
|
||||
- With `key="data.db.id"`: DOM reuse, **2x FPS improvement**
|
||||
|
||||
### References:
|
||||
|
||||
- [Ember API: each helper](https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/each)
|
||||
- [Ember template lint: equire-each-key](https://github.com/ember-template-lint/ember-template-lint/blob/main/docs/rule/require-each-key.md)
|
||||
- [Example PR showing the fps improvement on updated lists](https://github.com/universal-ember/table/pull/68)
|
||||
148
.agents/skills/ember-best-practices/rules/template-fn-helper.md
Normal file
148
.agents/skills/ember-best-practices/rules/template-fn-helper.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Use {{fn}} for Partial Application Only
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Clearer code, avoid unnecessary wrapping
|
||||
tags: helpers, templates, fn, partial-application
|
||||
---
|
||||
|
||||
## Use {{fn}} for Partial Application Only
|
||||
|
||||
The `{{fn}}` helper is used for partial application (binding arguments), similar to JavaScript's `.bind()`. Only use it when you need to pre-bind arguments to a function. Don't use it to simply pass a function reference.
|
||||
|
||||
**Incorrect (unnecessary use of {{fn}}):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/search.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class Search extends Component {
|
||||
@action
|
||||
handleSearch(event) {
|
||||
console.log('Searching:', event.target.value);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! Wrong - no arguments being bound}}
|
||||
<input {{on "input" (fn this.handleSearch)}} />
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (direct function reference):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/search.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class Search extends Component {
|
||||
@action
|
||||
handleSearch(event) {
|
||||
console.log('Searching:', event.target.value);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{! Correct - pass function directly}}
|
||||
<input {{on "input" this.handleSearch}} />
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**When to Use {{fn}} - Partial Application:**
|
||||
|
||||
Use `{{fn}}` when you need to pre-bind arguments to a function, similar to JavaScript's `.bind()`:
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-list.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class UserList extends Component {
|
||||
@action
|
||||
deleteUser(userId, event) {
|
||||
console.log('Deleting user:', userId);
|
||||
this.args.onDelete(userId);
|
||||
}
|
||||
|
||||
<template>
|
||||
<ul>
|
||||
{{#each @users as |user|}}
|
||||
<li>
|
||||
{{user.name}}
|
||||
{{! Correct - binding user.id as first argument}}
|
||||
<button {{on "click" (fn this.deleteUser user.id)}}>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Multiple Arguments:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-grid.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
class DataGrid extends Component {
|
||||
@action
|
||||
updateCell(rowId, columnKey, event) {
|
||||
const newValue = event.target.value;
|
||||
this.args.onUpdate(rowId, columnKey, newValue);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#each @rows as |row|}}
|
||||
{{#each @columns as |column|}}
|
||||
<input
|
||||
value={{get row column.key}}
|
||||
{{! Pre-binding rowId and columnKey}}
|
||||
{{on "input" (fn this.updateCell row.id column.key)}}
|
||||
/>
|
||||
{{/each}}
|
||||
{{/each}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Think of {{fn}} like .bind():**
|
||||
|
||||
```javascript
|
||||
// JavaScript comparison
|
||||
const boundFn = this.deleteUser.bind(this, userId); // .bind() pre-binds args
|
||||
// Template equivalent: {{fn this.deleteUser userId}}
|
||||
|
||||
// Direct reference
|
||||
const directFn = this.handleSearch; // No pre-binding
|
||||
// Template equivalent: {{this.handleSearch}}
|
||||
```
|
||||
|
||||
**Common Patterns:**
|
||||
|
||||
```javascript
|
||||
// ❌ Wrong - no partial application
|
||||
<button {{on "click" (fn this.save)}}>Save</button>
|
||||
|
||||
// ✅ Correct - direct reference
|
||||
<button {{on "click" this.save}}>Save</button>
|
||||
|
||||
// ✅ Correct - partial application with argument
|
||||
<button {{on "click" (fn this.save "draft")}}>Save Draft</button>
|
||||
|
||||
// ❌ Wrong - no partial application
|
||||
<input {{on "input" (fn this.handleInput)}} />
|
||||
|
||||
// ✅ Correct - direct reference
|
||||
<input {{on "input" this.handleInput}} />
|
||||
|
||||
// ✅ Correct - partial application with field name
|
||||
<input {{on "input" (fn this.updateField "email")}} />
|
||||
```
|
||||
|
||||
Only use `{{fn}}` when you're binding arguments. For simple function references, pass them directly.
|
||||
|
||||
Reference: [Ember Templates - fn Helper](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_passing-arguments-to-functions)
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: Import Helpers Directly in Templates
|
||||
impact: MEDIUM
|
||||
impactDescription: Better tree-shaking and clarity
|
||||
tags: helpers, imports, templates, gjs
|
||||
---
|
||||
|
||||
## Import Helpers Directly in Templates
|
||||
|
||||
Import helpers directly in gjs/gts files for better tree-shaking, clearer dependencies, and improved type safety.
|
||||
|
||||
**Incorrect (global helper resolution):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
<template>
|
||||
<div class="profile">
|
||||
<h1>{{capitalize @user.name}}</h1>
|
||||
<p>Joined: {{format-date @user.createdAt}}</p>
|
||||
<p>Posts: {{pluralize @user.postCount "post"}}</p>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (explicit helper imports):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import { capitalize } from 'ember-string-helpers';
|
||||
import { formatDate } from 'ember-intl';
|
||||
import { pluralize } from 'ember-inflector';
|
||||
|
||||
<template>
|
||||
<div class="profile">
|
||||
<h1>{{capitalize @user.name}}</h1>
|
||||
<p>Joined: {{formatDate @user.createdAt}}</p>
|
||||
<p>Posts: {{pluralize @user.postCount "post"}}</p>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Built-in and library helpers:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/conditional-content.gjs
|
||||
import { fn, hash } from '@ember/helper'; // Actually built-in to Ember
|
||||
import { eq, not } from 'ember-truth-helpers'; // From ember-truth-helpers addon
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
{{#if (eq @status "active")}}
|
||||
<span class="badge">Active</span>
|
||||
{{/if}}
|
||||
|
||||
{{#if (not @isLoading)}}
|
||||
<button {{on "click" (fn @onSave (hash id=@id data=@data))}}>
|
||||
Save
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Custom helper with imports:**
|
||||
|
||||
```javascript
|
||||
// app/utils/format-currency.js
|
||||
export function formatCurrency(amount, { currency = 'USD' } = {}) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// app/components/price-display.gjs
|
||||
import { formatCurrency } from '../utils/format-currency';
|
||||
|
||||
<template>
|
||||
<div class="price">
|
||||
{{formatCurrency @amount currency="EUR"}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Type-safe helpers with TypeScript:**
|
||||
|
||||
```glimmer-ts
|
||||
// app/components/typed-component.gts
|
||||
import { fn } from '@ember/helper';
|
||||
import type { TOC } from '@ember/component/template-only';
|
||||
|
||||
interface Signature {
|
||||
Args: {
|
||||
items: Array<{ id: string; name: string }>;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const TypedComponent: TOC<Signature> = <template>
|
||||
<ul>
|
||||
{{#each @items as |item|}}
|
||||
<li {{on "click" (fn @onSelect item.id)}}>
|
||||
{{item.name}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>;
|
||||
|
||||
export default TypedComponent;
|
||||
```
|
||||
|
||||
Explicit helper imports enable better tree-shaking, make dependencies clear, and improve IDE support with proper type checking.
|
||||
|
||||
Reference: [Template Imports](https://github.com/ember-template-imports/ember-template-imports)
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Use {{#let}} to Avoid Recomputation
|
||||
impact: MEDIUM
|
||||
impactDescription: 30-50% reduction in duplicate work
|
||||
tags: templates, helpers, performance, optimization
|
||||
---
|
||||
|
||||
## Use {{#let}} to Avoid Recomputation
|
||||
|
||||
Use `{{#let}}` to compute expensive values once and reuse them in the template instead of calling getters or helpers multiple times.
|
||||
|
||||
**Incorrect (recomputes on every reference):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
<template>
|
||||
<div class="user-card">
|
||||
{{#if (and this.user.isActive (not this.user.isDeleted))}}
|
||||
<h3>{{this.user.fullName}}</h3>
|
||||
<p>Status: Active</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and this.user.isActive (not this.user.isDeleted))}}
|
||||
<button {{on "click" this.editUser}}>Edit</button>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and this.user.isActive (not this.user.isDeleted))}}
|
||||
<button {{on "click" this.deleteUser}}>Delete</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Correct (compute once, reuse):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-card.gjs
|
||||
<template>
|
||||
{{#let (and this.user.isActive (not this.user.isDeleted)) as |isEditable|}}
|
||||
<div class="user-card">
|
||||
{{#if isEditable}}
|
||||
<h3>{{this.user.fullName}}</h3>
|
||||
<p>Status: Active</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if isEditable}}
|
||||
<button {{on "click" this.editUser}}>Edit</button>
|
||||
{{/if}}
|
||||
|
||||
{{#if isEditable}}
|
||||
<button {{on "click" this.deleteUser}}>Delete</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/let}}
|
||||
</template>
|
||||
```
|
||||
|
||||
**Multiple values:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/checkout.gjs
|
||||
<template>
|
||||
{{#let
|
||||
(this.calculateTotal this.items) (this.formatCurrency this.total) (this.hasDiscount this.user)
|
||||
as |total formattedTotal showDiscount|
|
||||
}}
|
||||
<div class="checkout">
|
||||
<p>Total: {{formattedTotal}}</p>
|
||||
|
||||
{{#if showDiscount}}
|
||||
<p>Original: {{total}}</p>
|
||||
<p>Discount Applied!</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/let}}
|
||||
</template>
|
||||
```
|
||||
|
||||
`{{#let}}` computes values once and caches them for the block scope, reducing redundant calculations.
|
||||
@@ -0,0 +1,220 @@
|
||||
---
|
||||
title: Template-Only Components with In-Scope Functions
|
||||
impact: MEDIUM
|
||||
impactDescription: Clean, performant patterns for template-only components
|
||||
tags: templates, components, functions, performance
|
||||
---
|
||||
|
||||
## Template-Only Components with In-Scope Functions
|
||||
|
||||
For template-only components (components without a class and `this`), use in-scope functions to keep logic close to the template while avoiding unnecessary caching overhead.
|
||||
|
||||
**Incorrect (using class-based component for simple logic):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/product-card.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
export class ProductCard extends Component {
|
||||
// Unnecessary class and overhead for simple formatting
|
||||
formatPrice(price) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="product-card">
|
||||
<h3>{{@product.name}}</h3>
|
||||
<div class="price">{{this.formatPrice @product.price}}</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (template-only component with in-scope functions):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/product-card.gjs
|
||||
function formatPrice(price) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(price);
|
||||
}
|
||||
|
||||
function calculateDiscount(price, discountPercent) {
|
||||
return price * (1 - discountPercent / 100);
|
||||
}
|
||||
|
||||
function isOnSale(product) {
|
||||
return product.discountPercent > 0;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="product-card">
|
||||
<h3>{{@product.name}}</h3>
|
||||
|
||||
{{#if (isOnSale @product)}}
|
||||
<div class="price">
|
||||
<span class="original">{{formatPrice @product.price}}</span>
|
||||
<span class="sale">
|
||||
{{formatPrice (calculateDiscount @product.price @product.discountPercent)}}
|
||||
</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="price">{{formatPrice @product.price}}</div>
|
||||
{{/if}}
|
||||
|
||||
<p>{{@product.description}}</p>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**When to use class-based vs template-only:**
|
||||
|
||||
```glimmer-js
|
||||
// Use class-based when:
|
||||
// - You need @cached for expensive computations accessed multiple times
|
||||
// - You have tracked state
|
||||
// - You need lifecycle hooks or services
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { cached } from '@glimmer/tracking';
|
||||
|
||||
export class ProductList extends Component {
|
||||
@cached
|
||||
get sortedProducts() {
|
||||
// Expensive sort, accessed in template multiple times
|
||||
return [...this.args.products].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
@cached
|
||||
get filteredProducts() {
|
||||
// Depends on sortedProducts - benefits from caching
|
||||
return this.sortedProducts.filter((p) => p.category === this.args.selectedCategory);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#each this.filteredProducts as |product|}}
|
||||
<div>{{product.name}}</div>
|
||||
{{/each}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Use template-only when:
|
||||
// - Simple transformations
|
||||
// - Functions accessed once
|
||||
// - No state or services needed
|
||||
|
||||
function formatDate(date) {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="timestamp">
|
||||
Last updated:
|
||||
{{formatDate @lastUpdate}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Combining in-scope functions for readability:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-badge.gjs
|
||||
function getInitials(name) {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function getBadgeColor(status) {
|
||||
const colors = {
|
||||
active: 'green',
|
||||
pending: 'yellow',
|
||||
inactive: 'gray',
|
||||
};
|
||||
return colors[status] || 'gray';
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="user-badge" style="background-color: {{getBadgeColor @user.status}}">
|
||||
<span class="initials">{{getInitials @user.name}}</span>
|
||||
<span class="name">{{@user.name}}</span>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Anti-pattern - Complex nested calls:**
|
||||
|
||||
```glimmer-js
|
||||
// ❌ Hard to read, lots of nesting
|
||||
<template>
|
||||
<div>
|
||||
{{formatCurrency (multiply (add @basePrice @taxAmount) @quantity)}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
// ✅ Better - use intermediate function
|
||||
function calculateTotal(basePrice, taxAmount, quantity) {
|
||||
return (basePrice + taxAmount) * quantity;
|
||||
}
|
||||
|
||||
function formatCurrency(amount) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div>
|
||||
{{formatCurrency (calculateTotal @basePrice @taxAmount @quantity)}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Key differences from class-based components:**
|
||||
|
||||
| Aspect | Template-Only | Class-Based |
|
||||
| ---------------- | ------------------------ | ------------------------ |
|
||||
| `this` context | ❌ No `this` | ✅ Has `this` |
|
||||
| Function caching | ❌ Recreated each render | ✅ `@cached` available |
|
||||
| Services | ❌ Cannot inject | ✅ `@service` decorator |
|
||||
| Tracked state | ❌ No instance state | ✅ `@tracked` properties |
|
||||
| Best for | Simple, stateless | Complex, stateful |
|
||||
|
||||
**Best practices:**
|
||||
|
||||
1. **Keep functions simple** - If computation is complex, consider a class with `@cached`
|
||||
2. **One responsibility per function** - Makes them reusable and testable
|
||||
3. **Minimize nesting** - Use intermediate functions for readability
|
||||
4. **No side effects** - Functions should be pure transformations
|
||||
5. **Export for testing** - Export functions so they can be tested independently
|
||||
|
||||
```glimmer-js
|
||||
// app/components/stats-display.gjs
|
||||
export function average(numbers) {
|
||||
if (numbers.length === 0) return 0;
|
||||
return numbers.reduce((sum, n) => sum + n, 0) / numbers.length;
|
||||
}
|
||||
|
||||
export function round(number, decimals = 2) {
|
||||
return Math.round(number * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="stats">
|
||||
Average:
|
||||
{{round (average @scores)}}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Reference: [Template-only Components](https://guides.emberjs.com/release/components/component-types/), [Component Authoring Best Practices](https://guides.emberjs.com/release/components/conditional-content/)
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
title: Provide DOM-Abstracted Test Utilities for Library Components
|
||||
impact: MEDIUM
|
||||
impactDescription: Stabilizes consumer tests against internal DOM refactors
|
||||
tags: testing, test-support, libraries, dom-abstraction, maintainability
|
||||
---
|
||||
|
||||
## Provide DOM-Abstracted Test Utilities for Library Components
|
||||
|
||||
**Impact: Medium** - Critical for library maintainability and consumer testing experience, especially important for team-based projects
|
||||
|
||||
## Problem
|
||||
|
||||
When building reusable components or libraries, consumers should not need to know implementation details or interact directly with the component's DOM. DOM structure should be considered **private** unless the author of the tests is the **owner** of the code being tested.
|
||||
|
||||
Without abstracted test utilities:
|
||||
|
||||
- Component refactoring breaks consumer tests
|
||||
- Tests are tightly coupled to implementation details
|
||||
- Teams waste time updating tests when internals change
|
||||
- Testing becomes fragile and maintenance-heavy
|
||||
|
||||
## Solution
|
||||
|
||||
**Library authors should provide test utilities that fully abstract the DOM.** These utilities expose a public API for testing that remains stable even when internal implementation changes.
|
||||
|
||||
**Incorrect (exposing DOM to consumers):**
|
||||
|
||||
```glimmer-js
|
||||
// my-library/src/components/data-grid.gjs
|
||||
export class DataGrid extends Component {
|
||||
<template>
|
||||
<div class="data-grid">
|
||||
<div class="data-grid__header">
|
||||
<button class="sort-button" data-column="name">Name</button>
|
||||
</div>
|
||||
<div class="data-grid__body">
|
||||
{{#each @rows as |row|}}
|
||||
<div class="data-grid__row">{{row.name}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Consumer's test - tightly coupled to DOM
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { DataGrid } from 'my-library';
|
||||
|
||||
test('sorting works', async function (assert) {
|
||||
await render(<template><DataGrid @rows={{this.rows}} /></template>);
|
||||
|
||||
// Fragile: breaks if class names or structure change
|
||||
await click('.data-grid__header .sort-button[data-column="name"]');
|
||||
|
||||
assert.dom('.data-grid__row:first-child').hasText('Alice');
|
||||
});
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- Consumer knows about `.data-grid__header`, `.sort-button`, `[data-column]`
|
||||
- Refactoring component structure breaks consumer tests
|
||||
- No clear public API for testing
|
||||
|
||||
**Correct (providing DOM-abstracted test utilities):**
|
||||
|
||||
```glimmer-js
|
||||
// my-library/src/test-support/data-grid.js
|
||||
import { click, findAll } from '@ember/test-helpers';
|
||||
|
||||
/**
|
||||
* Test utility for DataGrid component
|
||||
* Provides stable API regardless of internal DOM structure
|
||||
*/
|
||||
export class DataGridTestHelper {
|
||||
constructor(containerElement) {
|
||||
this.container = containerElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by column name
|
||||
* @param {string} columnName - Column to sort by
|
||||
*/
|
||||
async sortBy(columnName) {
|
||||
// Implementation detail hidden from consumer
|
||||
const button = this.container.querySelector(`[data-test-sort="${columnName}"]`);
|
||||
if (!button) {
|
||||
throw new Error(`Column "${columnName}" not found`);
|
||||
}
|
||||
await click(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all row data
|
||||
* @returns {Array<string>} Row text content
|
||||
*/
|
||||
getRows() {
|
||||
return findAll('[data-test-row]', this.container).map((el) => el.textContent.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get row by index
|
||||
* @param {number} index - Zero-based row index
|
||||
* @returns {string} Row text content
|
||||
*/
|
||||
getRow(index) {
|
||||
const rows = this.getRows();
|
||||
return rows[index];
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function for easier usage
|
||||
export function getDataGrid(container = document) {
|
||||
const gridElement = container.querySelector('[data-test-data-grid]');
|
||||
if (!gridElement) {
|
||||
throw new Error('DataGrid component not found');
|
||||
}
|
||||
return new DataGridTestHelper(gridElement);
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// my-library/src/components/data-grid.gjs
|
||||
// Component updated with test hooks (data-test-*)
|
||||
export class DataGrid extends Component {
|
||||
<template>
|
||||
<div data-test-data-grid class="data-grid">
|
||||
<div class="data-grid__header">
|
||||
{{#each @columns as |column|}}
|
||||
<button data-test-sort={{column.name}}>
|
||||
{{column.label}}
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="data-grid__body">
|
||||
{{#each @rows as |row|}}
|
||||
<div data-test-row class="data-grid__row">{{row.name}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// Consumer's test - abstracted from DOM
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { DataGrid } from 'my-library';
|
||||
import { getDataGrid } from 'my-library/test-support';
|
||||
|
||||
test('sorting works', async function (assert) {
|
||||
await render(<template><DataGrid @rows={{this.rows}} @columns={{this.columns}} /></template>);
|
||||
|
||||
const grid = getDataGrid();
|
||||
|
||||
// Clean API: no DOM knowledge required
|
||||
await grid.sortBy('name');
|
||||
|
||||
assert.strictEqual(grid.getRow(0), 'Alice');
|
||||
assert.deepEqual(grid.getRows(), ['Alice', 'Bob', 'Charlie']);
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Component internals can change without breaking consumer tests
|
||||
- Clear, documented testing API
|
||||
- Consumer tests are declarative and readable
|
||||
- Library maintains API stability contract
|
||||
|
||||
## When This Matters Most
|
||||
|
||||
### Team-Based Projects (Critical)
|
||||
|
||||
On projects with teams, DOM abstraction prevents:
|
||||
|
||||
- Merge conflicts from test changes
|
||||
- Cross-team coordination overhead
|
||||
- Broken tests from uncoordinated refactoring
|
||||
- Knowledge silos about component internals
|
||||
|
||||
### Solo Projects (Less Critical)
|
||||
|
||||
For solo projects, the benefit is smaller but still valuable:
|
||||
|
||||
- Easier refactoring without test maintenance
|
||||
- Better separation of concerns
|
||||
- Professional API design practice
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use `data-test-*` Attributes
|
||||
|
||||
```glimmer-js
|
||||
// Stable test hooks that won't conflict with styling
|
||||
<button data-test-submit>Submit</button>
|
||||
<div data-test-error-message>{{@errorMessage}}</div>
|
||||
```
|
||||
|
||||
### 2. Document the Test API
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* @class FormTestHelper
|
||||
* @description Test utility for Form component
|
||||
*
|
||||
* @example
|
||||
* const form = getForm();
|
||||
* await form.fillIn('email', 'user@example.com');
|
||||
* await form.submit();
|
||||
* assert.strictEqual(form.getError(), 'Invalid email');
|
||||
*/
|
||||
```
|
||||
|
||||
### 3. Provide Semantic Methods
|
||||
|
||||
```javascript
|
||||
// ✅ Semantic and declarative
|
||||
await modal.close();
|
||||
await form.fillIn('email', 'test@example.com');
|
||||
assert.true(dropdown.isOpen());
|
||||
|
||||
// ❌ Exposes implementation
|
||||
await click('.modal-close-button');
|
||||
await fillIn('.form-field[name="email"]', 'test@example.com');
|
||||
assert.dom('.dropdown.is-open').exists();
|
||||
```
|
||||
|
||||
### 4. Handle Edge Cases
|
||||
|
||||
```javascript
|
||||
export class FormTestHelper {
|
||||
async fillIn(fieldName, value) {
|
||||
const field = this.container.querySelector(`[data-test-field="${fieldName}"]`);
|
||||
if (!field) {
|
||||
throw new Error(
|
||||
`Field "${fieldName}" not found. Available fields: ${this.getFieldNames().join(', ')}`,
|
||||
);
|
||||
}
|
||||
await fillIn(field, value);
|
||||
}
|
||||
|
||||
getFieldNames() {
|
||||
return Array.from(this.container.querySelectorAll('[data-test-field]')).map(
|
||||
(el) => el.dataset.testField,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Complete Test Utility
|
||||
|
||||
```javascript
|
||||
// addon/test-support/modal.js
|
||||
import { click, find, waitUntil } from '@ember/test-helpers';
|
||||
|
||||
export class ModalTestHelper {
|
||||
constructor(container = document) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
get element() {
|
||||
return find('[data-test-modal]', this.container);
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return this.element !== null;
|
||||
}
|
||||
|
||||
async waitForOpen() {
|
||||
await waitUntil(() => this.isOpen(), { timeout: 1000 });
|
||||
}
|
||||
|
||||
async waitForClose() {
|
||||
await waitUntil(() => !this.isOpen(), { timeout: 1000 });
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
const titleEl = find('[data-test-modal-title]', this.element);
|
||||
return titleEl ? titleEl.textContent.trim() : null;
|
||||
}
|
||||
|
||||
getBody() {
|
||||
const bodyEl = find('[data-test-modal-body]', this.element);
|
||||
return bodyEl ? bodyEl.textContent.trim() : null;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.isOpen()) {
|
||||
throw new Error('Cannot close modal: modal is not open');
|
||||
}
|
||||
await click('[data-test-modal-close]', this.element);
|
||||
}
|
||||
|
||||
async clickButton(buttonText) {
|
||||
const buttons = findAll('[data-test-modal-button]', this.element);
|
||||
const button = buttons.find((btn) => btn.textContent.trim() === buttonText);
|
||||
if (!button) {
|
||||
const available = buttons.map((b) => b.textContent.trim()).join(', ');
|
||||
throw new Error(`Button "${buttonText}" not found. Available: ${available}`);
|
||||
}
|
||||
await click(button);
|
||||
}
|
||||
}
|
||||
|
||||
export function getModal(container) {
|
||||
return new ModalTestHelper(container);
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Impact
|
||||
|
||||
**Before:** ~30-50% of test maintenance time spent updating selectors
|
||||
**After:** Minimal test maintenance when refactoring components
|
||||
|
||||
## Related Patterns
|
||||
|
||||
- **component-avoid-classes-in-examples.md** - Avoid exposing implementation details
|
||||
- **testing-modern-patterns.md** - Modern testing approaches
|
||||
- **testing-render-patterns.md** - Component testing patterns
|
||||
|
||||
## References
|
||||
|
||||
- [Testing Best Practices - ember-learn](https://guides.emberjs.com/release/testing/)
|
||||
- [ember-test-selectors](https://github.com/mainmatter/ember-test-selectors) - Addon for stripping test selectors from production
|
||||
- [Page Objects Pattern](https://martinfowler.com/bliki/PageObject.html) - Related testing abstraction pattern
|
||||
@@ -0,0 +1,340 @@
|
||||
---
|
||||
title: Use Modern Testing Patterns
|
||||
impact: HIGH
|
||||
impactDescription: Better test coverage and maintainability
|
||||
tags: testing, qunit, test-helpers, integration-tests
|
||||
---
|
||||
|
||||
## Use Modern Testing Patterns
|
||||
|
||||
Use modern Ember testing patterns with `@ember/test-helpers` and `qunit-dom` for better test coverage and maintainability.
|
||||
|
||||
**Incorrect (old testing patterns):**
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/user-card-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, find, click } from '@ember/test-helpers';
|
||||
import UserCard from 'my-app/components/user-card';
|
||||
|
||||
module('Integration | Component | user-card', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await render(<template><UserCard /></template>);
|
||||
|
||||
// Using find() instead of qunit-dom
|
||||
assert.ok(find('.user-card'));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (modern testing patterns):**
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/user-card-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { setupIntl } from 'ember-intl/test-support';
|
||||
import UserCard from 'my-app/components/user-card';
|
||||
|
||||
module('Integration | Component | user-card', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupIntl(hooks);
|
||||
|
||||
test('it renders user information', async function (assert) {
|
||||
const user = {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
avatarUrl: '/avatar.jpg',
|
||||
};
|
||||
|
||||
await render(<template><UserCard @user={{user}} /></template>);
|
||||
|
||||
// qunit-dom assertions
|
||||
assert.dom('[data-test-user-name]').hasText('John Doe');
|
||||
assert.dom('[data-test-user-email]').hasText('john@example.com');
|
||||
assert
|
||||
.dom('[data-test-user-avatar]')
|
||||
.hasAttribute('src', '/avatar.jpg')
|
||||
.hasAttribute('alt', 'John Doe');
|
||||
});
|
||||
|
||||
test('it handles edit action', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const user = { name: 'John Doe', email: 'john@example.com' };
|
||||
const handleEdit = (editedUser) => {
|
||||
assert.deepEqual(editedUser, user, 'Edit handler called with user');
|
||||
};
|
||||
|
||||
await render(<template><UserCard @user={{user}} @onEdit={{handleEdit}} /></template>);
|
||||
|
||||
await click('[data-test-edit-button]');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Component testing with reactive state:**
|
||||
|
||||
```glimmer-ts
|
||||
// tests/integration/components/search-box-test.ts
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, fillIn } from '@ember/test-helpers';
|
||||
import { trackedObject } from '@ember/reactive/collections';
|
||||
import SearchBox from 'my-app/components/search-box';
|
||||
|
||||
module('Integration | Component | search-box', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it performs search', async function (assert) {
|
||||
// Use trackedObject for reactive state in tests
|
||||
const state = trackedObject({
|
||||
results: [] as string[],
|
||||
});
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
state.results = [`Result for ${query}`];
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<SearchBox @onSearch={{handleSearch}} />
|
||||
<ul data-test-results>
|
||||
{{#each state.results as |result|}}
|
||||
<li>{{result}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</template>,
|
||||
);
|
||||
|
||||
await fillIn('[data-test-search-input]', 'ember');
|
||||
|
||||
// State updates reactively - no waitFor needed when using test-waiters
|
||||
assert.dom('[data-test-results] li').hasText('Result for ember');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Testing with ember-concurrency tasks:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/async-button.js
|
||||
import Component from '@glimmer/component';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
export default class AsyncButtonComponent extends Component {
|
||||
@task
|
||||
*saveTask() {
|
||||
yield this.args.onSave();
|
||||
}
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
disabled={{this.saveTask.isRunning}}
|
||||
{{on "click" (perform this.saveTask)}}
|
||||
data-test-button
|
||||
>
|
||||
{{#if this.saveTask.isRunning}}
|
||||
<span data-test-loading-spinner>Saving...</span>
|
||||
{{else}}
|
||||
{{yield}}
|
||||
{{/if}}
|
||||
</button>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/async-button-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import AsyncButton from 'my-app/components/async-button';
|
||||
|
||||
module('Integration | Component | async-button', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it shows loading state during task execution', async function (assert) {
|
||||
let resolveTask;
|
||||
const onSave = () => {
|
||||
return new Promise((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<AsyncButton @onSave={{onSave}}>
|
||||
Save
|
||||
</AsyncButton>
|
||||
</template>,
|
||||
);
|
||||
|
||||
// Trigger the task
|
||||
await click('[data-test-button]');
|
||||
|
||||
// ember-concurrency automatically registers test waiters
|
||||
// The button will be disabled while the task runs
|
||||
assert.dom('[data-test-button]').hasAttribute('disabled');
|
||||
assert.dom('[data-test-loading-spinner]').hasText('Saving...');
|
||||
|
||||
// Resolve the task
|
||||
resolveTask();
|
||||
// No need to call settled() - ember-concurrency's test waiters handle this
|
||||
|
||||
assert.dom('[data-test-button]').doesNotHaveAttribute('disabled');
|
||||
assert.dom('[data-test-loading-spinner]').doesNotExist();
|
||||
assert.dom('[data-test-button]').hasText('Save');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**When to use test-waiters with ember-concurrency:**
|
||||
|
||||
- **ember-concurrency auto-registers test waiters** - You don't need to manually register test waiters for ember-concurrency tasks. The library automatically waits for tasks to complete before test helpers like `click()`, `fillIn()`, etc. resolve.
|
||||
|
||||
- **You still need test-waiters when:**
|
||||
- Using raw Promises outside of ember-concurrency tasks
|
||||
- Working with third-party async operations that don't integrate with Ember's test waiter system
|
||||
- Creating custom async behavior that needs to pause test execution
|
||||
|
||||
- **You DON'T need additional test-waiters when:**
|
||||
- Using ember-concurrency tasks (already handled)
|
||||
- Using Ember Data operations (already handled)
|
||||
- Using `settled()` from `@ember/test-helpers` (already coordinates with test waiters)
|
||||
- **Note**: `waitFor()` and `waitUntil()` from `@ember/test-helpers` are code smells - if you need them, it indicates missing test-waiters in your code. Instrument your async operations with test-waiters instead.
|
||||
|
||||
**Route testing with MSW (Mock Service Worker):**
|
||||
|
||||
```javascript
|
||||
// tests/acceptance/posts-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setupMSW } from 'my-app/tests/helpers/msw';
|
||||
|
||||
module('Acceptance | posts', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
const { server } = setupMSW(hooks);
|
||||
|
||||
test('visiting /posts', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/posts', () => {
|
||||
return HttpResponse.json({
|
||||
data: [
|
||||
{ id: '1', type: 'post', attributes: { title: 'Post 1' } },
|
||||
{ id: '2', type: 'post', attributes: { title: 'Post 2' } },
|
||||
{ id: '3', type: 'post', attributes: { title: 'Post 3' } },
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await visit('/posts');
|
||||
|
||||
assert.strictEqual(currentURL(), '/posts');
|
||||
assert.dom('[data-test-post-item]').exists({ count: 3 });
|
||||
});
|
||||
|
||||
test('clicking a post navigates to detail', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/posts', () => {
|
||||
return HttpResponse.json({
|
||||
data: [{ id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } }],
|
||||
});
|
||||
}),
|
||||
http.get('/api/posts/test-post', () => {
|
||||
return HttpResponse.json({
|
||||
data: { id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await visit('/posts');
|
||||
await click('[data-test-post-item]:first-child');
|
||||
|
||||
assert.strictEqual(currentURL(), '/posts/test-post');
|
||||
assert.dom('[data-test-post-title]').hasText('Test Post');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** Use MSW (Mock Service Worker) for API mocking instead of Mirage. MSW provides better conventions and doesn't lead developers astray. See `testing-msw-setup.md` for detailed setup instructions.
|
||||
|
||||
**Accessibility testing:**
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/modal-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import a11yAudit from 'ember-a11y-testing/test-support/audit';
|
||||
import Modal from 'my-app/components/modal';
|
||||
|
||||
module('Integration | Component | modal', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it passes accessibility audit', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<Modal @isOpen={{true}} @title="Test Modal">
|
||||
<p>Modal content</p>
|
||||
</Modal>
|
||||
</template>,
|
||||
);
|
||||
|
||||
await a11yAudit();
|
||||
assert.ok(true, 'no a11y violations');
|
||||
});
|
||||
|
||||
test('it traps focus', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<Modal @isOpen={{true}}>
|
||||
<button data-test-first>First</button>
|
||||
<button data-test-last>Last</button>
|
||||
</Modal>
|
||||
</template>,
|
||||
);
|
||||
|
||||
assert.dom('[data-test-first]').isFocused();
|
||||
|
||||
// Tab should stay within modal
|
||||
await click('[data-test-last]');
|
||||
assert.dom('[data-test-last]').isFocused();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Testing with data-test attributes:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/user-profile.gjs
|
||||
import Component from '@glimmer/component';
|
||||
|
||||
class UserProfile extends Component {
|
||||
<template>
|
||||
<div class="user-profile" data-test-user-profile>
|
||||
<img src={{@user.avatar}} alt={{@user.name}} data-test-avatar />
|
||||
<h2 data-test-name>{{@user.name}}</h2>
|
||||
<p data-test-email>{{@user.email}}</p>
|
||||
|
||||
{{#if @onEdit}}
|
||||
<button {{on "click" (fn @onEdit @user)}} data-test-edit-button>
|
||||
Edit
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
Modern testing patterns with `@ember/test-helpers`, `qunit-dom`, and data-test attributes provide better test reliability, readability, and maintainability.
|
||||
|
||||
Reference: [Ember Testing](https://guides.emberjs.com/release/testing/)
|
||||
541
.agents/skills/ember-best-practices/rules/testing-msw-setup.md
Normal file
541
.agents/skills/ember-best-practices/rules/testing-msw-setup.md
Normal file
@@ -0,0 +1,541 @@
|
||||
---
|
||||
title: MSW (Mock Service Worker) Setup for Testing
|
||||
impact: HIGH
|
||||
impactDescription: Proper API mocking without ORM complexity
|
||||
tags: testing, msw, api-mocking, mock-service-worker
|
||||
---
|
||||
|
||||
## MSW (Mock Service Worker) Setup for Testing
|
||||
|
||||
Use MSW (Mock Service Worker) for API mocking in tests. MSW provides a cleaner approach than Mirage by intercepting requests at the network level without introducing unnecessary ORM patterns or abstractions.
|
||||
|
||||
**Incorrect (using Mirage with ORM complexity):**
|
||||
|
||||
```javascript
|
||||
// mirage/config.js
|
||||
export default function () {
|
||||
this.namespace = '/api';
|
||||
|
||||
// Complex schema and factories
|
||||
this.get('/users', (schema) => {
|
||||
return schema.users.all();
|
||||
});
|
||||
|
||||
// Need to maintain schema, factories, serializers
|
||||
this.post('/users', (schema, request) => {
|
||||
let attrs = JSON.parse(request.requestBody);
|
||||
return schema.users.create(attrs);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (using MSW with simple network mocking):**
|
||||
|
||||
```javascript
|
||||
// tests/helpers/msw.js
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
// Simple request/response mocking
|
||||
export const handlers = [
|
||||
http.get('/api/users', () => {
|
||||
return HttpResponse.json([
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
]);
|
||||
}),
|
||||
|
||||
http.post('/api/users', async ({ request }) => {
|
||||
const user = await request.json();
|
||||
return HttpResponse.json({ id: 3, ...user }, { status: 201 });
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
**Why MSW over Mirage:**
|
||||
|
||||
- **Better conventions** - Mock at the network level, not with an ORM
|
||||
- **Simpler mental model** - Define request handlers, return responses
|
||||
- **Doesn't lead developers astray** - No schema migrations or factories to maintain
|
||||
- **Works everywhere** - Same mocks work in tests, Storybook, and development
|
||||
- **More realistic** - Actually intercepts fetch/XMLHttpRequest
|
||||
|
||||
Reference: [Ember.js Community Discussion on MSW](https://discuss.emberjs.com/t/my-cookbook-for-various-emberjs-things/19679)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install --save-dev msw
|
||||
```
|
||||
|
||||
### Setup Test Helper
|
||||
|
||||
Create a test helper to set up MSW in your tests:
|
||||
|
||||
```javascript
|
||||
// tests/helpers/msw.js
|
||||
import { setupServer } from 'msw/node';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
// Define default handlers that apply to all tests
|
||||
const defaultHandlers = [
|
||||
// Add default handlers here if needed
|
||||
];
|
||||
|
||||
export function setupMSW(hooks, handlers = []) {
|
||||
const server = setupServer(...defaultHandlers, ...handlers);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
server.listen({ onUnhandledRequest: 'warn' });
|
||||
});
|
||||
|
||||
hooks.afterEach(function () {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
hooks.after(function () {
|
||||
server.close();
|
||||
});
|
||||
|
||||
return { server };
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export { http, HttpResponse };
|
||||
```
|
||||
|
||||
### Basic Usage in Tests
|
||||
|
||||
```javascript
|
||||
// tests/acceptance/users-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupMSW, http, HttpResponse } from 'my-app/tests/helpers/msw';
|
||||
|
||||
module('Acceptance | users', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
const { server } = setupMSW(hooks);
|
||||
|
||||
test('displays list of users', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/users', () => {
|
||||
return HttpResponse.json({
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'user',
|
||||
attributes: { name: 'Alice', email: 'alice@example.com' },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'user',
|
||||
attributes: { name: 'Bob', email: 'bob@example.com' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await visit('/users');
|
||||
|
||||
assert.strictEqual(currentURL(), '/users');
|
||||
assert.dom('[data-test-user-item]').exists({ count: 2 });
|
||||
assert.dom('[data-test-user-name]').hasText('Alice');
|
||||
});
|
||||
|
||||
test('handles server errors gracefully', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/users', () => {
|
||||
return HttpResponse.json({ errors: [{ title: 'Server Error' }] }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
await visit('/users');
|
||||
|
||||
assert.dom('[data-test-error-message]').exists();
|
||||
assert.dom('[data-test-error-message]').containsText('Server Error');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking POST/PUT/DELETE Requests
|
||||
|
||||
```javascript
|
||||
import { visit, click, fillIn } from '@ember/test-helpers';
|
||||
|
||||
test('creates a new user', async function (assert) {
|
||||
let capturedRequest = null;
|
||||
|
||||
server.use(
|
||||
http.post('/api/users', async ({ request }) => {
|
||||
capturedRequest = await request.json();
|
||||
|
||||
return HttpResponse.json(
|
||||
{
|
||||
data: {
|
||||
id: '3',
|
||||
type: 'user',
|
||||
attributes: capturedRequest.data.attributes,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await visit('/users/new');
|
||||
await fillIn('[data-test-name-input]', 'Charlie');
|
||||
await fillIn('[data-test-email-input]', 'charlie@example.com');
|
||||
await click('[data-test-submit-button]');
|
||||
|
||||
assert.strictEqual(currentURL(), '/users/3');
|
||||
assert.deepEqual(capturedRequest.data.attributes, {
|
||||
name: 'Charlie',
|
||||
email: 'charlie@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
test('updates an existing user', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/users/1', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
id: '1',
|
||||
type: 'user',
|
||||
attributes: { name: 'Alice', email: 'alice@example.com' },
|
||||
},
|
||||
});
|
||||
}),
|
||||
http.patch('/api/users/1', async ({ request }) => {
|
||||
const body = await request.json();
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
id: '1',
|
||||
type: 'user',
|
||||
attributes: body.data.attributes,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await visit('/users/1/edit');
|
||||
await fillIn('[data-test-name-input]', 'Alice Updated');
|
||||
await click('[data-test-submit-button]');
|
||||
|
||||
assert.dom('[data-test-user-name]').hasText('Alice Updated');
|
||||
});
|
||||
|
||||
test('deletes a user', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/users', () => {
|
||||
return HttpResponse.json({
|
||||
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
|
||||
});
|
||||
}),
|
||||
http.delete('/api/users/1', () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
|
||||
await visit('/users');
|
||||
await click('[data-test-delete-button]');
|
||||
|
||||
assert.dom('[data-test-user-item]').doesNotExist();
|
||||
});
|
||||
```
|
||||
|
||||
### Query Parameters and Dynamic Routes
|
||||
|
||||
```javascript
|
||||
test('filters users by query parameter', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/users', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const searchQuery = url.searchParams.get('filter[name]');
|
||||
|
||||
const users = [
|
||||
{ id: '1', type: 'user', attributes: { name: 'Alice' } },
|
||||
{ id: '2', type: 'user', attributes: { name: 'Bob' } },
|
||||
];
|
||||
|
||||
const filtered = searchQuery
|
||||
? users.filter((u) => u.attributes.name.includes(searchQuery))
|
||||
: users;
|
||||
|
||||
return HttpResponse.json({ data: filtered });
|
||||
}),
|
||||
);
|
||||
|
||||
await visit('/users?filter[name]=Alice');
|
||||
|
||||
assert.dom('[data-test-user-item]').exists({ count: 1 });
|
||||
assert.dom('[data-test-user-name]').hasText('Alice');
|
||||
});
|
||||
|
||||
test('handles dynamic route segments', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/users/:id', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
id: params.id,
|
||||
type: 'user',
|
||||
attributes: { name: `User ${params.id}` },
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await visit('/users/42');
|
||||
|
||||
assert.dom('[data-test-user-name]').hasText('User 42');
|
||||
});
|
||||
```
|
||||
|
||||
### Network Delays and Race Conditions
|
||||
|
||||
```javascript
|
||||
test('handles slow network responses', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/users', async () => {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
return HttpResponse.json({
|
||||
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const visitPromise = visit('/users');
|
||||
|
||||
// Loading state should be visible
|
||||
assert.dom('[data-test-loading-spinner]').exists();
|
||||
|
||||
await visitPromise;
|
||||
|
||||
assert.dom('[data-test-loading-spinner]').doesNotExist();
|
||||
assert.dom('[data-test-user-item]').exists();
|
||||
});
|
||||
```
|
||||
|
||||
### Shared Handlers with Reusable Fixtures
|
||||
|
||||
```javascript
|
||||
// tests/helpers/msw-handlers.js
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
export const userHandlers = {
|
||||
list: (users = []) => {
|
||||
return http.get('/api/users', () => {
|
||||
return HttpResponse.json({ data: users });
|
||||
});
|
||||
},
|
||||
|
||||
get: (user) => {
|
||||
return http.get(`/api/users/${user.id}`, () => {
|
||||
return HttpResponse.json({ data: user });
|
||||
});
|
||||
},
|
||||
|
||||
create: (attributes) => {
|
||||
return http.post('/api/users', () => {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
data: {
|
||||
id: String(Math.random()),
|
||||
type: 'user',
|
||||
attributes,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Common fixtures
|
||||
export const fixtures = {
|
||||
users: {
|
||||
alice: {
|
||||
id: '1',
|
||||
type: 'user',
|
||||
attributes: { name: 'Alice', email: 'alice@example.com' },
|
||||
},
|
||||
bob: {
|
||||
id: '2',
|
||||
type: 'user',
|
||||
attributes: { name: 'Bob', email: 'bob@example.com' },
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```javascript
|
||||
// tests/acceptance/users-test.js
|
||||
import { userHandlers, fixtures } from 'my-app/tests/helpers/msw-handlers';
|
||||
|
||||
test('displays list of users', async function (assert) {
|
||||
server.use(userHandlers.list([fixtures.users.alice, fixtures.users.bob]));
|
||||
|
||||
await visit('/users');
|
||||
|
||||
assert.dom('[data-test-user-item]').exists({ count: 2 });
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Test Setup
|
||||
|
||||
MSW works in integration tests too:
|
||||
|
||||
```javascript
|
||||
// tests/integration/components/user-list-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, waitFor } from '@ember/test-helpers';
|
||||
import { setupMSW, http, HttpResponse } from 'my-app/tests/helpers/msw';
|
||||
import UserList from 'my-app/components/user-list';
|
||||
|
||||
module('Integration | Component | user-list', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
const { server } = setupMSW(hooks);
|
||||
|
||||
test('fetches and displays users', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/users', () => {
|
||||
return HttpResponse.json({
|
||||
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<UserList />
|
||||
</template>,
|
||||
);
|
||||
|
||||
// Wait for async data to load
|
||||
await waitFor('[data-test-user-item]');
|
||||
|
||||
assert.dom('[data-test-user-item]').exists();
|
||||
assert.dom('[data-test-user-name]').hasText('Alice');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Define handlers per test** - Use `server.use()` in individual tests rather than global handlers
|
||||
2. **Reset between tests** - The helper automatically resets handlers after each test
|
||||
3. **Use JSON:API format** - Keep responses consistent with your API format
|
||||
4. **Test error states** - Mock various HTTP error codes (400, 401, 403, 404, 500)
|
||||
5. **Capture requests** - Use the request object to verify what your app sent
|
||||
6. **Use fixtures** - Create reusable test data to keep tests DRY
|
||||
7. **Simulate delays** - Test loading states with artificial delays
|
||||
8. **Type-safe responses** - In TypeScript, type your response payloads
|
||||
|
||||
### Common Patterns
|
||||
|
||||
**Default handlers for all tests:**
|
||||
|
||||
```javascript
|
||||
// tests/helpers/msw.js
|
||||
const defaultHandlers = [
|
||||
// Always return current user
|
||||
http.get('/api/current-user', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
id: '1',
|
||||
type: 'user',
|
||||
attributes: { name: 'Test User', role: 'admin' },
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
**One-time handlers (don't persist):**
|
||||
|
||||
```javascript
|
||||
// MSW handlers persist until resetHandlers() is called
|
||||
// The test helper automatically resets after each test
|
||||
// For a one-time handler within a test, manually reset:
|
||||
test('one-time response', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/special', () => {
|
||||
return HttpResponse.json({ data: 'special' });
|
||||
}),
|
||||
);
|
||||
|
||||
// First request gets mocked response
|
||||
await visit('/special');
|
||||
assert.dom('[data-test-data]').hasText('special');
|
||||
|
||||
// Reset to remove this handler
|
||||
server.resetHandlers();
|
||||
|
||||
// Subsequent requests will use default handlers or be unhandled
|
||||
});
|
||||
```
|
||||
|
||||
**Conditional responses:**
|
||||
|
||||
```javascript
|
||||
http.post('/api/login', async ({ request }) => {
|
||||
const { email, password } = await request.json();
|
||||
|
||||
if (email === 'test@example.com' && password === 'password') {
|
||||
return HttpResponse.json({
|
||||
data: { token: 'abc123' },
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json({ errors: [{ title: 'Invalid credentials' }] }, { status: 401 });
|
||||
});
|
||||
```
|
||||
|
||||
### Migration from Mirage
|
||||
|
||||
If migrating from Mirage:
|
||||
|
||||
1. Remove `ember-cli-mirage` dependency
|
||||
2. Delete `mirage/` directory (models, factories, scenarios)
|
||||
3. Install MSW: `npm install --save-dev msw`
|
||||
4. Create the MSW test helper (see above)
|
||||
5. Replace `setupMirage(hooks)` with `setupMSW(hooks)`
|
||||
6. Convert Mirage handlers:
|
||||
- `this.server.get()` → `http.get()`
|
||||
- `this.server.create()` → Return inline JSON
|
||||
- `this.server.createList()` → Return array of JSON objects
|
||||
|
||||
**Before (Mirage):**
|
||||
|
||||
```javascript
|
||||
test('lists posts', async function (assert) {
|
||||
this.server.createList('post', 3);
|
||||
await visit('/posts');
|
||||
assert.dom('[data-test-post]').exists({ count: 3 });
|
||||
});
|
||||
```
|
||||
|
||||
**After (MSW):**
|
||||
|
||||
```javascript
|
||||
test('lists posts', async function (assert) {
|
||||
server.use(
|
||||
http.get('/api/posts', () => {
|
||||
return HttpResponse.json({
|
||||
data: [
|
||||
{ id: '1', type: 'post', attributes: { title: 'Post 1' } },
|
||||
{ id: '2', type: 'post', attributes: { title: 'Post 2' } },
|
||||
{ id: '3', type: 'post', attributes: { title: 'Post 3' } },
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
await visit('/posts');
|
||||
assert.dom('[data-test-post]').exists({ count: 3 });
|
||||
});
|
||||
```
|
||||
|
||||
Reference: [MSW Documentation](https://mswjs.io/docs/)
|
||||
@@ -0,0 +1,323 @@
|
||||
---
|
||||
title: Use qunit-dom for Better Test Assertions
|
||||
impact: MEDIUM
|
||||
impactDescription: More readable and maintainable tests
|
||||
tags: testing, qunit-dom, assertions, best-practices
|
||||
---
|
||||
|
||||
## Use qunit-dom for Better Test Assertions
|
||||
|
||||
Use `qunit-dom` for DOM assertions in tests. It provides expressive, chainable assertions that make tests more readable and provide better error messages than raw QUnit assertions.
|
||||
|
||||
**Why qunit-dom:**
|
||||
|
||||
- More expressive and readable test assertions
|
||||
- Better error messages when tests fail
|
||||
- Type-safe with TypeScript
|
||||
- Reduces boilerplate in DOM testing
|
||||
|
||||
### Basic DOM Assertions
|
||||
|
||||
**Incorrect (verbose QUnit assertions):**
|
||||
|
||||
```javascript
|
||||
// tests/integration/components/greeting-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
|
||||
|
||||
module('Integration | Component | greeting', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await render(<template><Greeting @name="World" /></template>);
|
||||
|
||||
const element = this.element.querySelector('.greeting');
|
||||
assert.ok(element, 'greeting element exists');
|
||||
assert.equal(element.textContent.trim(), 'Hello, World!', 'shows greeting');
|
||||
assert.ok(element.classList.contains('greeting'), 'has greeting class');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (expressive qunit-dom):**
|
||||
|
||||
```javascript
|
||||
// tests/integration/components/greeting-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
|
||||
|
||||
module('Integration | Component | greeting', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
await render(<template><Greeting @name="World" /></template>);
|
||||
|
||||
assert.dom('.greeting').exists('greeting element exists');
|
||||
assert.dom('.greeting').hasText('Hello, World!', 'shows greeting');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Common Assertions
|
||||
|
||||
**Existence and Visibility:**
|
||||
|
||||
```javascript
|
||||
test('element visibility', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<MyComponent />
|
||||
</template>,
|
||||
);
|
||||
|
||||
// Element exists in DOM
|
||||
assert.dom('[data-test-output]').exists();
|
||||
|
||||
// Element doesn't exist
|
||||
assert.dom('[data-test-deleted]').doesNotExist();
|
||||
|
||||
// Element is visible (not display: none or visibility: hidden)
|
||||
assert.dom('[data-test-visible]').isVisible();
|
||||
|
||||
// Element is not visible
|
||||
assert.dom('[data-test-hidden]').isNotVisible();
|
||||
|
||||
// Count elements
|
||||
assert.dom('[data-test-item]').exists({ count: 3 });
|
||||
});
|
||||
```
|
||||
|
||||
**Text Content:**
|
||||
|
||||
```javascript
|
||||
test('text assertions', async function (assert) {
|
||||
await render(<template><Article @title="Hello World" /></template>);
|
||||
|
||||
// Exact text match
|
||||
assert.dom('h1').hasText('Hello World');
|
||||
|
||||
// Contains text (partial match)
|
||||
assert.dom('p').containsText('Hello');
|
||||
|
||||
// Any text exists
|
||||
assert.dom('h1').hasAnyText();
|
||||
|
||||
// No text
|
||||
assert.dom('.empty').hasNoText();
|
||||
});
|
||||
```
|
||||
|
||||
**Attributes:**
|
||||
|
||||
```javascript
|
||||
test('attribute assertions', async function (assert) {
|
||||
await render(<template><Button @disabled={{true}} /></template>);
|
||||
|
||||
// Has attribute (any value)
|
||||
assert.dom('button').hasAttribute('disabled');
|
||||
|
||||
// Has specific attribute value
|
||||
assert.dom('button').hasAttribute('type', 'submit');
|
||||
|
||||
// Attribute value matches regex
|
||||
assert.dom('a').hasAttribute('href', /^https:\/\//);
|
||||
|
||||
// Doesn't have attribute
|
||||
assert.dom('button').doesNotHaveAttribute('aria-hidden');
|
||||
|
||||
// Has ARIA attributes
|
||||
assert.dom('[role="button"]').hasAttribute('aria-label', 'Close dialog');
|
||||
});
|
||||
```
|
||||
|
||||
**Classes:**
|
||||
|
||||
```javascript
|
||||
test('class assertions', async function (assert) {
|
||||
await render(<template><Card @status="active" /></template>);
|
||||
|
||||
// Has single class
|
||||
assert.dom('.card').hasClass('active');
|
||||
|
||||
// Doesn't have class
|
||||
assert.dom('.card').doesNotHaveClass('disabled');
|
||||
|
||||
// Has no classes at all
|
||||
assert.dom('.plain').hasNoClass();
|
||||
});
|
||||
```
|
||||
|
||||
**Form Elements:**
|
||||
|
||||
```javascript
|
||||
test('form assertions', async function (assert) {
|
||||
await render(
|
||||
<template>
|
||||
<form>
|
||||
<input type="text" value="hello" />
|
||||
<input type="checkbox" checked />
|
||||
<input type="radio" disabled />
|
||||
<select>
|
||||
<option selected>Option 1</option>
|
||||
</select>
|
||||
</form>
|
||||
</template>,
|
||||
);
|
||||
|
||||
// Input value
|
||||
assert.dom('input[type="text"]').hasValue('hello');
|
||||
|
||||
// Checkbox/radio state
|
||||
assert.dom('input[type="checkbox"]').isChecked();
|
||||
assert.dom('input[type="checkbox"]').isNotChecked();
|
||||
|
||||
// Disabled state
|
||||
assert.dom('input[type="radio"]').isDisabled();
|
||||
assert.dom('input[type="text"]').isNotDisabled();
|
||||
|
||||
// Required state
|
||||
assert.dom('input').isRequired();
|
||||
assert.dom('input').isNotRequired();
|
||||
|
||||
// Focus state
|
||||
assert.dom('input').isFocused();
|
||||
assert.dom('input').isNotFocused();
|
||||
});
|
||||
```
|
||||
|
||||
### Chaining Assertions
|
||||
|
||||
You can chain multiple assertions on the same element:
|
||||
|
||||
```javascript
|
||||
test('chained assertions', async function (assert) {
|
||||
await render(<template><Button @variant="primary" @disabled={{false}} /></template>);
|
||||
|
||||
assert.dom('button')
|
||||
.exists()
|
||||
.hasClass('btn-primary')
|
||||
.hasAttribute('type', 'button')
|
||||
.isNotDisabled()
|
||||
.hasText('Submit')
|
||||
.isVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Error Messages
|
||||
|
||||
Add custom messages to make failures clearer:
|
||||
|
||||
```javascript
|
||||
test('custom messages', async function (assert) {
|
||||
await render(<template><UserProfile @user={{this.user}} /></template>);
|
||||
|
||||
assert.dom('[data-test-username]')
|
||||
.hasText(this.user.name, 'username is displayed correctly');
|
||||
|
||||
assert.dom('[data-test-avatar]')
|
||||
.exists('user avatar should be visible');
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Counts
|
||||
|
||||
```javascript
|
||||
test('list items', async function (assert) {
|
||||
await render(<template>
|
||||
<TodoList @todos={{this.todos}} />
|
||||
</template>);
|
||||
|
||||
// Exact count
|
||||
assert.dom('[data-test-todo]').exists({ count: 5 });
|
||||
|
||||
// At least one
|
||||
assert.dom('[data-test-todo]').exists({ count: 1 });
|
||||
|
||||
// None
|
||||
assert.dom('[data-test-todo]').doesNotExist();
|
||||
});
|
||||
```
|
||||
|
||||
### Accessibility Testing
|
||||
|
||||
Use qunit-dom for basic accessibility checks:
|
||||
|
||||
```javascript
|
||||
test('accessibility', async function (assert) {
|
||||
await render(<template><Modal @onClose={{this.close}} /></template>);
|
||||
|
||||
// ARIA roles
|
||||
assert.dom('[role="dialog"]').exists();
|
||||
assert.dom('[role="dialog"]').hasAttribute('aria-modal', 'true');
|
||||
|
||||
// Labels
|
||||
assert.dom('[aria-label="Close modal"]').exists();
|
||||
|
||||
// Focus management
|
||||
assert.dom('[data-test-close-button]').isFocused();
|
||||
|
||||
// Required fields
|
||||
assert.dom('input[name="email"]').hasAttribute('aria-required', 'true');
|
||||
});
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use data-test attributes** for test selectors instead of classes:
|
||||
|
||||
```javascript
|
||||
// Good
|
||||
assert.dom('[data-test-submit-button]').exists();
|
||||
|
||||
// Avoid - classes can change
|
||||
assert.dom('.btn.btn-primary').exists();
|
||||
```
|
||||
|
||||
2. **Make assertions specific**:
|
||||
|
||||
```javascript
|
||||
// Better - exact match
|
||||
assert.dom('h1').hasText('Welcome');
|
||||
|
||||
// Less specific - could miss issues
|
||||
assert.dom('h1').containsText('Welc');
|
||||
```
|
||||
|
||||
3. **Use meaningful custom messages**:
|
||||
|
||||
```javascript
|
||||
assert.dom('[data-test-error]').hasText('Invalid email', 'shows correct validation error');
|
||||
```
|
||||
|
||||
4. **Combine with @ember/test-helpers**:
|
||||
|
||||
```javascript
|
||||
import { click, fillIn } from '@ember/test-helpers';
|
||||
|
||||
await fillIn('[data-test-email]', 'user@example.com');
|
||||
await click('[data-test-submit]');
|
||||
|
||||
assert.dom('[data-test-success]').exists();
|
||||
```
|
||||
|
||||
5. **Test user-visible behavior**, not implementation:
|
||||
|
||||
```javascript
|
||||
// Good - tests what user sees
|
||||
assert.dom('[data-test-greeting]').hasText('Hello, Alice');
|
||||
|
||||
// Avoid - tests implementation details
|
||||
assert.ok(this.component.internalState === 'ready');
|
||||
```
|
||||
|
||||
qunit-dom makes your tests more maintainable and easier to understand. It comes pre-installed with `ember-qunit`, so you can start using it immediately.
|
||||
|
||||
**References:**
|
||||
|
||||
- [qunit-dom Documentation](https://github.com/mainmatter/qunit-dom)
|
||||
- [qunit-dom API](https://github.com/mainmatter/qunit-dom/blob/master/API.md)
|
||||
- [Ember Testing Guide](https://guides.emberjs.com/release/testing/)
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
title: Use Appropriate Render Patterns in Tests
|
||||
impact: MEDIUM
|
||||
impactDescription: Simpler test code and better readability
|
||||
tags: testing, render, component-testing, test-helpers
|
||||
---
|
||||
|
||||
## Use Appropriate Render Patterns in Tests
|
||||
|
||||
Choose the right rendering pattern based on whether your component needs arguments, blocks, or attributes in the test.
|
||||
|
||||
**Incorrect (using template tag unnecessarily):**
|
||||
|
||||
```javascript
|
||||
// tests/integration/components/loading-spinner-test.js
|
||||
import { render } from '@ember/test-helpers';
|
||||
import LoadingSpinner from 'my-app/components/loading-spinner';
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
// ❌ Unnecessary template wrapper for component with no args
|
||||
await render(
|
||||
<template>
|
||||
<LoadingSpinner />
|
||||
</template>,
|
||||
);
|
||||
|
||||
assert.dom('[data-test-spinner]').exists();
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (direct component render when no args needed):**
|
||||
|
||||
```javascript
|
||||
// tests/integration/components/loading-spinner-test.js
|
||||
import { render } from '@ember/test-helpers';
|
||||
import LoadingSpinner from 'my-app/components/loading-spinner';
|
||||
|
||||
test('it renders', async function (assert) {
|
||||
// ✅ Simple: pass component directly when no args needed
|
||||
await render(LoadingSpinner);
|
||||
|
||||
assert.dom('[data-test-spinner]').exists();
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 1: Direct component render (no args/blocks/attributes):**
|
||||
|
||||
```javascript
|
||||
// tests/integration/components/loading-spinner-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import LoadingSpinner from 'my-app/components/loading-spinner';
|
||||
|
||||
module('Integration | Component | loading-spinner', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders without arguments', async function (assert) {
|
||||
// ✅ Simple: pass component directly when no args needed
|
||||
await render(LoadingSpinner);
|
||||
|
||||
assert.dom('[data-test-spinner]').exists();
|
||||
assert.dom('[data-test-spinner]').hasClass('loading');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Pattern 2: Template tag render (with args/blocks/attributes):**
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/user-card-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import UserCard from 'my-app/components/user-card';
|
||||
|
||||
module('Integration | Component | user-card', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders with arguments', async function (assert) {
|
||||
const user = { name: 'John Doe', email: 'john@example.com' };
|
||||
|
||||
// ✅ Use template tag when passing arguments
|
||||
await render(<template><UserCard @user={{user}} /></template>);
|
||||
|
||||
assert.dom('[data-test-user-name]').hasText('John Doe');
|
||||
});
|
||||
|
||||
test('it renders with block content', async function (assert) {
|
||||
// ✅ Use template tag when providing blocks
|
||||
await render(
|
||||
<template>
|
||||
<UserCard>
|
||||
<:header>Custom Header</:header>
|
||||
<:body>Custom Content</:body>
|
||||
</UserCard>
|
||||
</template>,
|
||||
);
|
||||
|
||||
assert.dom('[data-test-header]').hasText('Custom Header');
|
||||
assert.dom('[data-test-body]').hasText('Custom Content');
|
||||
});
|
||||
|
||||
test('it renders with HTML attributes', async function (assert) {
|
||||
// ✅ Use template tag when passing HTML attributes
|
||||
await render(<template><UserCard class="featured" data-test-featured /></template>);
|
||||
|
||||
assert.dom('[data-test-featured]').exists();
|
||||
assert.dom('[data-test-featured]').hasClass('featured');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Complete example showing both patterns:**
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/button-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import Button from 'my-app/components/button';
|
||||
|
||||
module('Integration | Component | button', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders default button', async function (assert) {
|
||||
// ✅ No args needed - use direct render
|
||||
await render(Button);
|
||||
|
||||
assert.dom('button').exists();
|
||||
assert.dom('button').hasText('Click me');
|
||||
});
|
||||
|
||||
test('it renders with custom text', async function (assert) {
|
||||
// ✅ Needs block content - use template tag
|
||||
await render(
|
||||
<template>
|
||||
<Button>Submit Form</Button>
|
||||
</template>,
|
||||
);
|
||||
|
||||
assert.dom('button').hasText('Submit Form');
|
||||
});
|
||||
|
||||
test('it handles click action', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const handleClick = () => {
|
||||
assert.ok(true, 'Click handler called');
|
||||
};
|
||||
|
||||
// ✅ Needs argument - use template tag
|
||||
await render(
|
||||
<template>
|
||||
<Button @onClick={{handleClick}}>Click me</Button>
|
||||
</template>,
|
||||
);
|
||||
|
||||
await click('button');
|
||||
});
|
||||
|
||||
test('it applies variant styling', async function (assert) {
|
||||
// ✅ Needs argument - use template tag
|
||||
await render(
|
||||
<template>
|
||||
<Button @variant="primary">Primary Button</Button>
|
||||
</template>,
|
||||
);
|
||||
|
||||
assert.dom('button').hasClass('btn-primary');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Testing template-only components:**
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/icon-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import Icon from 'my-app/components/icon';
|
||||
|
||||
module('Integration | Component | icon', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders default icon', async function (assert) {
|
||||
// ✅ Template-only component with no args - use direct render
|
||||
await render(Icon);
|
||||
|
||||
assert.dom('[data-test-icon]').exists();
|
||||
});
|
||||
|
||||
test('it renders specific icon', async function (assert) {
|
||||
// ✅ Needs @name argument - use template tag
|
||||
await render(<template><Icon @name="check" @size="large" /></template>);
|
||||
|
||||
assert.dom('[data-test-icon]').hasAttribute('data-icon', 'check');
|
||||
assert.dom('[data-test-icon]').hasClass('icon-large');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Decision guide:**
|
||||
|
||||
| Scenario | Pattern | Example |
|
||||
| ----------------------------------- | ---------------------------------- | ----------------------------------------------------------- |
|
||||
| No arguments, blocks, or attributes | `render(Component)` | `render(LoadingSpinner)` |
|
||||
| Component needs arguments | `render(<template>...</template>)` | `render(<template><Card @title="Hello" /></template>)` |
|
||||
| Component receives block content | `render(<template>...</template>)` | `render(<template><Card>Content</Card></template>)` |
|
||||
| Component needs HTML attributes | `render(<template>...</template>)` | `render(<template><Card class="featured" /></template>)` |
|
||||
| Multiple test context properties | `render(<template>...</template>)` | `render(<template><Card @data={{this.data}} /></template>)` |
|
||||
|
||||
**Why this matters:**
|
||||
|
||||
- **Simplicity**: Direct render reduces boilerplate for simple cases
|
||||
- **Clarity**: Template syntax makes data flow explicit when needed
|
||||
- **Consistency**: Clear pattern helps teams write maintainable tests
|
||||
- **Type Safety**: Both patterns work with TypeScript for component types
|
||||
|
||||
**Common patterns:**
|
||||
|
||||
```glimmer-js
|
||||
// ✅ Simple component, no setup needed
|
||||
await render(LoadingSpinner);
|
||||
await render(Divider);
|
||||
await render(Logo);
|
||||
|
||||
// ✅ Component with arguments from test context
|
||||
await render(
|
||||
<template><UserList @users={{this.users}} @onSelect={{this.handleSelect}} /></template>,
|
||||
);
|
||||
|
||||
// ✅ Component with named blocks
|
||||
await render(
|
||||
<template>
|
||||
<Modal>
|
||||
<:header>Title</:header>
|
||||
<:body>Content</:body>
|
||||
<:footer><button>Close</button></:footer>
|
||||
</Modal>
|
||||
</template>,
|
||||
);
|
||||
|
||||
// ✅ Component with splattributes
|
||||
await render(
|
||||
<template>
|
||||
<Card class="highlighted" data-test-card role="article">
|
||||
Card content
|
||||
</Card>
|
||||
</template>,
|
||||
);
|
||||
```
|
||||
|
||||
Using the appropriate render pattern keeps tests clean and expressive.
|
||||
|
||||
Reference: [Ember Testing Guide](https://guides.emberjs.com/release/testing/)
|
||||
@@ -0,0 +1,309 @@
|
||||
---
|
||||
title: Use Test Waiters for Async Operations
|
||||
impact: HIGH
|
||||
impactDescription: Reliable tests that don't depend on implementation details
|
||||
tags: testing, async, test-waiters, waitFor, settled
|
||||
---
|
||||
|
||||
## Use Test Waiters for Async Operations
|
||||
|
||||
Instrument async code with test waiters instead of using `waitFor()` or `waitUntil()` in tests. Test waiters abstract async implementation details so tests focus on user behavior rather than timing.
|
||||
|
||||
**Why Test Waiters Matter:**
|
||||
|
||||
Test waiters allow `settled()` and other test helpers to automatically wait for your async operations. This means:
|
||||
|
||||
- Tests don't need to know about implementation details (timeouts, polling intervals, etc.)
|
||||
- Tests are written from a user's perspective ("click button, see result")
|
||||
- Code refactoring doesn't break tests
|
||||
- Tests are more reliable and less flaky
|
||||
|
||||
**Incorrect (testing implementation details):**
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/data-loader-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click, waitFor } from '@ember/test-helpers';
|
||||
import DataLoader from 'my-app/components/data-loader';
|
||||
|
||||
module('Integration | Component | data-loader', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it loads data', async function (assert) {
|
||||
await render(<template><DataLoader /></template>);
|
||||
|
||||
await click('[data-test-load-button]');
|
||||
|
||||
// BAD: Test knows about implementation details
|
||||
// If the component changes from polling every 100ms to 200ms, test breaks
|
||||
await waitFor('[data-test-data]', { timeout: 5000 });
|
||||
|
||||
assert.dom('[data-test-data]').hasText('Loaded data');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (using test waiters):**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/data-loader.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
import { buildWaiter } from '@ember/test-waiters';
|
||||
|
||||
const waiter = buildWaiter('data-loader');
|
||||
|
||||
export class DataLoader extends Component {
|
||||
@tracked data = null;
|
||||
@tracked isLoading = false;
|
||||
|
||||
loadData = async () => {
|
||||
// Register the async operation with test waiter
|
||||
const token = waiter.beginAsync();
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
|
||||
// Simulate async data loading
|
||||
const response = await fetch('/api/data');
|
||||
this.data = await response.json();
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
// Always end the async operation, even on error
|
||||
waiter.endAsync(token);
|
||||
}
|
||||
};
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button {{on "click" this.loadData}} data-test-load-button>
|
||||
Load Data
|
||||
</button>
|
||||
|
||||
{{#if this.isLoading}}
|
||||
<div data-test-loading>Loading...</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.data}}
|
||||
<div data-test-data>{{this.data}}</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// tests/integration/components/data-loader-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click, settled } from '@ember/test-helpers';
|
||||
import DataLoader from 'my-app/components/data-loader';
|
||||
|
||||
module('Integration | Component | data-loader', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it loads data', async function (assert) {
|
||||
await render(<template><DataLoader /></template>);
|
||||
|
||||
await click('[data-test-load-button]');
|
||||
|
||||
// GOOD: settled() automatically waits for test waiters
|
||||
// No knowledge of timing needed - tests from user's perspective
|
||||
await settled();
|
||||
|
||||
assert.dom('[data-test-data]').hasText('Loaded data');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Test waiter with cleanup:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/polling-widget.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
import { buildWaiter } from '@ember/test-waiters';
|
||||
|
||||
const waiter = buildWaiter('polling-widget');
|
||||
|
||||
export class PollingWidget extends Component {
|
||||
@tracked status = 'idle';
|
||||
intervalId = null;
|
||||
token = null;
|
||||
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
|
||||
registerDestructor(this, () => {
|
||||
this.stopPolling();
|
||||
});
|
||||
}
|
||||
|
||||
startPolling = () => {
|
||||
// Register async operation
|
||||
this.token = waiter.beginAsync();
|
||||
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkStatus();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
stopPolling = () => {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
// End async operation on cleanup
|
||||
if (this.token) {
|
||||
waiter.endAsync(this.token);
|
||||
this.token = null;
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus = async () => {
|
||||
const response = await fetch('/api/status');
|
||||
this.status = await response.text();
|
||||
|
||||
if (this.status === 'complete') {
|
||||
this.stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button {{on "click" this.startPolling}} data-test-start>
|
||||
Start Polling
|
||||
</button>
|
||||
<div data-test-status>{{this.status}}</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Test waiter with Services:**
|
||||
|
||||
```glimmer-js
|
||||
// app/services/data-sync.js
|
||||
import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { buildWaiter } from '@ember/test-waiters';
|
||||
|
||||
const waiter = buildWaiter('data-sync-service');
|
||||
|
||||
export class DataSyncService extends Service {
|
||||
@tracked isSyncing = false;
|
||||
|
||||
async sync() {
|
||||
const token = waiter.beginAsync();
|
||||
|
||||
try {
|
||||
this.isSyncing = true;
|
||||
|
||||
const response = await fetch('/api/sync', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
waiter.endAsync(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```glimmer-js
|
||||
// tests/unit/services/data-sync-test.js
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
import { settled } from '@ember/test-helpers';
|
||||
|
||||
module('Unit | Service | data-sync', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it syncs data', async function (assert) {
|
||||
const service = this.owner.lookup('service:data-sync');
|
||||
|
||||
// Start async operation
|
||||
const syncPromise = service.sync();
|
||||
|
||||
// No need for manual waiting - settled() handles it
|
||||
await settled();
|
||||
|
||||
const result = await syncPromise;
|
||||
assert.ok(result, 'Sync completed successfully');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Multiple concurrent operations:**
|
||||
|
||||
```glimmer-js
|
||||
// app/components/parallel-loader.gjs
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { buildWaiter } from '@ember/test-waiters';
|
||||
|
||||
const waiter = buildWaiter('parallel-loader');
|
||||
|
||||
export class ParallelLoader extends Component {
|
||||
@tracked results = [];
|
||||
|
||||
loadAll = async () => {
|
||||
const urls = ['/api/data1', '/api/data2', '/api/data3'];
|
||||
|
||||
// Each request gets its own token
|
||||
const requests = urls.map(async (url) => {
|
||||
const token = waiter.beginAsync();
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return await response.json();
|
||||
} finally {
|
||||
waiter.endAsync(token);
|
||||
}
|
||||
});
|
||||
|
||||
this.results = await Promise.all(requests);
|
||||
};
|
||||
|
||||
<template>
|
||||
<button {{on "click" this.loadAll}} data-test-load-all>
|
||||
Load All
|
||||
</button>
|
||||
|
||||
{{#each this.results as |result|}}
|
||||
<div data-test-result>{{result}}</div>
|
||||
{{/each}}
|
||||
</template>
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
1. **User-focused tests**: Tests describe user actions, not implementation
|
||||
2. **Resilient to refactoring**: Change timing/polling without breaking tests
|
||||
3. **No arbitrary timeouts**: Tests complete as soon as operations finish
|
||||
4. **Automatic waiting**: `settled()`, `click()`, etc. wait for all registered operations
|
||||
5. **Better debugging**: Test waiters show pending operations when tests hang
|
||||
|
||||
**When to use test waiters:**
|
||||
|
||||
- Network requests (fetch, XHR)
|
||||
- Timers and intervals (setTimeout, setInterval)
|
||||
- Animations and transitions
|
||||
- Polling operations
|
||||
- Any async operation that affects rendered output
|
||||
|
||||
**When NOT needed:**
|
||||
|
||||
- ember-concurrency already registers test waiters automatically
|
||||
- Promises that complete before render (data preparation in constructors)
|
||||
- Operations that don't affect the DOM or component state
|
||||
|
||||
**Key principle:** If your code does something async that users care about, register it with a test waiter. Tests should never use `waitFor()` or `waitUntil()` - those are code smells indicating missing test waiters.
|
||||
|
||||
Reference: [@ember/test-waiters](https://github.com/emberjs/ember-test-waiters)
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
title: VSCode Extensions and MCP Configuration for Ember Projects
|
||||
impact: HIGH
|
||||
impactDescription: Improves editor consistency and AI-assisted debugging setup
|
||||
tags: tooling, vscode, mcp, glint, developer-experience
|
||||
---
|
||||
|
||||
## VSCode Extensions and MCP Configuration for Ember Projects
|
||||
|
||||
Set up recommended VSCode extensions and Model Context Protocol (MCP) servers for optimal Ember development experience.
|
||||
|
||||
**Incorrect (no extension recommendations):**
|
||||
|
||||
```json
|
||||
{
|
||||
"recommendations": []
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (recommended extensions for Ember):**
|
||||
|
||||
```json
|
||||
{
|
||||
"recommendations": [
|
||||
"emberjs.vscode-ember",
|
||||
"vunguyentuan.vscode-glint",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Recommended VSCode Extensions
|
||||
|
||||
Create a `.vscode/extensions.json` file in your project root to recommend extensions to all team members:
|
||||
|
||||
```json
|
||||
{
|
||||
"recommendations": [
|
||||
"emberjs.vscode-ember",
|
||||
"vunguyentuan.vscode-glint",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Core Ember Extensions
|
||||
|
||||
**ember-extension-pack** (or individual extensions):
|
||||
|
||||
- `emberjs.vscode-ember` - Ember.js language support
|
||||
- Syntax highlighting for `.hbs`, `.gjs`, `.gts` files
|
||||
- IntelliSense for Ember-specific patterns
|
||||
- Code snippets for common Ember patterns
|
||||
|
||||
**Glint 2 Extension** (for TypeScript projects):
|
||||
|
||||
- `vunguyentuan.vscode-glint` - Type checking for Glimmer templates
|
||||
- Real-time type errors in `.gts`/`.gjs` files
|
||||
- Template-aware autocomplete
|
||||
- Hover information for template helpers and components
|
||||
|
||||
Install instructions:
|
||||
|
||||
```bash
|
||||
# Via command palette
|
||||
# Press Cmd+Shift+P (Mac) or Ctrl+Shift+P (Windows/Linux)
|
||||
# Type: "Extensions: Install Extensions"
|
||||
# Search for "Ember" or "Glint"
|
||||
```
|
||||
|
||||
## MCP (Model Context Protocol) Server Configuration
|
||||
|
||||
Configure MCP servers in `.vscode/settings.json` to integrate AI coding assistants with Ember-specific context:
|
||||
|
||||
```json
|
||||
{
|
||||
"github.copilot.enable": {
|
||||
"*": true,
|
||||
"yaml": false,
|
||||
"plaintext": false,
|
||||
"markdown": false
|
||||
},
|
||||
"mcp.servers": {
|
||||
"ember-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["@ember/mcp-server"],
|
||||
"description": "Ember.js MCP Server - Provides Ember-specific context"
|
||||
},
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["@modelcontextprotocol/server-chrome-devtools"],
|
||||
"description": "Chrome DevTools MCP Server - Browser debugging integration"
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp-server"],
|
||||
"description": "Playwright MCP Server - Browser automation and testing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Server Benefits
|
||||
|
||||
**Ember MCP Server** (`@ember/mcp-server`):
|
||||
|
||||
- Ember API documentation lookup
|
||||
- Component and helper discovery
|
||||
- Addon documentation integration
|
||||
- Routing and data layer context
|
||||
|
||||
**Chrome DevTools MCP** (`@modelcontextprotocol/server-chrome-devtools`):
|
||||
|
||||
- Live browser inspection
|
||||
- Console debugging assistance
|
||||
- Network request analysis
|
||||
- Performance profiling integration
|
||||
|
||||
**Playwright MCP** (optional, `@playwright/mcp-server`):
|
||||
|
||||
- Test generation assistance
|
||||
- Browser automation context
|
||||
- E2E testing patterns
|
||||
- Debugging test failures
|
||||
|
||||
## Complete VSCode Settings Example
|
||||
|
||||
```json
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
|
||||
"[glimmer-js]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[glimmer-ts]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
|
||||
"files.associations": {
|
||||
"*.gjs": "glimmer-js",
|
||||
"*.gts": "glimmer-ts"
|
||||
},
|
||||
|
||||
"glint.enabled": true,
|
||||
"glint.configPath": "./tsconfig.json",
|
||||
|
||||
"github.copilot.enable": {
|
||||
"*": true
|
||||
},
|
||||
|
||||
"mcp.servers": {
|
||||
"ember-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["@ember/mcp-server"],
|
||||
"description": "Ember.js MCP Server"
|
||||
},
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["@modelcontextprotocol/server-chrome-devtools"],
|
||||
"description": "Chrome DevTools MCP Server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Configuration (when using Glint)
|
||||
|
||||
Ensure your `tsconfig.json` has Glint configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
// ... standard TS options
|
||||
},
|
||||
"glint": {
|
||||
"environment": ["ember-loose", "ember-template-imports"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installation Steps
|
||||
|
||||
1. **Install extensions** (prompted automatically when opening project with `.vscode/extensions.json`)
|
||||
2. **Install Glint** (if using TypeScript):
|
||||
```bash
|
||||
npm install --save-dev @glint/core @glint/environment-ember-loose @glint/environment-ember-template-imports
|
||||
```
|
||||
3. **Configure MCP servers** in `.vscode/settings.json`
|
||||
4. **Reload VSCode** to activate all extensions and MCP integrations
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Consistent team setup**: All developers get same extensions
|
||||
- **Type safety**: Glint provides template type checking
|
||||
- **AI assistance**: MCP servers give AI tools Ember-specific context
|
||||
- **Better DX**: Autocomplete, debugging, and testing integration
|
||||
- **Reduced onboarding**: New team members get productive faster
|
||||
|
||||
## References
|
||||
|
||||
- [VSCode Ember Extension](https://marketplace.visualstudio.com/items?itemName=emberjs.vscode-ember)
|
||||
- [Glint Documentation](https://typed-ember.gitbook.io/glint/)
|
||||
- [MCP Protocol Specification](https://modelcontextprotocol.io/)
|
||||
- [Ember Primitives VSCode Setup Example](https://github.com/universal-ember/ember-primitives/tree/main/.vscode)
|
||||
@@ -1,8 +1,12 @@
|
||||
# This file is committed to git and should not contain any secrets.
|
||||
#
|
||||
#
|
||||
# Vite recommends using .env.local or .env.[mode].local if you need to manage secrets
|
||||
# SEE: https://vite.dev/guide/env-and-mode.html#env-files for more information.
|
||||
|
||||
|
||||
# Default NODE_ENV with vite build --mode=test is production
|
||||
NODE_ENV=development
|
||||
|
||||
# OpenStreetMap OAuth
|
||||
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
|
||||
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org
|
||||
|
||||
3
.env.production
Normal file
3
.env.production
Normal file
@@ -0,0 +1,3 @@
|
||||
# OpenStreetMap OAuth
|
||||
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
|
||||
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org
|
||||
14
.gitea/release-drafter.yml
Normal file
14
.gitea/release-drafter.yml
Normal file
@@ -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
|
||||
@@ -18,15 +18,15 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Lint
|
||||
@@ -35,18 +35,16 @@ jobs:
|
||||
test:
|
||||
name: "Test"
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: cypress/browsers:node-22.19.0-chrome-139.0.7258.154-1-ff-142.0.1-edge-139.0.3405.125-1
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run Tests
|
||||
13
.gitea/workflows/release_drafter.yml
Normal file
13
.gitea/workflows/release_drafter.yml
Normal file
@@ -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,3 +1,7 @@
|
||||
export default {
|
||||
extends: ['stylelint-config-standard'],
|
||||
rules: {
|
||||
'no-descending-specificity': null,
|
||||
'property-no-vendor-prefix': null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,137 +1,76 @@
|
||||
# Project Status: Marco
|
||||
|
||||
**Last Updated:** Tue Jan 27 2026
|
||||
**Last Updated:** Wed Apr 1 2026
|
||||
|
||||
## Project Context
|
||||
|
||||
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris edition with GJS/GLIMMER), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**, using a custom module structure.
|
||||
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**.
|
||||
|
||||
## What We Have Done
|
||||
|
||||
### 1. Map Integration
|
||||
|
||||
- Set up OpenLayers in `app/components/map.gjs` (class-based component).
|
||||
- Switched tiles to **OpenFreeMap Liberty** style (supports vector POIs).
|
||||
- Implemented a hybrid click handler:
|
||||
- Detects clicks on visual vector tiles.
|
||||
- Falls back to fetching authoritative data from an **Overpass API** service.
|
||||
- **Logic Upgrade:** Map intelligently detects if _any_ sidebar/pane is open and handles outside clicks to close them instead of initiating new searches.
|
||||
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
|
||||
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
|
||||
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
|
||||
- **Mobile UX:**
|
||||
- **Touch:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
|
||||
- **Scroll:** Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`).
|
||||
- **Auto-Pan:** On mobile screens, if a selected pin is obscured by the bottom sheet, the map automatically pans to center the pin in the visible top half of the screen.
|
||||
- **Controls:** Fixed positioning of "Locate" and "Rotate" buttons on mobile by correcting CSS `inset` syntax.
|
||||
- **iOS Polish:**
|
||||
- Prevented input auto-zoom by ensuring `.form-control` font size is `1rem` (16px).
|
||||
- Added `-webkit-text-size-adjust: 100%` to prevent text inflation on rotation.
|
||||
- Set base `body` font size to `16px`.
|
||||
- **Geolocation ("Locate Me"):**
|
||||
- Implemented a "Locate Me" button with robust tracking logic.
|
||||
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
|
||||
- **Smart Pulse:** Displays a pulsing blue circle during the search phase.
|
||||
- **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout.
|
||||
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
|
||||
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
|
||||
- **Pin Animation:** Selected pins are highlighted with a custom **Red Pin** overlay that drops in with an animation. The center dot is styled as a solid dark red circle (`#b31412`).
|
||||
- **Vector Tiles:** Using **OpenFreeMap Liberty** style with a hybrid click handler (Visual Tiles + Overpass API fallback).
|
||||
- **Smart Interaction:**
|
||||
- **Hit Tolerance:** 10px buffer for easier mobile tapping.
|
||||
- **Auto-Pan:** Selected pins automatically center in the visible area (respecting bottom sheets/sidebars).
|
||||
- **Smart Zoom:** `zoomToBbox` fits complex geometries (ways/relations) with dynamic padding, only zooming out to fit.
|
||||
- **Visuals:** Custom "Red Pin" overlay with drop animation. Selected OSM ways/relations show distinct blue outlines.
|
||||
- **Geolocation:** Robust "Locate Me" with dynamic zoom and accuracy visualization.
|
||||
|
||||
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||
|
||||
- Created a custom TypeScript module in `vendor/remotestorage-module-places/`.
|
||||
- **Schema:** `place` object containing `id` (ULID), `title`, `lat`, `lon`, `geohash`, `osmId`, `url`, etc.
|
||||
- **Storage Path:** Nested `<2-char>/<2-char>/<id>` (based on geohash) for scalability.
|
||||
- **API:**
|
||||
- `getPlaces(prefixes?)`: efficient partial loading of specific sectors (or full recursive scan if no prefixes provided).
|
||||
- Uses `getListing` for directory traversal and `getAll` for object retrieval.
|
||||
- configured with `maxAge: false` to ensure data freshness.
|
||||
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
|
||||
- **Custom Module:** Handles `place` objects with Geohash-based partitioning (`<2-char>/<2-char>/<id>`).
|
||||
- **Optimization:** Supports efficient spatial querying via prefix loading.
|
||||
- **Lists Support:** Manages collection-based organization (e.g., "To Visit", "Favorites").
|
||||
|
||||
### 3. App Infrastructure & Build
|
||||
### 3. App Infrastructure
|
||||
|
||||
- **Services:**
|
||||
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
|
||||
- **Optimization:** Implemented **Debounced Reload** (200ms) for bookmark updates to handle rapid change events efficiently.
|
||||
- **Optimization:** Correctly handles deletion/updates by clearing stale data for reloaded geohash sectors.
|
||||
- `osm.js`: Fetches nearby POIs from Overpass API.
|
||||
- **Configurable:** Now supports dynamic API endpoints via `SettingsService`.
|
||||
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
|
||||
- **Caching:** Implemented in-memory cache for repeated `getNearbyPois` requests (same lat/lon/radius) to enable instant "Back" navigation.
|
||||
- `settings.js`: Manages user preferences (currently Overpass API provider) persisted to `localStorage`.
|
||||
- `storage.js`: Manages RemoteStorage, caching, and the new **Lists** feature (`to-go`, `to-do`).
|
||||
- `osm.js`: Fetches/caches POIs from Overpass API (configurable endpoints).
|
||||
- `settings.js`: Persists user preferences (e.g., API provider).
|
||||
- **UI Components:**
|
||||
- `places-sidebar.gjs`: Displays a list of nearby POIs.
|
||||
- **Layout:** Responsive design that transforms into a **Bottom Sheet** (50% height) on mobile screens (`<=768px`) with rounded corners and upward shadow.
|
||||
- `place-details.gjs`: Dedicated component for displaying rich place information.
|
||||
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
|
||||
- **Layout:** Polished UI with distinct sections for Actions and Meta info.
|
||||
- `app-header.gjs`: Transparent header with "Menu" button (Settings) and User Avatar (Login).
|
||||
- `settings-pane.gjs`: Sidebar component for app info ("About" section) and settings.
|
||||
- **Features:** Dropdown to select Overpass API provider (bke.ro, overpass-api.de, private.coffee).
|
||||
- **Mobile:** Renders as a 2/3 height bottom sheet on mobile.
|
||||
- **Z-Index:** Configured to overlay the Places sidebar correctly (`z-index: 3200`).
|
||||
- **Geo Utils:**
|
||||
- `app/utils/geo.js`: Haversine distance calculations.
|
||||
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
|
||||
- **Format Utils:**
|
||||
- `app/utils/format-text.js` & `humanize-osm-tag` helper: Standardized logic (Title Case, space replacement) for displaying OSM tags like `guest_house` -> "Guest House".
|
||||
- **Build & DevOps:**
|
||||
- **Icon Generation:** Added `build:icons` script using `magick` and `rsvg-convert` to automate PNG generation from SVG.
|
||||
- **Dependencies:** Documented system requirements (ImageMagick, librsvg) in `README.md`.
|
||||
- **Ember CLI:** Added as dev dependency to support generator commands.
|
||||
- **License:** Added AGPLv3 license.
|
||||
- **Responsive Layout:** Sidebar transforms into a Bottom Sheet on mobile.
|
||||
- **Place Details:** Rich info (Address, Socials, Opening Hours) with distinct "Actions" and "Meta" sections.
|
||||
- **App Menu:** Comprehensive settings and about section, implemented as a secondary sidebar.
|
||||
- **CI/CD:** Gitea Actions for automated testing and release drafting.
|
||||
|
||||
### 4. Routing & Architecture (Refactored)
|
||||
### 4. Routing & Architecture
|
||||
|
||||
- **URL-Driven Architecture:** Moved from service-based state to proper route-based state management.
|
||||
- `/search?lat=...&lon=...&q=...`: Displays search results list.
|
||||
- `/place/:place_id`: Displays details for a specific place (OSM POI or Bookmark).
|
||||
- **Heuristic Navigation:** The `search` route implements "visual click matching" logic. If a search yields a direct match (exact name or very close proximity), it automatically redirects to the `/place/` route, skipping the list view.
|
||||
- **Back Button Support:** Browser history works correctly. Navigating "Back" from a place returns to the cached search results instantly without network requests.
|
||||
- **Explicit URLs:** Routes support specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
|
||||
- **Smart Linking:** The `showPlaces` action intercepts search results and automatically resolves them to existing **Bookmarks** if a match is found (via `storage.findPlaceById`). This ensures the app navigates to the persistent Bookmark URL (ULID) and correctly reflects the "Saved" status in the UI instead of treating it as a new generic OSM place.
|
||||
- **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app.
|
||||
- **URL-Driven:** `/search` (list) and `/place/:id` (details) routes.
|
||||
- **Smart Navigation:**
|
||||
- Direct hits redirect to details.
|
||||
- Search results automatically resolve to existing **Bookmarks**.
|
||||
- "Back" navigation returns to cached search results instantly.
|
||||
|
||||
### 5. Creation & Editing Workflow
|
||||
### 5. Features
|
||||
|
||||
- **Create Place:**
|
||||
- Implemented `/place/new` route for creating new private places.
|
||||
- **UX:** Map displays a central crosshair for precise location selection.
|
||||
- **Mobile Optimization:**
|
||||
- Disabled map inertia (`kinetic: false`) to ensure the map stops exactly where the finger releases.
|
||||
- `PlaceEditForm` conditionally disables autofocus on mobile screens (`<= 768px`) to prevent the onscreen keyboard from obscuring the map view immediately.
|
||||
- Responsive crosshair sizing (48px desktop / 24px mobile).
|
||||
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
|
||||
- **Search:** Typo-tolerant **Photon API** integration with location bias, debounce, query aborting, and loading indicators.
|
||||
- **Category Search:** Quick search buttons/chips for POI categories, rendering distinct map markers with custom icons for results.
|
||||
- **UI Enhancements:** Toast notifications for failed search requests
|
||||
- **Creation & Editing:**
|
||||
- "Crosshair" mode for precise location picking.
|
||||
- Edit Title/Description for saved places.
|
||||
- **Lists:** Users can add places to default lists ("To Go", "To Do") directly from the details view.
|
||||
- **Socials:** Place details now include Email, Facebook, and Instagram links.
|
||||
- **Data Sync:** Auto-refreshes OSM data (coords/tags) for saved places on view, preserving custom titles.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Repo:** The app runs via `pnpm start`.
|
||||
- **Repo:** Runs via `pnpm start`.
|
||||
- **Workflow:**
|
||||
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
|
||||
2. User clicks map -> Route transition to `/search` -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
|
||||
3. **Navigation:**
|
||||
- If direct match: Redirect to `/place/:id`.
|
||||
- If multiple results: Show `/search` list view.
|
||||
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
|
||||
5. **Creation:** User clicks "Create Place" -> Enters creation mode (crosshair) -> Positions map -> Enters details -> Save.
|
||||
6. **Persistence:** RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
||||
7. **Editing:** User can edit the Title and Description of saved bookmarks via an "Edit" button in the details view.
|
||||
8. **Settings:** User can change the Overpass API provider via the new Settings menu.
|
||||
1. **Explore:** Pan/Zoom loads bookmarks from RemoteStorage.
|
||||
2. **Search:** Query via Photon -> List or Direct Result.
|
||||
3. **View:** Details pane (Sidebar/Bottom Sheet) shows rich info + social links.
|
||||
4. **Action:**
|
||||
- **Save:** Persist to RemoteStorage.
|
||||
- **Organize:** Add to "To Go" / "To Do" lists.
|
||||
- **Edit:** Custom Title/Description.
|
||||
5. **Sync:** Background check updates OSM data if changed.
|
||||
|
||||
## Files Currently in Focus
|
||||
## Next Steps
|
||||
|
||||
- `app/components/map.gjs`
|
||||
- `app/components/place-edit-form.gjs`
|
||||
- `app/templates/place/new.gjs`
|
||||
|
||||
## Next Steps & Pending Tasks
|
||||
|
||||
1. **Linting & Code Quality:** Fix remaining CSS errors and address unused variables/runloop usage.
|
||||
2. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
|
||||
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||
|
||||
## Technical Constraints
|
||||
|
||||
- **Template Style:** Strict Mode GJS (`<template>`).
|
||||
- **Package Manager:** `pnpm` for the main app, `npm` for the vendor module.
|
||||
- **Visuals:** No Tailwind/Bootstrap; using custom CSS in `app/styles/app.css`.
|
||||
1. **Testing:** Add automated tests for the new Lists logic and Geohash coverage.
|
||||
2. **Performance:** Monitor with large datasets.
|
||||
3. **Refinement:** Polish list UI and interactions.
|
||||
|
||||
@@ -5,10 +5,18 @@ import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import UserMenu from '#components/user-menu';
|
||||
import SearchBox from '#components/search-box';
|
||||
import CategoryChips from '#components/category-chips';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@service settings;
|
||||
@tracked isUserMenuOpen = false;
|
||||
@tracked searchQuery = '';
|
||||
|
||||
get hasQuery() {
|
||||
return !!this.searchQuery;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleUserMenu() {
|
||||
@@ -20,19 +28,34 @@ export default class AppHeaderComponent extends Component {
|
||||
this.isUserMenuOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
handleQueryChange(query) {
|
||||
this.searchQuery = query;
|
||||
}
|
||||
|
||||
@action
|
||||
handleChipSelect(category) {
|
||||
this.searchQuery = category.label;
|
||||
// The existing logic in CategoryChips triggers the route transition.
|
||||
// This update simply fills the search box.
|
||||
}
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="menu-btn btn-press"
|
||||
type="button"
|
||||
aria-label="Menu"
|
||||
{{on "click" @onToggleMenu}}
|
||||
>
|
||||
<Icon @name="menu" @size={{24}} @color="#333" />
|
||||
</button>
|
||||
<SearchBox
|
||||
@query={{this.searchQuery}}
|
||||
@onToggleMenu={{@onToggleMenu}}
|
||||
@onQueryChange={{this.handleQueryChange}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{{#if this.settings.showQuickSearchButtons}}
|
||||
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="header-right">
|
||||
<div class="user-menu-container">
|
||||
<button
|
||||
|
||||
168
app/components/app-menu/about.gjs
Normal file
168
app/components/app-menu/about.gjs
Normal file
@@ -0,0 +1,168 @@
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
<template>
|
||||
{{! template-lint-disable no-nested-interactive }}
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>About</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<section class="about-section">
|
||||
<p>
|
||||
<strong>Marco</strong>
|
||||
(as in
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Marco Polo</a>) is an unhosted maps application that respects your
|
||||
privacy and choices.
|
||||
</p>
|
||||
<p>
|
||||
Connect your own
|
||||
<a
|
||||
href="https://remotestorage.io/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>remote storage</a>
|
||||
to sync place bookmarks across apps and devices.
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="gift" @size={{20}} />
|
||||
<span>Open Source</span>
|
||||
</summary>
|
||||
<div class="details-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>License</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="https://gitea.kosmos.org/raucao/marco"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Marco App
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<abbr title="GNU Affero General Public License">AGPL</abbr>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="https://openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Map Data
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://opendatacommons.org/licenses/odbl/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<abbr
|
||||
title="Open Data Commons Open Database License"
|
||||
>ODbL</abbr>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/feathericons/feather"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Feather Icons
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/MIT_License"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<abbr title="MIT License">MIT</abbr>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://pinhead.ink/" target="_blank" rel="noopener">
|
||||
Pinhead Icons
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/waysidemapping/pinhead?tab=readme-ov-file#where-the-icons-are-from"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Various
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>
|
||||
<Icon @name="heart" @size={{20}} @color="#e5533d" />
|
||||
<span>Contribute</span>
|
||||
</summary>
|
||||
<div class="details-content">
|
||||
<p>
|
||||
<strong>Most impactful:</strong>
|
||||
Add and improve data for points of interest in
|
||||
<a
|
||||
href="https://www.openstreetmap.org"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>OpenStreetMap</a>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Most appreciated:</strong>
|
||||
Use this app as much as you can and
|
||||
<a
|
||||
href="https://community.remotestorage.io/t/marco-an-unhosted-maps-app/941"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>submit feedback</a>
|
||||
about your experience, problems, feature wishes, etc.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Most supportive:</strong>
|
||||
Tell others about this app, on social media, in blog posts,
|
||||
educational videos, etc.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
36
app/components/app-menu/home.gjs
Normal file
36
app/components/app-menu/home.gjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import Icon from '#components/icon';
|
||||
import iconRounded from '../../icons/icon-rounded.svg?raw';
|
||||
|
||||
<template>
|
||||
<div class="sidebar-header">
|
||||
<h2>
|
||||
<span class="app-logo-icon">
|
||||
{{htmlSafe iconRounded}}
|
||||
</span>
|
||||
Marco
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<ul class="app-menu">
|
||||
<li>
|
||||
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
||||
<Icon @name="settings" @size={{20}} />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" {{on "click" (fn @onNavigate "about")}}>
|
||||
<Icon @name="info" @size={{20}} />
|
||||
<span>About</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
38
app/components/app-menu/index.gjs
Normal file
38
app/components/app-menu/index.gjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { fn } from '@ember/helper';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
import AppMenuHome from './home';
|
||||
import AppMenuSettings from './settings';
|
||||
import AppMenuAbout from './about';
|
||||
|
||||
export default class AppMenu extends Component {
|
||||
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
|
||||
|
||||
@action
|
||||
setView(view) {
|
||||
this.currentView = view;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar app-menu-pane">
|
||||
{{#if (eq this.currentView "menu")}}
|
||||
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
|
||||
|
||||
{{else if (eq this.currentView "settings")}}
|
||||
<AppMenuSettings
|
||||
@onBack={{fn this.setView "menu"}}
|
||||
@onClose={{@onClose}}
|
||||
/>
|
||||
|
||||
{{else if (eq this.currentView "about")}}
|
||||
<AppMenuAbout
|
||||
@onBack={{fn this.setView "menu"}}
|
||||
@onClose={{@onClose}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
129
app/components/app-menu/settings.gjs
Normal file
129
app/components/app-menu/settings.gjs
Normal file
@@ -0,0 +1,129 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import Icon from '#components/icon';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
export default class AppMenuSettings extends Component {
|
||||
@service settings;
|
||||
|
||||
@action
|
||||
updateApi(event) {
|
||||
this.settings.updateOverpassApi(event.target.value);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleKinetic(event) {
|
||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||
}
|
||||
|
||||
@action
|
||||
toggleQuickSearchButtons(event) {
|
||||
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
|
||||
}
|
||||
|
||||
@action
|
||||
updatePhotonApi(event) {
|
||||
this.settings.updatePhotonApi(event.target.value);
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar-header">
|
||||
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
<h2>Settings</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<section class="settings-section">
|
||||
<div class="form-group">
|
||||
<label for="show-quick-search">Quick search buttons visible</label>
|
||||
<select
|
||||
id="show-quick-search"
|
||||
class="form-control"
|
||||
{{on "change" this.toggleQuickSearchButtons}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.showQuickSearchButtons "selected"}}
|
||||
>
|
||||
Yes
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless
|
||||
this.settings.showQuickSearchButtons
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
No
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||
<select
|
||||
id="map-kinetic"
|
||||
class="form-control"
|
||||
{{on "change" this.toggleKinetic}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
On
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
Off
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overpass-api">Overpass API Provider</label>
|
||||
<select
|
||||
id="overpass-api"
|
||||
class="form-control"
|
||||
{{on "change" this.updateApi}}
|
||||
>
|
||||
{{#each this.settings.overpassApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if
|
||||
(eq api.url this.settings.overpassApi)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="photon-api">Photon API Provider</label>
|
||||
<select
|
||||
id="photon-api"
|
||||
class="form-control"
|
||||
{{on "change" this.updatePhotonApi}}
|
||||
>
|
||||
{{#each this.settings.photonApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if (eq api.url this.settings.photonApi) "selected"}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
57
app/components/category-chips.gjs
Normal file
57
app/components/category-chips.gjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import Icon from '#components/icon';
|
||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||
import { eq, and } from 'ember-truth-helpers';
|
||||
|
||||
export default class CategoryChipsComponent extends Component {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
|
||||
get categories() {
|
||||
return POI_CATEGORIES;
|
||||
}
|
||||
|
||||
@action
|
||||
searchCategory(category) {
|
||||
// If passed an onSelect action, call it (e.g. to clear search box)
|
||||
if (this.args.onSelect) {
|
||||
this.args.onSelect(category);
|
||||
}
|
||||
|
||||
let queryParams = { category: category.id, q: null };
|
||||
|
||||
if (this.mapUi.currentCenter) {
|
||||
const { lat, lon } = this.mapUi.currentCenter;
|
||||
queryParams.lat = parseFloat(lat).toFixed(4);
|
||||
queryParams.lon = parseFloat(lon).toFixed(4);
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', { queryParams });
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="category-chips-scroll">
|
||||
<div class="category-chips-container">
|
||||
{{#each this.categories as |category|}}
|
||||
<button
|
||||
type="button"
|
||||
class="category-chip"
|
||||
{{on "click" (fn this.searchCategory category)}}
|
||||
aria-label={{category.label}}
|
||||
disabled={{and
|
||||
(eq this.mapUi.loadingState.type "category")
|
||||
(eq this.mapUi.loadingState.value category.id)
|
||||
}}
|
||||
>
|
||||
<Icon @name={{category.icon}} @size={{16}} />
|
||||
<span>{{category.label}}</span>
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -1,55 +1,10 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
|
||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
||||
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
activity,
|
||||
bookmark,
|
||||
clock,
|
||||
edit,
|
||||
globe,
|
||||
home,
|
||||
'log-in': logIn,
|
||||
'log-out': logOut,
|
||||
map,
|
||||
'map-pin': mapPin,
|
||||
menu,
|
||||
navigation,
|
||||
phone,
|
||||
plus,
|
||||
server,
|
||||
settings,
|
||||
target,
|
||||
user,
|
||||
x,
|
||||
zap,
|
||||
};
|
||||
import { getIcon, isIconFilled } from '../utils/icons';
|
||||
|
||||
export default class IconComponent extends Component {
|
||||
get svg() {
|
||||
return ICONS[this.args.name];
|
||||
return getIcon(this.args.name);
|
||||
}
|
||||
|
||||
get size() {
|
||||
@@ -61,16 +16,26 @@ export default class IconComponent extends Component {
|
||||
}
|
||||
|
||||
get style() {
|
||||
return `width:${this.size}px;height:${this.size}px;color:${this.color}`;
|
||||
return htmlSafe(
|
||||
`width:${this.size}px;height:${this.size}px;color:${this.color}`
|
||||
);
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.args.title || '';
|
||||
}
|
||||
|
||||
get isFilled() {
|
||||
return this.args.filled || isIconFilled(this.args.name);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.svg}}
|
||||
<span class="icon" style={{this.style}} title={{this.title}}>
|
||||
<span
|
||||
class="icon {{if this.isFilled 'icon-filled'}}"
|
||||
style={{this.style}}
|
||||
title={{this.title}}
|
||||
>
|
||||
{{htmlSafe this.svg}}
|
||||
</span>
|
||||
{{/if}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { modifier } from 'ember-modifier';
|
||||
import 'ol/ol.css';
|
||||
import Map from 'ol/Map.js';
|
||||
import OlMap from 'ol/Map.js';
|
||||
import { defaults as defaultControls, Control } from 'ol/control.js';
|
||||
import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
|
||||
import Kinetic from 'ol/Kinetic.js';
|
||||
@@ -13,10 +13,13 @@ import LayerGroup from 'ol/layer/Group.js';
|
||||
import VectorLayer from 'ol/layer/Vector.js';
|
||||
import VectorSource from 'ol/source/Vector.js';
|
||||
import Feature from 'ol/Feature.js';
|
||||
import GeoJSON from 'ol/format/GeoJSON.js';
|
||||
import Point from 'ol/geom/Point.js';
|
||||
import Geolocation from 'ol/Geolocation.js';
|
||||
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
||||
import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
|
||||
import { apply } from 'ol-mapbox-style';
|
||||
import { getIcon } from '../utils/icons';
|
||||
import { getIconNameForTags } from '../utils/osm-icons';
|
||||
|
||||
export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@@ -27,23 +30,63 @@ export default class MapComponent extends Component {
|
||||
|
||||
mapInstance;
|
||||
bookmarkSource;
|
||||
searchResultsSource;
|
||||
selectedShapeSource;
|
||||
searchOverlay;
|
||||
searchOverlayElement;
|
||||
selectedPinOverlay;
|
||||
selectedPinElement;
|
||||
crosshairElement;
|
||||
crosshairOverlay;
|
||||
ignoreNextMapClick = false;
|
||||
|
||||
setupMap = modifier((element) => {
|
||||
if (this.mapInstance) return;
|
||||
|
||||
const openfreemap = new LayerGroup();
|
||||
|
||||
// Create a vector source and layer for the selected shape (outline)
|
||||
this.selectedShapeSource = new VectorSource();
|
||||
const selectedShapeLayer = new VectorLayer({
|
||||
source: this.selectedShapeSource,
|
||||
style: new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#3388ff',
|
||||
width: 4,
|
||||
}),
|
||||
fill: new Fill({
|
||||
color: 'rgba(51, 136, 255, 0.1)',
|
||||
}),
|
||||
}),
|
||||
zIndex: 5, // Below bookmarks (10) but above tiles
|
||||
});
|
||||
|
||||
// Create a vector source and layer for bookmarks
|
||||
this.bookmarkSource = new VectorSource();
|
||||
const bookmarkLayer = new VectorLayer({
|
||||
source: this.bookmarkSource,
|
||||
style: [
|
||||
|
||||
const bookmarkStyleFunction = (feature) => {
|
||||
const originalPlace = feature.get('originalPlace');
|
||||
let color =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--default-list-color')
|
||||
.trim() || '#000000'; // Fallback to black if variable is missing to make error obvious
|
||||
|
||||
if (
|
||||
originalPlace &&
|
||||
originalPlace._listIds &&
|
||||
originalPlace._listIds.length > 0
|
||||
) {
|
||||
// Find the first list color
|
||||
// We need access to storage.lists.
|
||||
// Since this is inside setupMap, 'this' refers to the component instance.
|
||||
const firstListId = originalPlace._listIds[0];
|
||||
const list = this.storage.lists.find((l) => l.id === firstListId);
|
||||
if (list && list.color) {
|
||||
color = list.color;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
new Style({
|
||||
image: new Circle({
|
||||
radius: 10,
|
||||
@@ -54,17 +97,139 @@ export default class MapComponent extends Component {
|
||||
new Style({
|
||||
image: new Circle({
|
||||
radius: 9,
|
||||
fill: new Fill({ color: '#ffcc33' }), // Gold/Yellow
|
||||
fill: new Fill({ color: color }),
|
||||
stroke: new Stroke({
|
||||
color: '#fff',
|
||||
width: 2,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
const bookmarkLayer = new VectorLayer({
|
||||
source: this.bookmarkSource,
|
||||
style: bookmarkStyleFunction,
|
||||
zIndex: 10, // Ensure it sits above the map tiles
|
||||
});
|
||||
|
||||
// Create a vector source and layer for search results
|
||||
this.searchResultsSource = new VectorSource();
|
||||
const cachedIconUrls = new Map();
|
||||
|
||||
const searchResultStyle = (feature) => {
|
||||
const originalPlace = feature.get('originalPlace');
|
||||
|
||||
// If this place is currently selected, hide the search result marker
|
||||
// because the main red drop pin will be shown instead.
|
||||
const selectedPlace = this.mapUi.selectedPlace;
|
||||
if (selectedPlace) {
|
||||
const isSameOsmId =
|
||||
originalPlace.osmId &&
|
||||
selectedPlace.osmId &&
|
||||
originalPlace.osmId === selectedPlace.osmId;
|
||||
const isSameId =
|
||||
originalPlace.id &&
|
||||
selectedPlace.id &&
|
||||
originalPlace.id === selectedPlace.id;
|
||||
const isSameCoords =
|
||||
originalPlace.lat === selectedPlace.lat &&
|
||||
originalPlace.lon === selectedPlace.lon;
|
||||
|
||||
if (isSameOsmId || isSameId || isSameCoords) {
|
||||
return new Style({}); // Empty style makes it invisible
|
||||
}
|
||||
}
|
||||
|
||||
// Some search results might be just the place object without separate tags
|
||||
// If it's a raw place object, it might have osmTags property.
|
||||
// Or it might be the tags object itself.
|
||||
const tags = originalPlace.osmTags || originalPlace;
|
||||
const iconName = getIconNameForTags(tags);
|
||||
|
||||
// Use 'default' key for the standard red dot marker. Use iconName as key if present.
|
||||
const cacheKey = iconName || 'default';
|
||||
|
||||
if (!cachedIconUrls.has(cacheKey)) {
|
||||
const markerColor =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--marker-color-primary')
|
||||
.trim() || '#ea4335';
|
||||
|
||||
// Default content: Red circle
|
||||
let innerContent = `<circle cx="12" cy="12" r="8" fill="${markerColor}"/>`;
|
||||
|
||||
if (iconName) {
|
||||
const rawSvg = getIcon(iconName);
|
||||
if (rawSvg) {
|
||||
// Pinhead icons are usually 15x15 viewBox="0 0 15 15".
|
||||
// We want to center it on 12,12.
|
||||
// A 12x12 icon centered at 12,12 means top-left at 6,6.
|
||||
// However, since we are embedding a new SVG, we can just use x/y/width/height.
|
||||
// But we need to strip the outer <svg> tag to embed the paths cleanly if we want full control,
|
||||
// or we can nest the SVG. Nesting is safer.
|
||||
|
||||
// The rawSvg string contains <svg ...>...</svg>.
|
||||
// We want to make it white. We can add a group with fill="white".
|
||||
// But if the SVG has fill attributes, they override. Pinhead icons usually don't have fills.
|
||||
|
||||
// Let's strip the outer SVG tag to get the path content.
|
||||
let content = rawSvg.trim();
|
||||
const svgStart = content.indexOf('<svg');
|
||||
const svgEnd = content.indexOf('>', svgStart);
|
||||
const contentStart = svgEnd + 1;
|
||||
const contentEnd = content.lastIndexOf('</svg>');
|
||||
|
||||
if (svgStart !== -1 && contentEnd !== -1) {
|
||||
content = content.substring(contentStart, contentEnd);
|
||||
}
|
||||
|
||||
// We render the red circle background, then the icon on top.
|
||||
// Icon is scaled down slightly to fit nicely inside the circle.
|
||||
// 15x15 scaled by 0.8 is 12x12.
|
||||
// Translate to 6,6 to center.
|
||||
innerContent = `
|
||||
<circle cx="12" cy="12" r="8" fill="${markerColor}"/>
|
||||
<g transform="translate(6, 6) scale(0.8)" fill="white">
|
||||
${content}
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 40" width="40" height="50">
|
||||
<defs>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="1.5" flood-color="black" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12C2 17.5 12 24 12 24C12 24 22 17.5 22 12C22 6.5 17.5 2 12 2Z" fill="white" filter="url(#shadow)"/>
|
||||
${innerContent}
|
||||
</svg>
|
||||
`;
|
||||
|
||||
cachedIconUrls.set(
|
||||
cacheKey,
|
||||
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.trim())
|
||||
);
|
||||
}
|
||||
|
||||
return new Style({
|
||||
image: new Icon({
|
||||
src: cachedIconUrls.get(cacheKey),
|
||||
anchor: [0.5, 0.65],
|
||||
scale: 1,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const searchResultLayer = new VectorLayer({
|
||||
source: this.searchResultsSource,
|
||||
style: searchResultStyle,
|
||||
zIndex: 11, // Above bookmarks (10)
|
||||
});
|
||||
|
||||
// Default view settings
|
||||
let center = [14.21683569, 27.060114248];
|
||||
let zoom = 2.661;
|
||||
@@ -96,9 +261,14 @@ export default class MapComponent extends Component {
|
||||
projection: 'EPSG:3857',
|
||||
});
|
||||
|
||||
this.mapInstance = new Map({
|
||||
this.mapInstance = new OlMap({
|
||||
target: element,
|
||||
layers: [openfreemap, bookmarkLayer],
|
||||
layers: [
|
||||
openfreemap,
|
||||
selectedShapeLayer,
|
||||
searchResultLayer,
|
||||
bookmarkLayer,
|
||||
],
|
||||
view: view,
|
||||
controls: defaultControls({
|
||||
zoom: true,
|
||||
@@ -110,6 +280,10 @@ export default class MapComponent extends Component {
|
||||
}),
|
||||
});
|
||||
|
||||
// Initialize the UI service with the map center
|
||||
const initialCenter = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||
|
||||
this.searchOverlayElement = document.createElement('div');
|
||||
@@ -129,7 +303,7 @@ export default class MapComponent extends Component {
|
||||
const pinIcon = document.createElement('div');
|
||||
pinIcon.className = 'selected-pin';
|
||||
// Simple SVG for Map Pin
|
||||
pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`;
|
||||
pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: var(--marker-color-dark); stroke: none;"></circle></svg>`;
|
||||
|
||||
const pinShadow = document.createElement('div');
|
||||
pinShadow.className = 'selected-pin-shadow';
|
||||
@@ -155,9 +329,6 @@ export default class MapComponent extends Component {
|
||||
`;
|
||||
element.appendChild(this.crosshairElement);
|
||||
|
||||
|
||||
|
||||
|
||||
// Geolocation Pulse Overlay
|
||||
this.locationOverlayElement = document.createElement('div');
|
||||
this.locationOverlayElement.className = 'search-pulse blue';
|
||||
@@ -168,6 +339,18 @@ export default class MapComponent extends Component {
|
||||
});
|
||||
this.mapInstance.addOverlay(this.locationOverlay);
|
||||
|
||||
// Track search box focus state on pointer down to handle race conditions
|
||||
// The blur event fires before click, so we need to capture state here
|
||||
element.addEventListener(
|
||||
'pointerdown',
|
||||
() => {
|
||||
if (this.mapUi.searchBoxHasFocus) {
|
||||
this.ignoreNextMapClick = true;
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// Geolocation Setup
|
||||
const geolocation = new Geolocation({
|
||||
trackingOptions: {
|
||||
@@ -311,7 +494,7 @@ export default class MapComponent extends Component {
|
||||
};
|
||||
|
||||
const startLocating = () => {
|
||||
console.debug('Getting current geolocation...')
|
||||
console.debug('Getting current geolocation...');
|
||||
// 1. Clear any previous session
|
||||
stopLocating();
|
||||
|
||||
@@ -374,11 +557,15 @@ export default class MapComponent extends Component {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
// Remove existing DragPan interactions
|
||||
this.mapInstance.getInteractions().getArray().slice().forEach((interaction) => {
|
||||
if (interaction instanceof DragPan) {
|
||||
this.mapInstance.removeInteraction(interaction);
|
||||
}
|
||||
});
|
||||
this.mapInstance
|
||||
.getInteractions()
|
||||
.getArray()
|
||||
.slice()
|
||||
.forEach((interaction) => {
|
||||
if (interaction instanceof DragPan) {
|
||||
this.mapInstance.removeInteraction(interaction);
|
||||
}
|
||||
});
|
||||
|
||||
// Add new DragPan with current setting
|
||||
const kinetic = this.settings.mapKinetic
|
||||
@@ -402,12 +589,50 @@ export default class MapComponent extends Component {
|
||||
);
|
||||
});
|
||||
|
||||
updateSearchResults = modifier(() => {
|
||||
if (!this.searchResultsSource) return;
|
||||
|
||||
this.searchResultsSource.clear();
|
||||
const results = this.mapUi.searchResults;
|
||||
|
||||
if (!results || results.length === 0) return;
|
||||
|
||||
const features = [];
|
||||
results.forEach((place) => {
|
||||
if (place.lat && place.lon) {
|
||||
const feature = new Feature({
|
||||
geometry: new Point(fromLonLat([place.lon, place.lat])),
|
||||
name: place.title,
|
||||
id: place.id,
|
||||
isSearchResult: true,
|
||||
originalPlace: place,
|
||||
});
|
||||
features.push(feature);
|
||||
}
|
||||
});
|
||||
|
||||
if (features.length > 0) {
|
||||
this.searchResultsSource.addFeatures(features);
|
||||
}
|
||||
});
|
||||
|
||||
// Track the selected place from the UI Service (Router -> Map)
|
||||
updateSelectedPin = modifier(() => {
|
||||
const selected = this.mapUi.selectedPlace;
|
||||
const options = this.mapUi.selectionOptions || {};
|
||||
|
||||
// Force a redraw of the search results layer so it can hide/show the selected pin
|
||||
if (this.searchResultsSource) {
|
||||
this.searchResultsSource.changed();
|
||||
}
|
||||
|
||||
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||
|
||||
// Clear any previous shape
|
||||
if (this.selectedShapeSource) {
|
||||
this.selectedShapeSource.clear();
|
||||
}
|
||||
|
||||
if (selected && selected.lat && selected.lon) {
|
||||
const coords = fromLonLat([selected.lon, selected.lat]);
|
||||
this.selectedPinOverlay.setPosition(coords);
|
||||
@@ -418,7 +643,27 @@ export default class MapComponent extends Component {
|
||||
void this.selectedPinElement.offsetWidth;
|
||||
this.selectedPinElement.classList.add('active');
|
||||
|
||||
this.handlePinVisibility(coords);
|
||||
// Draw GeoJSON shape if available
|
||||
if (selected.geojson && this.selectedShapeSource) {
|
||||
try {
|
||||
const feature = new GeoJSON().readFeature(selected.geojson, {
|
||||
featureProjection: 'EPSG:3857',
|
||||
});
|
||||
this.selectedShapeSource.addFeature(feature);
|
||||
} catch (e) {
|
||||
console.warn('Failed to render selected place shape:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.preventZoom) {
|
||||
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
||||
// This avoids unnecessary panning if the place is already visible.
|
||||
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||
} else if (selected.bbox) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
this.handlePinVisibility(coords);
|
||||
}
|
||||
} else {
|
||||
this.selectedPinElement.classList.remove('active');
|
||||
// Hide it effectively by moving it away or just relying on display:none in CSS
|
||||
@@ -426,9 +671,73 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
handlePinVisibility(coords) {
|
||||
zoomToBbox(bbox) {
|
||||
if (!this.mapInstance || !bbox) return;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const size = this.mapInstance.getSize();
|
||||
|
||||
// Convert bbox to extent: [minx, miny, maxx, maxy]
|
||||
const min = fromLonLat([bbox.minLon, bbox.minLat]);
|
||||
const max = fromLonLat([bbox.maxLon, bbox.maxLat]);
|
||||
const extent = [...min, ...max];
|
||||
|
||||
// Default padding for full screen: 15% on all sides (70% visible)
|
||||
let padding = [
|
||||
size[1] * 0.15, // Top
|
||||
size[0] * 0.15, // Right
|
||||
size[1] * 0.15, // Bottom
|
||||
size[0] * 0.15, // Left
|
||||
];
|
||||
|
||||
// Mobile: Bottom sheet covers 50% of the screen height
|
||||
if (size[0] <= 768) {
|
||||
// We want the geometry to be centered in the top 50% of the screen.
|
||||
// Top padding: 15% of the VISIBLE height (size[1] * 0.5)
|
||||
const visibleHeight = size[1] * 0.5;
|
||||
const topPadding = visibleHeight * 0.15;
|
||||
const bottomPadding = size[1] * 0.5 + visibleHeight * 0.15; // Sheet + padding
|
||||
|
||||
padding[0] = topPadding;
|
||||
padding[2] = bottomPadding;
|
||||
}
|
||||
// Desktop: Sidebar covers left side (approx 400px)
|
||||
else if (this.args.isSidebarOpen) {
|
||||
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--sidebar-width')
|
||||
.trim();
|
||||
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||
const visibleWidth = size[0] - sidebarWidth;
|
||||
|
||||
// Left padding: Sidebar + 15% of visible width
|
||||
padding[3] = sidebarWidth + visibleWidth * 0.15;
|
||||
// Right padding: 15% of visible width
|
||||
padding[1] = visibleWidth * 0.15;
|
||||
}
|
||||
|
||||
const currentZoom = view.getZoom();
|
||||
|
||||
view.fit(extent, {
|
||||
padding: padding,
|
||||
duration: 1000,
|
||||
easing: (t) => t * (2 - t),
|
||||
maxZoom: Math.max(currentZoom, 18),
|
||||
});
|
||||
}
|
||||
|
||||
handlePinVisibility(coords, options = {}) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const currentZoom = view.getZoom();
|
||||
|
||||
// If too far out (e.g. world view), zoom in to neighborhood level (16)
|
||||
// UNLESS we want to maintain the current zoom
|
||||
if (!options.maintainZoom && currentZoom < 16) {
|
||||
this.animateToSmartCenter(coords, 16);
|
||||
return;
|
||||
}
|
||||
|
||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||
const size = this.mapInstance.getSize();
|
||||
|
||||
@@ -441,18 +750,27 @@ export default class MapComponent extends Component {
|
||||
pixel[1] > size[1];
|
||||
|
||||
if (isOffScreen) {
|
||||
this.animateToSmartCenter(coords);
|
||||
// If off-screen, center it smartly (considering sidebar/bottom sheet)
|
||||
// Pass maintainZoom to prevent zoom reset if desired
|
||||
const zoom = options.maintainZoom ? null : 16;
|
||||
this.animateToSmartCenter(coords, zoom);
|
||||
} else {
|
||||
// If on-screen, only pan if obscured by UI
|
||||
this.panIfObscured(coords);
|
||||
}
|
||||
}
|
||||
|
||||
animateToSmartCenter(coords) {
|
||||
animateToSmartCenter(coords, zoom = null) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const size = this.mapInstance.getSize();
|
||||
const view = this.mapInstance.getView();
|
||||
const resolution = view.getResolution();
|
||||
let resolution = view.getResolution();
|
||||
|
||||
if (zoom !== null) {
|
||||
resolution = view.getResolutionForZoom(zoom);
|
||||
}
|
||||
|
||||
let targetCenter = coords;
|
||||
|
||||
// Check if mobile (width <= 768px matches CSS)
|
||||
@@ -473,45 +791,113 @@ export default class MapComponent extends Component {
|
||||
// To move the camera South (Lower Y), we subtract.
|
||||
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
||||
}
|
||||
// Desktop: Check if sidebar is open
|
||||
else if (this.args.isSidebarOpen) {
|
||||
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--sidebar-width')
|
||||
.trim();
|
||||
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||
|
||||
view.animate({
|
||||
// We want the pin to be in the center of the remaining space.
|
||||
// Remaining space starts at x = sidebarWidth.
|
||||
// Center of remaining space = sidebarWidth + (totalWidth - sidebarWidth) / 2
|
||||
// = sidebarWidth/2 + totalWidth/2
|
||||
// Map Center is totalWidth/2
|
||||
// Offset = sidebarWidth/2 (to the right)
|
||||
|
||||
const offsetPixels = sidebarWidth / 2;
|
||||
const offsetMapUnits = offsetPixels * resolution;
|
||||
|
||||
// We want pin at center + offset.
|
||||
// So map center must be pin - offset.
|
||||
// X increases to the right.
|
||||
targetCenter = [coords[0] - offsetMapUnits, coords[1]];
|
||||
}
|
||||
|
||||
const animationOptions = {
|
||||
center: targetCenter,
|
||||
duration: 1000,
|
||||
easing: (t) => t * (2 - t), // Ease-out
|
||||
});
|
||||
};
|
||||
|
||||
if (zoom !== null) {
|
||||
animationOptions.zoom = zoom;
|
||||
}
|
||||
|
||||
view.animate(animationOptions);
|
||||
}
|
||||
|
||||
panIfObscured(coords) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const size = this.mapInstance.getSize();
|
||||
// Check if mobile (width <= 768px matches CSS)
|
||||
if (size[0] > 768) return;
|
||||
|
||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||
if (!pixel) return;
|
||||
|
||||
const height = size[1];
|
||||
const view = this.mapInstance.getView();
|
||||
const center = view.getCenter();
|
||||
const resolution = view.getResolution();
|
||||
|
||||
// Sidebar covers the bottom 50%
|
||||
const splitPoint = height / 2;
|
||||
// Default targets (current position)
|
||||
let targetPixelX = pixel[0];
|
||||
let targetPixelY = pixel[1];
|
||||
let needsPan = false;
|
||||
|
||||
// If the pin is in the bottom half (y > splitPoint), it is obscured
|
||||
if (pixel[1] > splitPoint) {
|
||||
// Target position: Center of top half = height * 0.25
|
||||
const targetY = height * 0.25;
|
||||
const deltaY = pixel[1] - targetY;
|
||||
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
||||
if (size[0] <= 768) {
|
||||
const height = size[1];
|
||||
const splitPoint = height / 2;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const center = view.getCenter();
|
||||
const resolution = view.getResolution();
|
||||
// If in bottom half
|
||||
if (pixel[1] > splitPoint) {
|
||||
targetPixelY = height * 0.25; // Target: Center of top half
|
||||
needsPan = true;
|
||||
}
|
||||
}
|
||||
// 2. Desktop Sidebar Logic (Screen > 768px + Sidebar Open)
|
||||
else if (this.args.isSidebarOpen) {
|
||||
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--sidebar-width')
|
||||
.trim();
|
||||
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||
|
||||
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
|
||||
const deltaMapUnits = deltaY * resolution;
|
||||
const newCenter = [center[0], center[1] - deltaMapUnits];
|
||||
// If under sidebar
|
||||
if (pixel[0] < sidebarWidth) {
|
||||
const visibleWidth = size[0] - sidebarWidth;
|
||||
targetPixelX = sidebarWidth + visibleWidth / 2; // Target: Center of visible area
|
||||
needsPan = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Header Logic (Any screen size)
|
||||
// Check if the (potentially new) target Y is under the header
|
||||
const headerHeight = 60;
|
||||
const minTopDistance = headerHeight + 20; // 80px
|
||||
|
||||
if (targetPixelY < minTopDistance) {
|
||||
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
|
||||
needsPan = true;
|
||||
}
|
||||
|
||||
if (needsPan) {
|
||||
const deltaPixelX = pixel[0] - targetPixelX;
|
||||
const deltaPixelY = pixel[1] - targetPixelY;
|
||||
|
||||
// X: Camera moves same direction as we want the world to move? No.
|
||||
// If we want pin to move RIGHT (pixel increases), Camera must move LEFT (X decreases).
|
||||
// deltaPixelX = current - target. If current < target (want move right), delta is negative.
|
||||
// center + negative = decrease. Correct.
|
||||
const newCenterX = center[0] + deltaPixelX * resolution;
|
||||
|
||||
// Y: Camera moves opposite direction to world relative to pixel coords.
|
||||
// Pixel Y increases DOWN. Map Y increases UP.
|
||||
// If we want pin to move DOWN (pixel increases), Camera must move UP (Y increases).
|
||||
// deltaPixelY = current - target. If current < target (want move down), delta is negative.
|
||||
// center - negative = increase. Correct.
|
||||
const newCenterY = center[1] - deltaPixelY * resolution;
|
||||
|
||||
view.animate({
|
||||
center: newCenter,
|
||||
center: [newCenterX, newCenterY],
|
||||
duration: 500,
|
||||
easing: (t) => t * (2 - t), // Ease-out
|
||||
});
|
||||
@@ -644,18 +1030,29 @@ export default class MapComponent extends Component {
|
||||
handleMapMove = async () => {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const center = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(center[1], center[0]);
|
||||
|
||||
// If in creation mode, update the coordinates in the service AND the URL
|
||||
if (this.mapUi.isCreating) {
|
||||
// Calculate coordinates under the crosshair element
|
||||
// We need the pixel position of the crosshair relative to the map viewport
|
||||
// The crosshair is positioned via CSS, so we can use getBoundingClientRect
|
||||
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
|
||||
const mapRect = this.mapInstance
|
||||
.getTargetElement()
|
||||
.getBoundingClientRect();
|
||||
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
||||
|
||||
const centerX = crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
||||
const centerY = crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
||||
const centerX =
|
||||
crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
||||
const centerY =
|
||||
crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
||||
|
||||
const coordinate = this.mapInstance.getCoordinateFromPixel([centerX, centerY]);
|
||||
const coordinate = this.mapInstance.getCoordinateFromPixel([
|
||||
centerX,
|
||||
centerY,
|
||||
]);
|
||||
const center = toLonLat(coordinate);
|
||||
|
||||
const lat = parseFloat(center[1].toFixed(6));
|
||||
@@ -674,6 +1071,7 @@ export default class MapComponent extends Component {
|
||||
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
|
||||
|
||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||
this.mapUi.updateBounds(bbox);
|
||||
await this.storage.loadPlacesInBounds(bbox);
|
||||
this.loadBookmarks(this.storage.placesInView);
|
||||
|
||||
@@ -695,11 +1093,17 @@ export default class MapComponent extends Component {
|
||||
};
|
||||
|
||||
handleMapClick = async (event) => {
|
||||
if (this.ignoreNextMapClick) {
|
||||
this.ignoreNextMapClick = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user clicked on a rendered feature (POI or Bookmark) FIRST
|
||||
const features = this.mapInstance.getFeaturesAtPixel(event.pixel, {
|
||||
hitTolerance: 10,
|
||||
});
|
||||
let clickedBookmark = null;
|
||||
let clickedSearchResult = null;
|
||||
let selectedFeatureName = null;
|
||||
|
||||
if (features && features.length > 0) {
|
||||
@@ -708,8 +1112,12 @@ export default class MapComponent extends Component {
|
||||
console.debug(f);
|
||||
}
|
||||
const bookmarkFeature = features.find((f) => f.get('isBookmark'));
|
||||
const searchResultFeature = features.find((f) => f.get('isSearchResult'));
|
||||
|
||||
if (bookmarkFeature) {
|
||||
clickedBookmark = bookmarkFeature.get('originalPlace');
|
||||
} else if (searchResultFeature) {
|
||||
clickedSearchResult = searchResultFeature.get('originalPlace');
|
||||
}
|
||||
// Also get visual props for standard map click logic later
|
||||
const props = features[0].getProperties();
|
||||
@@ -718,15 +1126,30 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to transition with proper state
|
||||
const transitionToPlace = (place) => {
|
||||
// If we are currently in search mode OR have active search results,
|
||||
// we want the "Back" button on the place details to return to the search results.
|
||||
if (
|
||||
this.router.currentRouteName === 'search' ||
|
||||
(this.mapUi.currentSearch && this.mapUi.searchResults.length > 0)
|
||||
) {
|
||||
this.mapUi.returnToSearch = true;
|
||||
}
|
||||
this.mapUi.preventNextZoom = true;
|
||||
this.router.transitionTo('place', place);
|
||||
};
|
||||
|
||||
// Special handling when sidebar is OPEN
|
||||
if (this.args.isSidebarOpen) {
|
||||
// If it's a bookmark, we allow "switching" to it even if sidebar is open
|
||||
if (clickedBookmark) {
|
||||
// If it's a bookmark or search result, we allow "switching" to it even if sidebar is open
|
||||
const targetPlace = clickedBookmark || clickedSearchResult;
|
||||
if (targetPlace) {
|
||||
console.debug(
|
||||
'Clicked bookmark while sidebar open (switching):',
|
||||
clickedBookmark
|
||||
'Clicked feature while sidebar open (switching):',
|
||||
targetPlace
|
||||
);
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
transitionToPlace(targetPlace);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -740,7 +1163,13 @@ export default class MapComponent extends Component {
|
||||
// Normal behavior (sidebar is closed)
|
||||
if (clickedBookmark) {
|
||||
console.debug('Clicked bookmark:', clickedBookmark);
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
transitionToPlace(clickedBookmark);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickedSearchResult) {
|
||||
console.debug('Clicked search result:', clickedSearchResult);
|
||||
transitionToPlace(clickedSearchResult);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -780,10 +1209,10 @@ export default class MapComponent extends Component {
|
||||
const queryParams = {
|
||||
lat: lat.toFixed(6),
|
||||
lon: lon.toFixed(6),
|
||||
q: null, // Clear q to force spatial search
|
||||
category: null, // Clear category to force spatial search
|
||||
selected: selectedFeatureName || null,
|
||||
};
|
||||
if (selectedFeatureName) {
|
||||
queryParams.q = selectedFeatureName;
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', { queryParams });
|
||||
};
|
||||
@@ -794,6 +1223,7 @@ export default class MapComponent extends Component {
|
||||
{{this.setupMap}}
|
||||
{{this.updateInteractions}}
|
||||
{{this.updateBookmarks}}
|
||||
{{this.updateSearchResults}}
|
||||
{{this.updateSelectedPin}}
|
||||
{{this.syncPulse}}
|
||||
{{this.syncCreationMode}}
|
||||
|
||||
@@ -1,36 +1,50 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { fn } from '@ember/helper';
|
||||
import { service } from '@ember/service';
|
||||
import { on } from '@ember/modifier';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import { humanizeOsmTag } from '../utils/format-text';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
import { mapToStorageSchema } from '../utils/place-mapping';
|
||||
import { getSocialInfo } from '../utils/social-links';
|
||||
import Icon from '../components/icon';
|
||||
import PlaceEditForm from './place-edit-form';
|
||||
import PlaceListsManager from './place-lists-manager';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class PlaceDetails extends Component {
|
||||
@service storage;
|
||||
@tracked isEditing = false;
|
||||
@tracked showLists = false;
|
||||
|
||||
get isSaved() {
|
||||
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
||||
}
|
||||
|
||||
get place() {
|
||||
return this.args.place || {};
|
||||
}
|
||||
|
||||
get saveablePlace() {
|
||||
if (this.place.createdAt) {
|
||||
return this.place;
|
||||
}
|
||||
|
||||
return mapToStorageSchema(this.place);
|
||||
}
|
||||
|
||||
get tags() {
|
||||
return this.place.osmTags || {};
|
||||
}
|
||||
|
||||
get name() {
|
||||
return (
|
||||
this.place.title ||
|
||||
this.tags.name ||
|
||||
this.tags['name:en'] ||
|
||||
'Unnamed Place'
|
||||
);
|
||||
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
|
||||
}
|
||||
|
||||
@action
|
||||
startEditing() {
|
||||
if (!this.place.createdAt) return; // Only allow editing saved places
|
||||
if (!this.isSaved) return; // Only allow editing saved places
|
||||
this.isEditing = true;
|
||||
}
|
||||
|
||||
@@ -39,6 +53,21 @@ export default class PlaceDetails extends Component {
|
||||
this.isEditing = false;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleLists(event) {
|
||||
// Prevent this click from propagating to the document listener
|
||||
// which handles the "click outside" logic.
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.showLists = !this.showLists;
|
||||
}
|
||||
|
||||
@action
|
||||
closeLists() {
|
||||
this.showLists = false;
|
||||
}
|
||||
|
||||
@action
|
||||
async saveChanges(changes) {
|
||||
if (this.args.onSave) {
|
||||
@@ -51,58 +80,118 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
get type() {
|
||||
const rawType =
|
||||
this.tags.amenity ||
|
||||
this.tags.shop ||
|
||||
this.tags.tourism ||
|
||||
this.tags.leisure ||
|
||||
this.tags.historic ||
|
||||
'Point of Interest';
|
||||
|
||||
return humanizeOsmTag(rawType);
|
||||
return getPlaceType(this.tags);
|
||||
}
|
||||
|
||||
get address() {
|
||||
const t = this.tags;
|
||||
const parts = [];
|
||||
|
||||
// Helper to get value from multiple keys
|
||||
const get = (...keys) => {
|
||||
for (const k of keys) {
|
||||
if (t[k]) return t[k];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Street + Number
|
||||
if (t['addr:street']) {
|
||||
let street = t['addr:street'];
|
||||
if (t['addr:housenumber']) {
|
||||
street += ` ${t['addr:housenumber']}`;
|
||||
let street = get('addr:street', 'street');
|
||||
const number = get('addr:housenumber', 'housenumber');
|
||||
|
||||
if (street) {
|
||||
if (number) {
|
||||
street = `${street} ${number}`;
|
||||
}
|
||||
parts.push(street);
|
||||
}
|
||||
|
||||
// Postcode + City
|
||||
if (t['addr:city']) {
|
||||
let city = t['addr:city'];
|
||||
if (t['addr:postcode']) {
|
||||
city = `${t['addr:postcode']} ${city}`;
|
||||
let city = get('addr:city', 'city');
|
||||
const postcode = get('addr:postcode', 'postcode');
|
||||
|
||||
if (city) {
|
||||
if (postcode) {
|
||||
city = `${postcode} ${city}`;
|
||||
}
|
||||
parts.push(city);
|
||||
}
|
||||
|
||||
// State + Country (if not already covered)
|
||||
const state = get('addr:state', 'state');
|
||||
const country = get('addr:country', 'country');
|
||||
|
||||
if (state && state !== city) parts.push(state);
|
||||
if (country) parts.push(country);
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
formatMultiLine(val, type) {
|
||||
if (!val) return null;
|
||||
const parts = val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
if (type === 'phone') {
|
||||
return htmlSafe(
|
||||
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'email') {
|
||||
return htmlSafe(
|
||||
parts.map((p) => `<a href="mailto:${p}">${p}</a>`).join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'url') {
|
||||
return htmlSafe(
|
||||
parts
|
||||
.map(
|
||||
(url) =>
|
||||
`<a href="${url}" target="_blank" rel="noopener noreferrer">${this.getDomain(
|
||||
url
|
||||
)}</a>`
|
||||
)
|
||||
.join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
return htmlSafe(parts.join('<br>'));
|
||||
}
|
||||
|
||||
get phone() {
|
||||
return this.tags.phone || this.tags['contact:phone'];
|
||||
const val = this.tags.phone || this.tags['contact:phone'];
|
||||
return this.formatMultiLine(val, 'phone');
|
||||
}
|
||||
|
||||
get email() {
|
||||
const val = this.tags.email || this.tags['contact:email'];
|
||||
return this.formatMultiLine(val, 'email');
|
||||
}
|
||||
|
||||
get website() {
|
||||
return this.place.url || this.tags.website || this.tags['contact:website'];
|
||||
const val =
|
||||
this.place.url || this.tags.website || this.tags['contact:website'];
|
||||
return this.formatMultiLine(val, 'url');
|
||||
}
|
||||
|
||||
get websiteDomain() {
|
||||
const url = new URL(this.website);
|
||||
return url.hostname;
|
||||
getDomain(urlStr) {
|
||||
try {
|
||||
const url = new URL(urlStr);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return urlStr;
|
||||
}
|
||||
}
|
||||
|
||||
get openingHours() {
|
||||
return this.tags.opening_hours;
|
||||
const val = this.tags.opening_hours;
|
||||
return this.formatMultiLine(val);
|
||||
}
|
||||
|
||||
get cuisine() {
|
||||
@@ -113,8 +202,21 @@ export default class PlaceDetails extends Component {
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
get facebook() {
|
||||
return getSocialInfo(this.tags, 'facebook');
|
||||
}
|
||||
|
||||
get instagram() {
|
||||
return getSocialInfo(this.tags, 'instagram');
|
||||
}
|
||||
|
||||
get wikipedia() {
|
||||
return this.tags.wikipedia;
|
||||
const val = this.tags.wikipedia;
|
||||
if (!val) return null;
|
||||
return val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)[0];
|
||||
}
|
||||
|
||||
get geoLink() {
|
||||
@@ -129,7 +231,7 @@ export default class PlaceDetails extends Component {
|
||||
const lat = this.place.lat;
|
||||
const lon = this.place.lon;
|
||||
if (!lat || !lon) return '';
|
||||
return `${lat}, ${lon}`;
|
||||
return `${Number(lat).toFixed(6)}, ${Number(lon).toFixed(6)}`;
|
||||
}
|
||||
|
||||
get osmUrl() {
|
||||
@@ -145,6 +247,16 @@ export default class PlaceDetails extends Component {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
|
||||
}
|
||||
|
||||
get showDescription() {
|
||||
// If it's a Photon result, the description IS the address.
|
||||
// Since we are showing the address in the meta section (bottom),
|
||||
// we should hide the description to avoid duplication.
|
||||
if (this.place.source === 'photon') return false;
|
||||
|
||||
// Otherwise (e.g. saved place with custom description), show it.
|
||||
return !!this.place.description;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="place-details">
|
||||
{{#if this.isEditing}}
|
||||
@@ -158,7 +270,7 @@ export default class PlaceDetails extends Component {
|
||||
<p class="place-type">
|
||||
{{this.type}}
|
||||
</p>
|
||||
{{#if this.place.description}}
|
||||
{{#if this.showDescription}}
|
||||
<p class="place-description">
|
||||
{{this.place.description}}
|
||||
</p>
|
||||
@@ -166,30 +278,37 @@ export default class PlaceDetails extends Component {
|
||||
{{/if}}
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class={{if
|
||||
this.place.createdAt
|
||||
"btn btn-secondary"
|
||||
"btn btn-outline"
|
||||
}}
|
||||
{{on "click" (fn @onToggleSave this.place)}}
|
||||
>
|
||||
<Icon
|
||||
@name="bookmark"
|
||||
@color={{if this.place.createdAt "currentColor" "#007bff"}}
|
||||
/>
|
||||
{{if this.place.createdAt "Saved" "Save"}}
|
||||
</button>
|
||||
<div class="save-button-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class={{if this.isSaved "btn btn-secondary" "btn btn-outline"}}
|
||||
{{on "click" this.toggleLists}}
|
||||
>
|
||||
<Icon
|
||||
@name="bookmark"
|
||||
@color={{if this.isSaved "currentColor" "var(--link-color)"}}
|
||||
/>
|
||||
{{if this.isSaved "Saved" "Save"}}
|
||||
</button>
|
||||
|
||||
{{#if this.place.createdAt}}
|
||||
{{#if this.showLists}}
|
||||
<PlaceListsManager
|
||||
@place={{this.saveablePlace}}
|
||||
@onClose={{this.closeLists}}
|
||||
@isSaved={{this.isSaved}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if this.isSaved}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
title="Edit"
|
||||
disabled={{this.isEditing}}
|
||||
{{on "click" this.startEditing}}
|
||||
>
|
||||
<Icon @name="edit" @color="#007bff" />
|
||||
<Icon @name="edit" @color="var(--link-color)" />
|
||||
Edit
|
||||
</button>
|
||||
{{/if}}
|
||||
@@ -198,45 +317,92 @@ export default class PlaceDetails extends Component {
|
||||
<div class="meta-info">
|
||||
|
||||
{{#if this.cuisine}}
|
||||
<p>
|
||||
<strong>Cuisine:</strong>
|
||||
{{this.cuisine}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="fork-and-knife" @title="Cuisine" />
|
||||
<span>
|
||||
{{this.cuisine}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.openingHours}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="clock" @title="Opening hours" />
|
||||
<span>{{this.openingHours}}</span>
|
||||
<span>
|
||||
{{this.openingHours}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.phone}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="phone" @title="Phone" />
|
||||
<span><a href="tel:{{this.phone}}">{{this.phone}}</a></span>
|
||||
<span>
|
||||
{{this.phone}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.website}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="globe" @title="Website" />
|
||||
<span><a
|
||||
href={{this.website}}
|
||||
<span>
|
||||
{{this.website}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.email}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="mail" @title="Email" />
|
||||
<span>
|
||||
{{this.email}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.facebook}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="facebook" @title="Facebook" />
|
||||
<span>
|
||||
<a
|
||||
href={{this.facebook.url}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{this.websiteDomain}}</a></span>
|
||||
>
|
||||
{{this.facebook.username}}
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.instagram}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="instagram" @title="Instagram" />
|
||||
<span>
|
||||
<a
|
||||
href={{this.instagram.url}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{this.instagram.username}}
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.wikipedia}}
|
||||
<p>
|
||||
<strong>Wikipedia:</strong>
|
||||
<a
|
||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Article</a>
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="wikipedia" @title="Wikipedia" />
|
||||
<span>
|
||||
<a
|
||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Wikipedia
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
@@ -274,7 +440,11 @@ export default class PlaceDetails extends Component {
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="map" />
|
||||
<span>
|
||||
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={{this.gmapsUrl}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Google Maps
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -45,6 +45,7 @@ export default class PlaceEditForm extends Component {
|
||||
<form class="edit-form" {{on "submit" this.handleSubmit}}>
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title</label>
|
||||
{{! template-lint-disable no-autofocus-attribute }}
|
||||
<input
|
||||
id="edit-title"
|
||||
type="text"
|
||||
|
||||
135
app/components/place-lists-manager.gjs
Normal file
135
app/components/place-lists-manager.gjs
Normal file
@@ -0,0 +1,135 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import onClickOutside from '../modifiers/on-click-outside';
|
||||
|
||||
export default class PlaceListsManager extends Component {
|
||||
@service storage;
|
||||
@service router;
|
||||
@tracked _forceClear = false;
|
||||
|
||||
get isSaved() {
|
||||
return this.args.isSaved;
|
||||
}
|
||||
|
||||
get placeListIds() {
|
||||
if (this._forceClear) return [];
|
||||
return this.args.place._listIds || [];
|
||||
}
|
||||
|
||||
styleFor(color) {
|
||||
return htmlSafe(`background-color: ${color}`);
|
||||
}
|
||||
|
||||
@action
|
||||
isInList(list) {
|
||||
if (!this.placeListIds) return false;
|
||||
return this.placeListIds.includes(list.id);
|
||||
}
|
||||
|
||||
@action
|
||||
async toggleSaved() {
|
||||
if (this.isSaved) {
|
||||
const { osmId, osmType } = this.args.place;
|
||||
|
||||
await this.storage.removePlace(this.args.place);
|
||||
|
||||
// Clean up the local object reference immediately to prevent UI flicker
|
||||
// or stale state if the transition is delayed/cancelled.
|
||||
if (this.args.place) {
|
||||
this.args.place.id = null;
|
||||
this.args.place.createdAt = null;
|
||||
this.args.place._listIds = [];
|
||||
this._forceClear = true;
|
||||
}
|
||||
|
||||
// Transition immediately to the canonical state
|
||||
if (osmId && osmType) {
|
||||
// Create a transient copy that looks like a fresh OSM result
|
||||
const rawPlace = { ...this.args.place };
|
||||
delete rawPlace.id;
|
||||
delete rawPlace.createdAt;
|
||||
delete rawPlace._listIds;
|
||||
|
||||
// Transition to the place route using the raw object
|
||||
// This updates the URL to 'osm:...' and renders immediately
|
||||
this.router.transitionTo('place', rawPlace);
|
||||
} else {
|
||||
// Custom place deleted -> go home
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
if (this.args.onClose) this.args.onClose();
|
||||
} else {
|
||||
await this.storage.storePlace(this.args.place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async toggleList(list) {
|
||||
const isMember = this.placeListIds.includes(list.id);
|
||||
const shouldAdd = !isMember;
|
||||
|
||||
if (shouldAdd && !this.isSaved) {
|
||||
// Auto-save if adding to list
|
||||
await this.storage.storePlace(this.args.place);
|
||||
}
|
||||
|
||||
try {
|
||||
// Toggle membership
|
||||
// We must pass the SAVED place (with ID) to the toggle function
|
||||
// If we just saved it above, the args.place might still be the old object reference unless storage updates it in-place?
|
||||
// StorageService.storePlace returns the new object.
|
||||
// But togglePlaceList handles saving internally if ID is missing.
|
||||
|
||||
// Let's rely on storage.togglePlaceList to handle the "save if needed" part.
|
||||
await this.storage.togglePlaceList(this.args.place, list.id, shouldAdd);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Failed to update list: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="place-lists-manager" {{onClickOutside @onClose}}>
|
||||
<div class="list-item master-toggle">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={{this.isSaved}}
|
||||
{{on "change" this.toggleSaved}}
|
||||
/>
|
||||
<span class="list-color"></span>
|
||||
<span class="list-name">Saved places</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="lists-container">
|
||||
{{#each this.storage.lists as |list|}}
|
||||
<div class="list-item">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={{this.isInList list}}
|
||||
{{on "change" (fn this.toggleList list)}}
|
||||
disabled={{unless this.isSaved true}}
|
||||
/>
|
||||
{{! template-lint-disable no-inline-styles }}
|
||||
<span
|
||||
class="list-color"
|
||||
style={{this.styleFor list.color}}
|
||||
></span>
|
||||
<span class="list-name">{{list.title}}</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import PlaceDetails from './place-details';
|
||||
import Icon from './icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@service storage;
|
||||
@@ -22,8 +24,10 @@ export default class PlacesSidebar extends Component {
|
||||
if (lat && lon) {
|
||||
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
|
||||
} else {
|
||||
// Fallback (shouldn't happen in search context)
|
||||
this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } });
|
||||
// Fallback (shouldn't happen in search context)
|
||||
this.router.transitionTo('place.new', {
|
||||
queryParams: { lat: 0, lon: 0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,46 +51,44 @@ export default class PlacesSidebar extends Component {
|
||||
if (!place) return;
|
||||
|
||||
if (place.createdAt) {
|
||||
if (confirm(`Delete "${place.title}"?`)) {
|
||||
try {
|
||||
await this.storage.removePlace(place);
|
||||
console.debug('Place deleted:', place.title);
|
||||
// Direct delete without confirmation
|
||||
try {
|
||||
await this.storage.removePlace(place);
|
||||
console.debug('Place deleted:', place.title);
|
||||
|
||||
// Notify parent to refresh map bookmarks
|
||||
if (this.args.onBookmarkChange) {
|
||||
this.args.onBookmarkChange();
|
||||
}
|
||||
|
||||
if (this.args.onUpdate) {
|
||||
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
||||
const freshPlace = {
|
||||
...place,
|
||||
id: undefined,
|
||||
geohash: undefined,
|
||||
createdAt: undefined,
|
||||
};
|
||||
this.args.onUpdate(freshPlace);
|
||||
}
|
||||
|
||||
// Also fire onSelect if it exists (for list view)
|
||||
if (this.args.onSelect) {
|
||||
this.args.onSelect(null);
|
||||
}
|
||||
|
||||
// Close sidebar after delete
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e);
|
||||
alert('Failed to delete: ' + e.message);
|
||||
// Notify parent to refresh map bookmarks
|
||||
if (this.args.onBookmarkChange) {
|
||||
this.args.onBookmarkChange();
|
||||
}
|
||||
|
||||
if (this.args.onUpdate) {
|
||||
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
||||
const freshPlace = {
|
||||
...place,
|
||||
id: undefined,
|
||||
geohash: undefined,
|
||||
createdAt: undefined,
|
||||
};
|
||||
this.args.onUpdate(freshPlace);
|
||||
}
|
||||
|
||||
// Also fire onSelect if it exists (for list view)
|
||||
if (this.args.onSelect) {
|
||||
this.args.onSelect(null);
|
||||
}
|
||||
|
||||
// Close sidebar after delete
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e);
|
||||
alert('Failed to delete: ' + e.message);
|
||||
}
|
||||
} else {
|
||||
// It's a fresh POI -> Save it
|
||||
const placeData = {
|
||||
title:
|
||||
place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
|
||||
title: getLocalizedName(place.osmTags, 'Untitled Place'),
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
tags: [],
|
||||
@@ -142,6 +144,11 @@ export default class PlacesSidebar extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
get isNearbySearch() {
|
||||
const qp = this.router.currentRoute.queryParams;
|
||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
@@ -152,7 +159,12 @@ export default class PlacesSidebar extends Component {
|
||||
{{on "click" this.clearSelection}}
|
||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||
{{else}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
|
||||
{{#if this.isNearbySearch}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||
Nearby</h2>
|
||||
{{else}}
|
||||
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||
@name="x"
|
||||
@@ -184,20 +196,29 @@ export default class PlacesSidebar extends Component {
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">{{humanizeOsmTag (or
|
||||
place.osmTags.amenity
|
||||
place.osmTags.shop
|
||||
place.osmTags.tourism
|
||||
place.osmTags.leisure
|
||||
place.osmTags.historic
|
||||
"Point of Interest"
|
||||
)}}</div>
|
||||
<div class="place-type">
|
||||
{{#if (eq place.source "osm")}}
|
||||
{{humanizeOsmTag place.type}}
|
||||
{{else if (eq place.source "photon")}}
|
||||
{{place.description}}
|
||||
{{else}}
|
||||
{{#if place.osmTags}}
|
||||
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
||||
{{else if place.description}}
|
||||
{{place.description}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{else}}
|
||||
<p class="empty-state">No results found.</p>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
@@ -205,7 +226,7 @@ export default class PlacesSidebar extends Component {
|
||||
class="btn btn-outline create-place"
|
||||
{{on "click" this.createNewPlace}}
|
||||
>
|
||||
<Icon @name="plus" @size={{18}} @color="#007bff" />
|
||||
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||
Create new place
|
||||
</button>
|
||||
{{/if}}
|
||||
|
||||
281
app/components/search-box.gjs
Normal file
281
app/components/search-box.gjs
Normal file
@@ -0,0 +1,281 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { task, timeout } from 'ember-concurrency';
|
||||
import Icon from '#components/icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||
import { eq, or } from 'ember-truth-helpers';
|
||||
|
||||
export default class SearchBoxComponent extends Component {
|
||||
@service photon;
|
||||
@service osm;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service map; // Assuming we might need map context, but mostly we use router
|
||||
|
||||
@tracked _internalQuery = '';
|
||||
@tracked results = [];
|
||||
@tracked isFocused = false;
|
||||
@tracked isLoading = false;
|
||||
|
||||
get query() {
|
||||
return this.args.query ?? this._internalQuery;
|
||||
}
|
||||
|
||||
set query(value) {
|
||||
this._internalQuery = value;
|
||||
}
|
||||
|
||||
get showPopover() {
|
||||
return this.isFocused && this.results.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
handleInput(event) {
|
||||
const value = event.target.value;
|
||||
this.query = value;
|
||||
if (this.args.onQueryChange) {
|
||||
this.args.onQueryChange(value);
|
||||
}
|
||||
|
||||
if (value.length < 2) {
|
||||
this.results = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchTask.perform(value);
|
||||
}
|
||||
|
||||
searchTask = task({ restartable: true }, async (term) => {
|
||||
await timeout(300);
|
||||
|
||||
const query = typeof term === 'string' ? term : this.query;
|
||||
|
||||
if (query.length < 2) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
// Use map center if available for location bias
|
||||
let lat, lon;
|
||||
if (this.mapUi.currentCenter) {
|
||||
({ lat, lon } = this.mapUi.currentCenter);
|
||||
}
|
||||
|
||||
// Filter categories
|
||||
const q = query.toLowerCase();
|
||||
const categoryMatches = POI_CATEGORIES.filter((c) =>
|
||||
c.label.toLowerCase().includes(q)
|
||||
).map((c) => ({
|
||||
source: 'category',
|
||||
title: c.label,
|
||||
id: c.id,
|
||||
icon: 'search',
|
||||
}));
|
||||
|
||||
const results = await this.photon.search(query, lat, lon);
|
||||
this.results = [...categoryMatches, ...results];
|
||||
} catch (e) {
|
||||
console.error('Search failed', e);
|
||||
this.results = [];
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
@action
|
||||
handleFocus() {
|
||||
this.isFocused = true;
|
||||
this.mapUi.setSearchBoxFocus(true);
|
||||
if (this.query.length >= 2 && this.results.length === 0) {
|
||||
this.searchTask.perform();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleBlur() {
|
||||
// Delay hiding so clicks on results can register
|
||||
setTimeout(() => {
|
||||
this.isFocused = false;
|
||||
this.mapUi.setSearchBoxFocus(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@action
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!this.query) return;
|
||||
|
||||
let queryParams = { q: this.query, selected: null, category: null };
|
||||
|
||||
if (this.mapUi.currentCenter) {
|
||||
const { lat, lon } = this.mapUi.currentCenter;
|
||||
queryParams.lat = parseFloat(lat).toFixed(4);
|
||||
queryParams.lon = parseFloat(lon).toFixed(4);
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', { queryParams });
|
||||
this.isFocused = false;
|
||||
}
|
||||
|
||||
@action
|
||||
selectResult(place) {
|
||||
if (place.source === 'category') {
|
||||
this.query = place.title;
|
||||
if (this.args.onQueryChange) {
|
||||
this.args.onQueryChange(place.title);
|
||||
}
|
||||
this.results = [];
|
||||
|
||||
let lat = null,
|
||||
lon = null;
|
||||
if (this.mapUi.currentCenter) {
|
||||
({ lat, lon } = this.mapUi.currentCenter);
|
||||
lat = lat?.toString();
|
||||
lon = lon?.toString();
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: {
|
||||
q: place.title,
|
||||
category: place.id,
|
||||
selected: null,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.query = place.title;
|
||||
if (this.args.onQueryChange) {
|
||||
this.args.onQueryChange(place.title);
|
||||
}
|
||||
this.results = []; // Hide popover
|
||||
|
||||
// If it has an OSM ID, go to place details
|
||||
if (place.osmId) {
|
||||
// Format: osm:node:123
|
||||
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
||||
const id = `osm:${place.osmType}:${place.osmId}`;
|
||||
this.router.transitionTo('place', id);
|
||||
} else {
|
||||
// Just a location (e.g. from Photon without OSM ID, though unlikely for Photon)
|
||||
// Or we can treat it as a search query
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: {
|
||||
q: place.title,
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
selected: null,
|
||||
category: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
clear() {
|
||||
this.searchTask.cancelAll();
|
||||
this.mapUi.stopLoading();
|
||||
this.osm.cancelAll();
|
||||
this.photon.cancelAll();
|
||||
|
||||
this.query = '';
|
||||
this.results = [];
|
||||
if (this.args.onQueryChange) {
|
||||
this.args.onQueryChange('');
|
||||
}
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="search-box">
|
||||
<form class="search-form" {{on "submit" this.handleSubmit}}>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn-integrated"
|
||||
aria-label="Menu"
|
||||
{{on "click" @onToggleMenu}}
|
||||
>
|
||||
<Icon @name="menu" @size={{20}} @color="#5f6368" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search places..."
|
||||
aria-label="Search places"
|
||||
value={{this.query}}
|
||||
{{on "input" this.handleInput}}
|
||||
{{on "focus" this.handleFocus}}
|
||||
{{on "blur" this.handleBlur}}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<button type="submit" class="search-submit-btn" aria-label="Search">
|
||||
{{#if
|
||||
(or
|
||||
(eq this.mapUi.loadingState.type "text")
|
||||
(eq this.mapUi.loadingState.type "category")
|
||||
)
|
||||
}}
|
||||
<Icon @name="loading-ring" @size={{20}} />
|
||||
{{else}}
|
||||
<Icon @name="search" @size={{20}} @color="#5f6368" />
|
||||
{{/if}}
|
||||
</button>
|
||||
|
||||
{{#if this.query}}
|
||||
<button
|
||||
type="button"
|
||||
class="search-clear-btn"
|
||||
{{on "click" this.clear}}
|
||||
aria-label="Clear"
|
||||
>
|
||||
<Icon @name="x" @size={{20}} @color="#5f6368" />
|
||||
</button>
|
||||
{{/if}}
|
||||
</form>
|
||||
|
||||
{{#if this.showPopover}}
|
||||
<div class="search-results-popover">
|
||||
<ul class="search-results-list">
|
||||
{{#each this.results as |result|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="search-result-item"
|
||||
{{on "click" (fn this.selectResult result)}}
|
||||
>
|
||||
<div class="result-icon">
|
||||
<Icon
|
||||
@name={{if result.icon result.icon "map-pin"}}
|
||||
@size={{16}}
|
||||
@color="#666"
|
||||
/>
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<span class="result-title">{{result.title}}</span>
|
||||
{{#if (eq result.source "osm")}}
|
||||
<span class="result-desc">{{humanizeOsmTag
|
||||
result.type
|
||||
}}</span>
|
||||
{{else}}
|
||||
{{#if result.description}}
|
||||
<span class="result-desc">{{result.description}}</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import Icon from '#components/icon';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import not from 'ember-truth-helpers/helpers/not';
|
||||
|
||||
export default class SettingsPane extends Component {
|
||||
@service settings;
|
||||
|
||||
@action
|
||||
updateApi(event) {
|
||||
this.settings.updateOverpassApi(event.target.value);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleKinetic(event) {
|
||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar settings-pane">
|
||||
<div class="sidebar-header">
|
||||
<h2>Marco</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<section class="settings-section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||
<select
|
||||
id="map-kinetic"
|
||||
class="form-control"
|
||||
{{on "change" this.toggleKinetic}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
On
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{if (not this.settings.mapKinetic) "selected"}}
|
||||
>
|
||||
Off
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overpass-api">Overpass API Provider</label>
|
||||
<select
|
||||
id="overpass-api"
|
||||
class="form-control"
|
||||
{{on "change" this.updateApi}}
|
||||
>
|
||||
{{#each this.settings.overpassApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if (eq api.url this.settings.overpassApi) "selected"}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<h3>About</h3>
|
||||
<p>
|
||||
<strong>Marco</strong> (as in <a
|
||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||
target="_blank" rel="noopener">Marco Polo</a>) is an unhosted maps application
|
||||
that respects your privacy and choices.
|
||||
</p>
|
||||
<p>
|
||||
Connect your own <a href="https://remotestorage.io/"
|
||||
target="_blank" rel="noopener">remote storage</a> to sync place bookmarks across
|
||||
apps and devices.
|
||||
</p>
|
||||
<ul class="link-list">
|
||||
<li>
|
||||
<a href="https://gitea.kosmos.org/raucao/marco" target="_blank" rel="noopener">
|
||||
Source Code
|
||||
</a> (<a href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License" target="_blank" rel="noopener">AGPL</a>)
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openstreetmap.org/copyright" target="_blank" rel="noopener">
|
||||
Map Data © OpenStreetMap
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
14
app/components/toast.gjs
Normal file
14
app/components/toast.gjs
Normal file
@@ -0,0 +1,14 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class ToastComponent extends Component {
|
||||
@service toast;
|
||||
|
||||
<template>
|
||||
{{#if this.toast.isVisible}}
|
||||
<div class="toast-notification">
|
||||
{{this.toast.message}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { service } from '@ember/service';
|
||||
import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class UserMenuComponent extends Component {
|
||||
@service storage;
|
||||
@service osmAuth;
|
||||
|
||||
@action
|
||||
connectRS() {
|
||||
this.args.onClose();
|
||||
@@ -15,49 +19,87 @@ export default class UserMenuComponent extends Component {
|
||||
this.args.storage.disconnect();
|
||||
}
|
||||
|
||||
@action
|
||||
connectOsm() {
|
||||
this.args.onClose();
|
||||
this.osmAuth.login();
|
||||
}
|
||||
|
||||
@action
|
||||
disconnectOsm() {
|
||||
this.osmAuth.logout();
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="user-menu-popover">
|
||||
<div class="user-status">
|
||||
{{#if @storage.connected}}
|
||||
Connected as
|
||||
<strong>{{@storage.userAddress}}</strong>
|
||||
{{else}}
|
||||
Not connected
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<ul class="account-list">
|
||||
<li class="account-item">
|
||||
<div class="account-info">
|
||||
<Icon @name="server" @size={{18}} />
|
||||
<span>RemoteStorage</span>
|
||||
<div class="account-header">
|
||||
<div class="account-info">
|
||||
<Icon @name="remotestorage" @size={{18}} />
|
||||
<span>RemoteStorage</span>
|
||||
</div>
|
||||
{{#if @storage.connected}}
|
||||
<button
|
||||
class="btn-text text-danger"
|
||||
type="button"
|
||||
{{on "click" this.disconnectRS}}
|
||||
>Disconnect</button>
|
||||
{{else}}
|
||||
<button
|
||||
class="btn-text text-primary"
|
||||
type="button"
|
||||
{{on "click" this.connectRS}}
|
||||
>Connect</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="account-status">
|
||||
{{#if @storage.connected}}
|
||||
{{@storage.userAddress}}
|
||||
{{else}}
|
||||
Not connected
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if @storage.connected}}
|
||||
<button
|
||||
class="btn-text text-danger"
|
||||
type="button"
|
||||
{{on "click" this.disconnectRS}}
|
||||
>Disconnect</button>
|
||||
{{else}}
|
||||
<button
|
||||
class="btn-text text-primary"
|
||||
type="button"
|
||||
{{on "click" this.connectRS}}
|
||||
>Connect</button>
|
||||
{{/if}}
|
||||
</li>
|
||||
|
||||
<li class="account-item disabled">
|
||||
<div class="account-info">
|
||||
<Icon @name="globe" @size={{18}} />
|
||||
<span>OpenStreetMap</span>
|
||||
<li class="account-item">
|
||||
<div class="account-header">
|
||||
<div class="account-info">
|
||||
<Icon @name="map" @size={{18}} />
|
||||
<span>OpenStreetMap</span>
|
||||
</div>
|
||||
{{#if this.osmAuth.isConnected}}
|
||||
<button
|
||||
class="btn-text text-danger"
|
||||
type="button"
|
||||
{{on "click" this.disconnectOsm}}
|
||||
>Disconnect</button>
|
||||
{{else}}
|
||||
<button
|
||||
class="btn-text text-primary"
|
||||
type="button"
|
||||
{{on "click" this.connectOsm}}
|
||||
>Connect</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="account-status">
|
||||
{{#if this.osmAuth.isConnected}}
|
||||
{{this.osmAuth.userDisplayName}}
|
||||
{{else}}
|
||||
Not connected
|
||||
{{/if}}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="account-item disabled">
|
||||
<div class="account-info">
|
||||
<Icon @name="zap" @size={{18}} />
|
||||
<span>Nostr</span>
|
||||
<div class="account-header">
|
||||
<div class="account-info">
|
||||
<Icon @name="zap" @size={{18}} />
|
||||
<span>Nostr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-status">
|
||||
Coming soon
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
11
app/controllers/search.js
Normal file
11
app/controllers/search.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class SearchController extends Controller {
|
||||
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
||||
|
||||
lat = null;
|
||||
lon = null;
|
||||
q = null;
|
||||
selected = null;
|
||||
category = null;
|
||||
}
|
||||
1
app/icons/270-ring.svg
Normal file
1
app/icons/270-ring.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-ember-extension="1"><path d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite"/></path></svg>
|
||||
|
After Width: | Height: | Size: 464 B |
45
app/icons/icon-rounded.svg
Normal file
45
app/icons/icon-rounded.svg
Normal file
@@ -0,0 +1,45 @@
|
||||
<svg
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<!-- Background -->
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="1024"
|
||||
height="1024"
|
||||
rx="220"
|
||||
fill="#F6E9A6"
|
||||
/>
|
||||
|
||||
<!-- Subtle map grid (kept well outside safe zone) -->
|
||||
<g stroke="#E6D88A" stroke-width="10" opacity="0.6">
|
||||
<line x1="256" y1="0" x2="256" y2="1024" />
|
||||
<line x1="512" y1="0" x2="512" y2="1024" />
|
||||
<line x1="768" y1="0" x2="768" y2="1024" />
|
||||
|
||||
<line x1="0" y1="256" x2="1024" y2="256" />
|
||||
<line x1="0" y1="512" x2="1024" y2="512" />
|
||||
<line x1="0" y1="768" x2="1024" y2="768" />
|
||||
</g>
|
||||
|
||||
<!-- Location pin (exact app shape, larger, centered, safe-zone compliant) -->
|
||||
<!-- Safe zone target: ~680px diameter -->
|
||||
<g
|
||||
transform="
|
||||
translate(512 512)
|
||||
scale(22)
|
||||
translate(-12 -12)
|
||||
"
|
||||
fill="#ea4335"
|
||||
stroke="#b31412"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" fill="#b31412" stroke="none" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
app/icons/nostrich-2.svg
Normal file
3
app/icons/nostrich-2.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" class="icon-nostrich" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.7084 10.1607C18.1683 13.3466 14.8705 14.0207 12.9733 13.9618C12.8515 13.958 12.7366 14.0173 12.6647 14.1157C12.4684 14.384 12.1547 14.7309 11.9125 14.7309C11.6405 14.7309 11.3957 15.254 11.284 15.5795C11.2723 15.6137 11.3059 15.6452 11.3403 15.634C14.345 14.6584 15.5241 14.3238 16.032 14.4178C16.4421 14.4937 17.209 15.8665 17.5413 16.5434C16.7155 16.5909 16.4402 15.8507 16.2503 15.7178C16.0985 15.6116 16.0415 16.0974 16.032 16.3536C15.8517 16.2587 15.6239 16.1259 15.6049 15.7178C15.5859 15.3098 15.3771 15.4142 15.2157 15.4332C15.0544 15.4521 12.5769 16.2493 12.2067 16.3536C11.8366 16.458 11.4094 16.6004 11.0582 16.8471C10.4697 17.1318 10.09 16.9325 9.98561 16.4485C9.90208 16.0614 10.4444 14.8701 10.726 14.3229C10.3779 14.4526 9.65529 14.7158 9.54898 14.7309C9.44588 14.7457 8.13815 15.7552 7.43879 16.3038C7.398 16.3358 7.37174 16.3827 7.36236 16.4336C7.25047 17.0416 6.89335 17.2118 6.27423 17.5303C5.77602 17.7867 4.036 20.4606 3.14127 21.9041C3.0794 22.0039 2.9886 22.0806 2.8911 22.1461C2.32279 22.5276 1.74399 23.4985 1.50923 23.9737C1.17511 23.0095 1.61048 22.1802 1.86993 21.886C1.75602 21.7873 1.49341 21.8449 1.37634 21.886C1.69907 20.7757 2.82862 20.7757 2.79066 20.7757C2.99948 20.5954 5.44842 17.0938 5.50538 16.9325C5.56187 16.7725 5.46892 16.0242 6.69975 15.6139C6.7193 15.6073 6.73868 15.5984 6.75601 15.5873C7.71493 14.971 8.43427 13.9774 8.67571 13.5542C7.39547 13.4662 5.92943 12.7525 5.16289 12.294C4.99765 12.1952 4.8224 12.1092 4.63108 12.0875C3.58154 11.9687 2.53067 12.6401 2.10723 13.0228C1.93258 12.7799 2.12938 12.0739 2.24961 11.7513C1.82437 11.6905 1.19916 12.308 0.939711 12.6243C0.658747 12.184 0.904907 11.397 1.06311 11.0585C0.501179 11.0737 0.120232 11.3306 0 11.4571C0.465109 7.99343 4.02275 9.00076 4.06259 9.04675C3.87275 8.84937 3.88857 8.59126 3.92021 8.48688C6.0749 8.54381 7.08105 8.18321 7.71702 7.81313C12.7288 5.01374 14.8882 6.73133 15.6856 7.1631C16.4829 7.59487 17.9304 7.77042 18.9318 7.37187C20.1278 6.83097 19.9478 5.43673 19.7054 4.90461C19.4397 4.32101 17.9399 3.51438 17.4084 2.49428C16.8768 1.47418 17.34 0.233672 17.9558 0.0607684C18.5425 -0.103972 18.9615 0.0876835 19.2831 0.378128C19.4974 0.571763 20.0994 0.710259 20.3509 0.800409C20.6024 0.890558 21.0201 1.00918 20.9964 1.08035C20.9726 1.15152 20.5699 1.14202 20.5075 1.14202C20.3794 1.14202 20.2275 1.161 20.3794 1.23217C20.5575 1.30439 20.8263 1.40936 20.955 1.47846C20.9717 1.48744 20.9683 1.51084 20.95 1.51577C20.0765 1.75085 19.2966 1.26578 18.7183 1.82526C18.1298 2.39463 19.3827 2.83114 20.0282 3.51438C20.6736 4.19762 21.3381 5.01372 20.8065 6.87365C20.395 8.31355 18.6703 9.53781 17.7795 10.0167C17.7282 10.0442 17.7001 10.1031 17.7084 10.1607Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
6
app/icons/nostrich.svg
Normal file
6
app/icons/nostrich.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" class="icon-nostrich-head" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.03377 4.84648C2.38935 5.60878 1.88639 6.49681 1.5799 7.4713C3.32454 7.07836 5.64286 6.98406 6.95527 6.88189C7.36392 5.20013 8.52701 3.91915 10.476 4.0056C11.3169 4.04489 12.0556 4.58714 12.5664 5.42017C12.9436 5.01937 13.4466 4.75218 14.1146 4.65787C14.1617 4.65787 14.2639 4.65001 14.3425 4.65001C12.9593 3.14114 10.9868 2.18237 8.77849 2.18237C8.3777 2.18237 7.98476 2.22167 7.59183 2.28454C7.51324 2.28454 7.41108 2.30026 7.27748 2.33169C7.26962 2.33169 7.2539 2.33169 7.24604 2.33169C7.23818 2.33169 7.23032 2.33169 7.21461 2.33169C5.69001 2.70105 4.54264 2.40242 3.89037 1.51438C3.81964 1.42008 3.54458 1.00357 3.45814 0.272705C2.97876 0.767805 2.66441 1.58511 2.9316 2.45743C3.14379 3.149 3.54458 3.51836 3.97681 3.73054C3.31668 3.76984 2.76657 3.6441 2.21646 3.22759C1.89425 2.98396 1.68992 2.71677 1.352 2.01734C1.03765 2.51244 1.06909 3.06255 1.13195 3.34547C1.21054 3.72268 1.40701 4.14706 1.65849 4.39068C2.04357 4.76789 2.59368 4.85434 3.04162 4.84648H3.03377Z" fill="currentColor"/>
|
||||
<path d="M10.4837 11.3458C11.4602 11.3458 12.2519 9.99116 12.2519 8.32016C12.2519 6.64917 11.4602 5.29456 10.4837 5.29456C9.50711 5.29456 8.71545 6.64917 8.71545 8.32016C8.71545 9.99116 9.50711 11.3458 10.4837 11.3458Z" fill="currentColor"/>
|
||||
<path d="M14.3737 10.615C15.1376 10.615 15.7569 9.53831 15.7569 8.21019C15.7569 6.88207 15.1376 5.80542 14.3737 5.80542C13.6099 5.80542 12.9906 6.88207 12.9906 8.21019C12.9906 9.53831 13.6099 10.615 14.3737 10.615Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.52542 23.9833C7.53337 23.6314 7.66454 22.5232 8.7864 20.3047C9.2815 19.3381 10.4053 18.0021 11.2462 17.2791C11.6941 16.8862 12.1421 16.5561 12.5822 16.2496C12.8101 16.116 13.0222 15.9745 13.2266 15.8252C16.9076 13.5684 20.157 14.0396 22.8528 14.4306L22.9321 14.4421C22.9321 14.4421 23.5765 12.5246 20.9203 11.5344C19.4743 11 17.7689 10.5677 16.3465 10.2691C16.1422 10.6385 15.8828 10.9528 15.5763 11.1886C15.5721 11.1917 15.5678 11.195 15.5634 11.1983C15.3354 11.3696 14.795 11.7757 13.816 11.6601C13.313 11.5972 12.9279 11.3929 12.6215 11.0943C12.1028 11.9509 11.3562 12.5088 10.4917 12.5874C8.09483 12.7918 6.88458 10.7799 6.806 8.55591C5.00635 8.7288 2.55443 9.83688 1.24988 10.4813L1.25662 22.0396C2.92115 22.6846 5.41819 23.4807 7.52542 23.9833Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
46
app/icons/remotestorage.svg
Normal file
46
app/icons/remotestorage.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Creator: CorelDRAW X7 -->
|
||||
|
||||
<svg
|
||||
xml:space="preserve"
|
||||
width="24"
|
||||
height="24"
|
||||
version="1.1"
|
||||
style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
|
||||
viewBox="0 0 249.99729 249.90068"
|
||||
id="svg1"
|
||||
sodipodi:docname="icon-square.svg"
|
||||
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="true"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="in"
|
||||
inkscape:zoom="6.5838793"
|
||||
inkscape:cx="2.1264059"
|
||||
inkscape:cy="39.414453"
|
||||
inkscape:window-width="2160"
|
||||
inkscape:window-height="1281"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_x0020_1" /> <defs
|
||||
id="defs1"></defs> <g
|
||||
id="Layer_x0020_1"
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
transform="translate(-66.822266,-0.16483529)"> <metadata
|
||||
id="CorelCorpID_0Corel-Layer" /> <polygon
|
||||
fill="currentColor"
|
||||
points="228,181 370,100 511,181 652,263 370,425 87,263 87,263 0,213 0,213 0,311 0,378 0,427 0,476 86,525 185,582 370,689 554,582 653,525 653,590 653,592 370,754 0,542 0,640 185,747 370,853 554,747 739,640 739,525 739,476 739,427 739,378 653,427 370,589 86,427 86,361 185,418 370,524 554,418 653,361 739,311 739,213 554,107 370,0 185,107 58,180 144,230 "
|
||||
id="polygon1"
|
||||
transform="matrix(0.29308006,0,0,0.29308006,83.527829,-0.02838471)"
|
||||
/> </g> </svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
4
app/icons/wikipedia.svg
Normal file
4
app/icons/wikipedia.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="7.15 7.15 113.7 113.7" fill="currentColor">
|
||||
<path d="M 120.85,29.21 C 120.85,29.62 120.72,29.99 120.47,30.33 C 120.21,30.66 119.94,30.83 119.63,30.83 C 117.14,31.07 115.09,31.87 113.51,33.24 C 111.92,34.6 110.29,37.21 108.6,41.05 L 82.8,99.19 C 82.63,99.73 82.16,100 81.38,100 C 80.77,100 80.3,99.73 79.96,99.19 L 65.49,68.93 L 48.85,99.19 C 48.51,99.73 48.04,100 47.43,100 C 46.69,100 46.2,99.73 45.96,99.19 L 20.61,41.05 C 19.03,37.44 17.36,34.92 15.6,33.49 C 13.85,32.06 11.4,31.17 8.27,30.83 C 8,30.83 7.74,30.69 7.51,30.4 C 7.27,30.12 7.15,29.79 7.15,29.42 C 7.15,28.47 7.42,28 7.96,28 C 10.22,28 12.58,28.1 15.05,28.3 C 17.34,28.51 19.5,28.61 21.52,28.61 C 23.58,28.61 26.01,28.51 28.81,28.3 C 31.74,28.1 34.34,28 36.6,28 C 37.14,28 37.41,28.47 37.41,29.42 C 37.41,30.36 37.24,30.83 36.91,30.83 C 34.65,31 32.87,31.58 31.57,32.55 C 30.27,33.53 29.62,34.81 29.62,36.4 C 29.62,37.21 29.89,38.22 30.43,39.43 L 51.38,86.74 L 63.27,64.28 L 52.19,41.05 C 50.2,36.91 48.56,34.23 47.28,33.03 C 46,31.84 44.06,31.1 41.46,30.83 C 41.22,30.83 41,30.69 40.78,30.4 C 40.56,30.12 40.45,29.79 40.45,29.42 C 40.45,28.47 40.68,28 41.16,28 C 43.42,28 45.49,28.1 47.38,28.3 C 49.2,28.51 51.14,28.61 53.2,28.61 C 55.22,28.61 57.36,28.51 59.62,28.3 C 61.95,28.1 64.24,28 66.5,28 C 67.04,28 67.31,28.47 67.31,29.42 C 67.31,30.36 67.15,30.83 66.81,30.83 C 62.29,31.14 60.03,32.42 60.03,34.68 C 60.03,35.69 60.55,37.26 61.6,39.38 L 68.93,54.26 L 76.22,40.65 C 77.23,38.73 77.74,37.11 77.74,35.79 C 77.74,32.69 75.48,31.04 70.96,30.83 C 70.55,30.83 70.35,30.36 70.35,29.42 C 70.35,29.08 70.45,28.76 70.65,28.46 C 70.86,28.15 71.06,28 71.26,28 C 72.88,28 74.87,28.1 77.23,28.3 C 79.49,28.51 81.35,28.61 82.8,28.61 C 83.84,28.61 85.38,28.52 87.4,28.35 C 89.96,28.12 92.11,28 93.83,28 C 94.23,28 94.43,28.4 94.43,29.21 C 94.43,30.29 94.06,30.83 93.32,30.83 C 90.69,31.1 88.57,31.83 86.97,33.01 C 85.37,34.19 83.37,36.87 80.98,41.05 L 71.26,59.02 L 84.42,85.83 L 103.85,40.65 C 104.52,39 104.86,37.48 104.86,36.1 C 104.86,32.79 102.6,31.04 98.08,30.83 C 97.67,30.83 97.47,30.36 97.47,29.42 C 97.47,28.47 97.77,28 98.38,28 C 100.03,28 101.99,28.1 104.25,28.3 C 106.34,28.51 108.1,28.61 109.51,28.61 C 111,28.61 112.72,28.51 114.67,28.3 C 116.7,28.1 118.52,28 120.14,28 C 120.61,28 120.85,28.4 120.85,29.21 z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
21
app/modifiers/on-click-outside.js
Normal file
21
app/modifiers/on-click-outside.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { modifier } from 'ember-modifier';
|
||||
|
||||
export default modifier((element, [callback]) => {
|
||||
const handler = (event) => {
|
||||
// Check if the click target is contained within the element
|
||||
if (element && !element.contains(event.target)) {
|
||||
callback(event);
|
||||
}
|
||||
};
|
||||
|
||||
// Delay attaching the listener to avoid catching the opening click
|
||||
// (using a microtask or setTimeout 0)
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener('click', handler);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handler);
|
||||
};
|
||||
});
|
||||
@@ -10,4 +10,7 @@ Router.map(function () {
|
||||
this.route('place', { path: '/place/:place_id' });
|
||||
this.route('place.new', { path: '/place/new' });
|
||||
this.route('search');
|
||||
this.route('oauth', function () {
|
||||
this.route('osm-callback', { path: '/osm/callback' });
|
||||
});
|
||||
});
|
||||
|
||||
10
app/routes/index.js
Normal file
10
app/routes/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class IndexRoute extends Route {
|
||||
@service mapUi;
|
||||
|
||||
activate() {
|
||||
this.mapUi.clearSearchResults();
|
||||
}
|
||||
}
|
||||
17
app/routes/oauth/osm-callback.js
Normal file
17
app/routes/oauth/osm-callback.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class OauthOsmCallbackRoute extends Route {
|
||||
@service osmAuth;
|
||||
@service router;
|
||||
|
||||
async model() {
|
||||
try {
|
||||
await this.osmAuth.handleCallback();
|
||||
} catch (e) {
|
||||
console.error('Failed to handle OSM OAuth callback', e);
|
||||
} finally {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,11 @@ export default class PlaceRoute extends Route {
|
||||
async model(params) {
|
||||
const id = params.place_id;
|
||||
|
||||
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
|
||||
if (
|
||||
id.startsWith('osm:node:') ||
|
||||
id.startsWith('osm:way:') ||
|
||||
id.startsWith('osm:relation:')
|
||||
) {
|
||||
const [, type, osmId] = id.split(':');
|
||||
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
||||
return this.loadOsmPlace(osmId, type);
|
||||
@@ -44,10 +48,33 @@ export default class PlaceRoute extends Route {
|
||||
}
|
||||
}
|
||||
|
||||
afterModel(model) {
|
||||
async afterModel(model) {
|
||||
// If the model comes from a search result (e.g. Photon), it might lack detailed geometry.
|
||||
// We want to ensure we have the full OSM object (with polygon/linestring) for display.
|
||||
if (
|
||||
model &&
|
||||
model.osmId &&
|
||||
model.osmType &&
|
||||
model.osmType !== 'node' &&
|
||||
!model.geojson
|
||||
) {
|
||||
// Only fetch if it's NOT a node (nodes don't have interesting geometry anyway, just a point)
|
||||
// Although fetching nodes again ensures we have the latest tags too.
|
||||
console.debug('Model missing geometry, fetching full OSM details...');
|
||||
const fullDetails = await this.loadOsmPlace(model.osmId, model.osmType);
|
||||
|
||||
if (fullDetails) {
|
||||
// Update the model in-place with the fuller details
|
||||
Object.assign(model, fullDetails);
|
||||
console.debug('Enriched model with full OSM details', model);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify the Map UI to show the pin
|
||||
if (model) {
|
||||
this.mapUi.selectPlace(model);
|
||||
const options = { preventZoom: this.mapUi.preventNextZoom };
|
||||
this.mapUi.selectPlace(model, options);
|
||||
this.mapUi.preventNextZoom = false;
|
||||
}
|
||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||
this.mapUi.stopSearch();
|
||||
@@ -62,7 +89,8 @@ export default class PlaceRoute extends Route {
|
||||
|
||||
async loadOsmPlace(id, type = null) {
|
||||
try {
|
||||
const poi = await this.osm.getPoiById(id, type);
|
||||
// Use the direct OSM API fetch instead of Overpass for single object lookups
|
||||
const poi = await this.osm.fetchOsmObject(id, type);
|
||||
if (poi) {
|
||||
console.debug('Found OSM POI:', poi);
|
||||
return poi;
|
||||
@@ -73,6 +101,23 @@ export default class PlaceRoute extends Route {
|
||||
return null;
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
this.checkUpdates(model);
|
||||
}
|
||||
|
||||
async checkUpdates(place) {
|
||||
// Only check for updates if it's a saved place (has ID) and is an OSM object
|
||||
if (place && place.id && place.osmId && place.osmType) {
|
||||
const updatedPlace = await this.storage.refreshPlace(place);
|
||||
if (updatedPlace) {
|
||||
// If an update occurred, refresh the map UI selection without moving the camera
|
||||
// This ensures the sidebar shows the new data
|
||||
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serialize(model) {
|
||||
// If the model is a saved bookmark, use its ID
|
||||
if (model.id) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user