Compare commits

...

185 Commits

Author SHA1 Message Date
9d06898b15 1.18.1
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 44s
2026-04-01 19:26:05 +04:00
6df43edbf9 Fix OSM client ID missing in release build 2026-04-01 19:25:16 +04:00
e2fcdce154 1.18.0
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 46s
2026-04-01 19:14:03 +04:00
829dff9839 Merge pull request 'Connect OSM account' (#40) from feature/osm_auth into master
All checks were successful
CI / Lint (push) Successful in 30s
CI / Test (push) Successful in 47s
Reviewed-on: #40
2026-04-01 15:12:46 +00:00
e7dfed204e Connect OSM account
All checks were successful
CI / Lint (pull_request) Successful in 30s
CI / Test (pull_request) Successful in 49s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-01 18:46:19 +04:00
2dfd411837 Merge pull request 'Improve user menu layout, icons' (#39) from feature/user_menu into master
Some checks failed
CI / Lint (push) Successful in 29s
CI / Test (push) Failing after 44s
Reviewed-on: #39
2026-04-01 13:33:07 +00:00
5afece5f51 Add nostrich icons for future use
All checks were successful
CI / Lint (pull_request) Successful in 45s
CI / Test (pull_request) Successful in 47s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-01 17:16:56 +04:00
f1979282bd Use map icon for OSM in user menu 2026-04-01 17:16:36 +04:00
04181d5772 Use actual remoteStorage icon in user menu 2026-04-01 15:56:31 +04:00
b4ab1e926d Keep icon imports sorted properly 2026-04-01 15:56:16 +04:00
9b83d35b40 User menu: Move connection status to respective account type 2026-04-01 15:36:29 +04:00
beda03c2ac Update status doc 2026-04-01 14:42:07 +04:00
ecbac12440 Add ember-best-practices skill 2026-04-01 12:59:41 +04:00
913d5c915c 1.17.2
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
2026-03-28 16:49:03 +04:00
89f667b17e Add more icons 2026-03-28 16:48:14 +04:00
22d4ef8d96 Update Pinhead 2026-03-28 16:47:53 +04:00
b17793af9d 1.17.1
Some checks failed
CI / Lint (push) Successful in 29s
CI / Test (push) Failing after 44s
2026-03-28 15:32:21 +04:00
dc9e0f210a Hide search result markers when result is selected 2026-03-28 15:30:27 +04:00
2b219fe0cf 1.17.0
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
2026-03-27 15:17:16 +04:00
9fd6c4d64d Merge pull request 'When search requests fail, show error in toast notifications instead of empty search results' (#38) from feature/failed_requests into master
Some checks failed
CI / Lint (push) Successful in 28s
CI / Test (push) Failing after 44s
Reviewed-on: #38
2026-03-27 11:12:58 +00:00
8e5b2c7439 Fix lint error
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 44s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-03-27 15:05:56 +04:00
0f29430e1a When request retries exhaust, show error in toast notification
Some checks failed
CI / Lint (pull_request) Failing after 29s
CI / Test (pull_request) Failing after 44s
2026-03-27 15:01:04 +04:00
0059d89cc3 Add toast notifications 2026-03-27 15:00:36 +04:00
54e2766dc4 Merge pull request 'Add setting for hiding quick search buttons' (#36) from feature/settings_quick-search into master
All checks were successful
CI / Lint (push) Successful in 28s
CI / Test (push) Successful in 43s
Reviewed-on: #36
2026-03-27 10:09:18 +00:00
5978f67d48 Add setting for hiding quick search buttons
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 44s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-03-27 13:59:36 +04:00
d72e5f3de2 Merge pull request 'Add category search, and search result markers with icons' (#35) from feature/poi_type_search into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 47s
Reviewed-on: #35
2026-03-27 09:12:28 +00:00
582ab4f8b3 Fix lint errors
All checks were successful
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 47s
Release Drafter / Update release notes draft (pull_request) Successful in 38s
2026-03-23 18:32:18 +04:00
0ac6db65cb Add parking icon
Some checks are pending
CI / Lint (pull_request) Waiting to run
CI / Test (pull_request) Waiting to run
2026-03-23 18:21:41 +04:00
86b20fd474 Abort search requests when clearing search box
Also adds abort support for Photon queries
2026-03-23 18:07:29 +04:00
8478e00253 Add loading indicator for search queries 2026-03-23 17:50:21 +04:00
818ec35071 Ensure map marker clicks preserve search context
Fixes the back button just closing the sidebar and clearing the whole
search after having seleted a result via map marker
2026-03-23 16:42:32 +04:00
46605dbd32 Add more icons 2026-03-23 16:07:33 +04:00
bcc51efecc Add catch-alls for place icons, staring with shop 2026-03-23 15:12:07 +04:00
8bec4b978e Ignore certain public transport results in nearby search 2026-03-23 15:00:39 +04:00
cd9676047d Add more icons 2026-03-23 14:42:41 +04:00
a92b44ec13 Ensure nearby search isn't doing category search 2026-03-23 13:51:11 +04:00
0c2d1f8419 Don't include coffee places in restaurant search 2026-03-22 19:23:16 +04:00
bb77ed8337 Add more icons 2026-03-22 19:13:19 +04:00
438bf0c31c Add icons to search result markers 2026-03-22 14:05:49 +04:00
af57e7fe57 Add map markers for search results 2026-03-22 10:59:11 +04:00
9183e3c366 Cache category search results
And abort ongoing searches when there's a new query
2026-03-20 19:27:26 +04:00
7e98b6796c Integrate category search with search box 2026-03-20 18:56:18 +04:00
8e9beb16de WIP Integrate category search with search box 2026-03-20 18:39:51 +04:00
b083c1d001 feat(search): add category search support and sync with chips 2026-03-20 18:14:02 +04:00
4008a8c883 Use "Results" header for category search results 2026-03-20 17:59:11 +04:00
eb7cff7ff5 Add tests for category quick search 2026-03-20 17:49:54 +04:00
db6478e353 Clear category param when typing new search 2026-03-20 17:42:36 +04:00
b39d92b7c4 Fix lint errors 2026-03-20 17:30:49 +04:00
aa99e5d766 Add icons for all quick search categories 2026-03-20 17:26:03 +04:00
5fd4ebe184 Centrally define filled icons
So we don't have to manually pass the option everywhere
2026-03-20 16:55:19 +04:00
f2a2d910a0 WIP Search places by category 2026-03-20 16:43:57 +04:00
6b37508f66 Merge pull request 'Disable edit button while editing' (#34) from ui/edit_button into master
All checks were successful
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 1m0s
Reviewed-on: #34
2026-03-18 15:13:15 +00:00
8106009677 Disable edit button while editing
All checks were successful
CI / Lint (pull_request) Successful in 51s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 19:09:59 +04:00
07489c43a4 Merge pull request 'Add Pinhead iconset, icon for cuisine' (#33) from feature/pinhead_icons into master
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 1m0s
Reviewed-on: #33
2026-03-18 14:57:22 +00:00
a4e375cb51 Add Pinhead info to FOSS table in About section
All checks were successful
CI / Lint (pull_request) Successful in 51s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 18:34:49 +04:00
b680769eac Replace "cuisine" with icon in place details 2026-03-18 18:17:17 +04:00
4a609c8388 Add pinhead icons
https://pinhead.ink
2026-03-18 18:16:47 +04:00
cfcaaea3ec Update status doc
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 57s
2026-03-18 17:42:50 +04:00
2f440d4971 1.16.0
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
2026-03-18 14:48:49 +04:00
1c6cbe6b0f Merge pull request 'Update OSM data when opening saved places' (#32) from feature/update_place_data into master
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #32
2026-03-18 10:46:33 +00:00
bdd5db157c Update OSM data when opening saved places
All checks were successful
CI / Lint (pull_request) Successful in 49s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 14:42:15 +04:00
f7c40095d5 1.15.4
All checks were successful
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 58s
2026-03-17 20:08:37 +04:00
579892067e Tweak sidebar width 2026-03-17 20:07:49 +04:00
48f87f98d6 Remove obsolete styles
All checks were successful
CI / Lint (push) Successful in 51s
CI / Test (push) Successful in 57s
2026-03-17 16:59:54 +04:00
3cd1b32af9 1.15.3
All checks were successful
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 57s
2026-03-17 16:55:31 +04:00
462404b53e Fix sidebar content padding, class name 2026-03-17 16:54:41 +04:00
e3147caa90 1.15.2
All checks were successful
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 59s
2026-03-17 16:35:10 +04:00
7c11fefdb7 Tweak whitespace 2026-03-17 16:34:19 +04:00
9af6636971 1.15.1
All checks were successful
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 57s
2026-03-17 16:30:18 +04:00
8ae7fd1fd8 Tweak font sizes
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 56s
2026-03-17 16:25:55 +04:00
cb3fbade39 Prevent unwanted zoom on iOS
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 56s
2026-03-17 16:17:16 +04:00
6dfd9765b4 1.15.0
All checks were successful
CI / Lint (push) Successful in 53s
CI / Test (push) Successful in 56s
2026-03-17 15:55:15 +04:00
45eb8f087d Add linting to preversion script 2026-03-17 15:53:58 +04:00
3630fae133 Merge pull request 'Improve app menu sidebar, add separate subsections, more content' (#31) from feature/app_menu into master
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #31
2026-03-17 11:52:42 +00:00
1116161e93 Add info/links for preferred contributions
All checks were successful
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 18s
2026-03-17 15:48:11 +04:00
88eb0ac0c1 WIP About section
* Add fold-out sections for additional details
* Use table for Open Source info, add licenses
* Move link styles to CSS variables
2026-03-17 15:12:29 +04:00
6da004e199 Merge pull request 'Increase sidebar width, improve auto-panning' (#30) from ui/autopan into feature/app_menu
Reviewed-on: #30
2026-03-17 09:20:46 +00:00
8877a9e1c8 Remove unnecessary whitespace
All checks were successful
CI / Lint (pull_request) Successful in 49s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Successful in 18s
2026-03-17 13:18:17 +04:00
03d6a86569 Increase sidebar width, improve auto-panning
Some checks failed
CI / Lint (pull_request) Failing after 54s
CI / Test (pull_request) Has been cancelled
* Increase sidebar with on desktop to fit more content
* Move sidebar width to CSS variable
* Auto-pan when a selected place would be obstructed by the desktop
  sidebar
* Consider the header obscuring selected places, too
* Only autopan when actually necessary
2026-03-17 13:14:21 +04:00
5baebbb846 Add details elements/sections to App Menu sidebar
Starting with some About details
2026-03-16 18:23:46 +04:00
dca2991754 Style dropdown menu form controls 2026-03-16 17:07:38 +04:00
aee7f9d181 Move Photon API URL to Settings 2026-03-16 17:07:24 +04:00
56a077cceb Change release drafter token config
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 57s
Another day, another try
2026-03-16 16:24:40 +04:00
7e5a034cac Merge pull request 'Prevent app icon loading when opening the app menu' (#29) from dev/app-icon into master
All checks were successful
CI / Lint (push) Successful in 47s
CI / Test (push) Successful in 56s
Reviewed-on: #29
2026-03-16 12:06:49 +00:00
5892bd0cda Prevent app icon loading when opening the app menu
Some checks failed
CI / Lint (pull_request) Successful in 50s
CI / Test (pull_request) Successful in 56s
Release Drafter / Update release notes draft (pull_request) Has been cancelled
Compile into app code by importing the icon in JS.
2026-03-16 16:02:57 +04:00
baaf027900 Fix missing token
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Hopefully
2026-03-14 16:57:52 +04:00
beb3d12169 Merge pull request 'Refactor app menu sidebar' (#28) from feature/app_menu into master
All checks were successful
CI / Lint (push) Successful in 48s
CI / Test (push) Successful in 57s
Reviewed-on: #28
2026-03-14 12:40:53 +00:00
a5aa396411 Merge branch 'master' into feature/app_menu
Some checks failed
CI / Test (pull_request) Successful in 58s
Release Drafter / Update release notes draft (pull_request) Failing after 17s
CI / Lint (pull_request) Successful in 48s
2026-03-14 16:31:42 +04:00
21cb8e6cc2 Add release drafter workflow
All checks were successful
CI / Lint (push) Successful in 54s
CI / Test (push) Successful in 1m8s
2026-03-14 16:31:22 +04:00
12ec7fcbbf Add release drafter config
Some checks failed
CI / Lint (push) Successful in 52s
CI / Test (push) Has been cancelled
2026-03-14 16:30:23 +04:00
78d7aeba2c Refactor app menu sidebar
* Rename to "app menu" across code
* Move content to dedicated sections/components, introduce app menu links
* Use CSS variable for hover background color across the app
2026-03-14 16:24:36 +04:00
3b71531de2 1.14.0
All checks were successful
CI / Lint (push) Successful in 22s
CI / Test (push) Successful in 37s
2026-03-14 13:06:07 +04:00
6ef7549ea9 Merge pull request 'Add places to default lists' (#27) from feature/1-lists into master
All checks were successful
CI / Lint (push) Successful in 25s
CI / Test (push) Successful in 38s
Reviewed-on: #27
2026-03-14 09:04:21 +00:00
9097c63a55 Upgrade places module to latest release
All checks were successful
CI / Lint (pull_request) Successful in 25s
CI / Test (pull_request) Successful in 42s
... with lists support
2026-03-14 12:36:49 +04:00
ec0d5a30f9 Extract icon imports to separate util
All checks were successful
CI / Lint (pull_request) Successful in 28s
CI / Test (pull_request) Successful in 43s
So icons can be used from anywhere, e.g. map component JS
2026-03-14 12:28:17 +04:00
f1779131e8 Also load/init lists in anonymous mode
Some checks failed
CI / Lint (pull_request) Failing after 22s
CI / Test (pull_request) Successful in 34s
2026-03-13 17:04:29 +04:00
37cf47b3dd Properly handle place removals
Some checks failed
CI / Lint (pull_request) Failing after 23s
CI / Test (pull_request) Successful in 36s
* Transition to OSM route or index instead of staying on ghost route/ID
  (closes sidebar if it was a custom place)
* Ensure save button and lists are in the correct state
2026-03-13 15:33:29 +04:00
ff68b5addc Move default yellow to var, add in list UI 2026-03-13 14:56:12 +04:00
990f3afa88 Fix lint errors
All checks were successful
CI / Lint (pull_request) Successful in 21s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:51:49 +04:00
b2220b8310 Close list dropdown when clicking outside of it
Some checks failed
CI / Lint (pull_request) Failing after 25s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:40:28 +04:00
a8613ab81a Remove confirmation dialog when deleting place bookmarks 2026-03-13 13:27:01 +04:00
bcb9b20e85 WIP Add places to lists 2026-03-13 12:22:51 +04:00
466b1d5383 Comment dev config for remote access
All checks were successful
CI / Lint (push) Successful in 20s
CI / Test (push) Successful in 34s
2026-03-11 18:26:45 +04:00
ea7cb2f895 1.13.3
Some checks failed
CI / Lint (push) Failing after 18s
CI / Test (push) Successful in 29s
2026-03-11 18:19:15 +04:00
7e94f335ac Prevent zooming when selecting saved places 2026-03-11 18:16:24 +04:00
066ddb240d 1.13.2
Some checks failed
CI / Lint (push) Failing after 23s
CI / Test (push) Successful in 34s
2026-03-11 17:53:06 +04:00
df336b87ac Smart auto zoom for search/select 2026-03-11 17:51:26 +04:00
dbf71e366a Further improve scrolling 2026-03-11 17:19:48 +04:00
6a83003acb 1.13.1 2026-03-11 16:30:33 +04:00
bcc7c2a011 Improve bottom card scrolling on Android 2026-03-11 16:29:31 +04:00
19f04efecb 1.13.0 2026-03-11 16:16:57 +04:00
c79bbaa41a Merge pull request 'Add email, FB, Instagram to place details' (#26) from feature/social_links into master
All checks were successful
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 34s
Reviewed-on: #26
2026-03-11 12:11:13 +00:00
b07640375a Add some white space to place details bottom
All checks were successful
CI / Lint (pull_request) Successful in 23s
CI / Test (pull_request) Successful in 39s
2026-03-11 16:07:37 +04:00
ffcb8219b0 Add email links 2026-03-11 15:22:34 +04:00
e01cb2ce6f Add Facebook and Instagram links 2026-03-11 15:02:47 +04:00
808c1ee37b 1.12.3
All checks were successful
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 35s
2026-02-24 22:28:56 +04:00
34bc15cfa9 Only zoom out, not in, when fitting ways/relations 2026-02-24 22:27:52 +04:00
ee5e56910d 1.12.2
All checks were successful
CI / Lint (push) Successful in 21s
CI / Test (push) Successful in 37s
2026-02-24 21:51:21 +04:00
e019fc2d6b Don't include roads and similar in nearby search
Meant to include bus stops, but have to be more specific when re-adding
2026-02-24 21:49:59 +04:00
9e03426b2e 1.12.1
All checks were successful
CI / Lint (push) Successful in 22s
CI / Test (push) Successful in 33s
2026-02-24 20:49:35 +04:00
ecbf77c573 Add OpenGraph and Twitter Card metadata 2026-02-24 20:48:37 +04:00
703a5e8de0 1.12.0 2026-02-24 18:53:27 +04:00
b3c733769c Add app icon before app name in Settings header
All checks were successful
CI / Lint (push) Successful in 26s
CI / Test (push) Successful in 39s
2026-02-24 18:52:07 +04:00
60b2548efd Set up Gitea Actions/CI (#23)
All checks were successful
CI / Lint (push) Successful in 19s
CI / Test (push) Successful in 33s
Reviewed-on: #23
2026-02-24 14:23:01 +00:00
2e632658ad Fix warning 2026-02-24 16:43:06 +04:00
845be96b71 Fix linting errors, improve lint scripts 2026-02-24 16:31:22 +04:00
9ac4273fae Don't use outdated Overpass providers 2026-02-24 15:13:44 +04:00
3a825c3d6c Merge pull request 'Add full-text search' (#20) from feature/10-fulltext_search into master
Reviewed-on: #20
2026-02-24 11:03:57 +00:00
a6ca362876 Multi-line rendering for multi-value tags
E.g. opening hours, multiple phone numbers, ...
2026-02-24 14:50:39 +04:00
95e9c621a5 Fix sidebar link layout issue 2026-02-24 13:31:11 +04:00
e980431c17 Add Wikipedia icon, support for filled SVGs 2026-02-24 13:25:17 +04:00
4fdf2e2fb6 WIP Add more icons 2026-02-24 13:04:15 +04:00
de1b162ee9 Different sidebar headers for nearby and full search 2026-02-24 12:49:07 +04:00
1df77c2045 Tweak search box width on small screens 2026-02-24 12:21:03 +04:00
eb1445b749 Update status doc 2026-02-24 11:52:55 +04:00
316a38dbf8 Complete tests for localized names 2026-02-24 11:51:25 +04:00
7bcb572dbf If place key's value is "yes", display key instead
For example, building=yes with no other useful tags (e.g. amenity) will
show as Building now
2026-02-24 11:46:59 +04:00
d827fe263b Draw outlines/areas for ways and relations on map 2026-02-24 11:22:57 +04:00
1926e2b20c Switch back to more reliable Overpass default 2026-02-24 11:05:25 +04:00
df1f32d8bd More place type improvements 2026-02-24 11:05:02 +04:00
aa058bd7a3 Include places that only have localized names
For example "name" absent, but "name:en" present
2026-02-24 10:41:54 +04:00
361a826e4f Improve nearby search
* Use regular expression queries for place types
* Add more place types
* Add relations
* Only return results with a name
2026-02-24 09:58:12 +04:00
ff01d54fdd Update status doc 2026-02-23 23:28:11 +04:00
f73677139d Zoom to fit ways and relations into map view 2026-02-23 22:01:46 +04:00
8135695bba Add waterways (e.g. river) 2026-02-23 21:58:45 +04:00
8217e85836 Improve display of boundaries like cities, states, etc. 2026-02-23 21:14:40 +04:00
d9645d1a8c Fix search result subheadings
Should be address for non-nearby results
2026-02-23 20:28:03 +04:00
688e8eda8d Add more place types 2026-02-23 20:16:24 +04:00
323aab8256 Add more place types, refactor tag usage 2026-02-23 18:02:15 +04:00
ecb3fe4b5a Tweak sizes and layout for icon buttons 2026-02-23 16:50:34 +04:00
43b2700465 Don't start nearby search when unfocusing search by clicking map 2026-02-20 19:48:41 +04:00
00454c8fab Integrate the menu button in the search box
Allows us to make the search box wider, too
2026-02-20 18:35:01 +04:00
bf12305600 Add full-text search
Add a search box with a quick results popover, as well full results in
the sidebar on pressing enter.
2026-02-20 12:39:04 +04:00
2734f08608 Formatting 2026-02-20 12:38:57 +04:00
2aa59f9384 Fetch place details from OSM API, support relations
* Much faster
* Has more place details, which allows us to locate relations, in
  addition to nodes and ways
2026-02-20 12:34:48 +04:00
bcf8ca4255 Add service for Photon requests 2026-02-19 16:28:07 +04:00
20f63065ad 1.11.4 2026-02-10 19:21:34 +04:00
39a7ec3595 Improve code based on linting 2026-02-10 19:20:38 +04:00
32dfa3a30f Merge pull request 'Prefer place name in UA/browser language' (#19) from feature/16-place_names into master
Reviewed-on: #19
2026-02-10 15:20:23 +00:00
64ccc694d3 Prefer place name in UA/browser language
closes #16
2026-02-10 19:19:36 +04:00
87e2380ef6 1.11.3 2026-02-10 18:53:21 +04:00
66c31b19f1 Merge pull request 'UI improvements' (#18) from feature/location_improvements into master
Reviewed-on: #18
2026-02-10 14:52:22 +00:00
55aecbd699 Apply same button-press effect to both header buttons 2026-02-10 18:51:26 +04:00
ccaa56b78f Remove remaining default tap highlights on mobiles 2026-02-10 18:44:41 +04:00
d30375707a Prevent map search when zoomed out too much
It's usually an accidental click, and if not, the search radius/pulse
wouldn't be clearly visible.
2026-02-10 18:33:44 +04:00
53300b92f5 Re-add zoom controls 2026-02-10 17:47:03 +04:00
c37f794eea Auto-locate user on first app launch
closes #17
2026-02-10 17:18:59 +04:00
4bc92bb7cc Run tests before versioning 2026-02-08 17:01:56 +04:00
9f48d7b264 1.11.2 2026-02-08 17:01:01 +04:00
bbd3bf47c6 Merge pull request 'Fix back button behavior' (#14) from bugfix/back_button into master
Reviewed-on: #14
2026-02-08 13:00:07 +00:00
59e3d91071 Fix back button behavior
fixes #12
2026-02-08 16:59:53 +04:00
348b721876 1.11.1 2026-01-27 15:05:08 +07:00
3d982a6a7c More kinetic panning optimizations 2026-01-27 15:04:25 +07:00
0af9d9f16d 1.11.0 2026-01-27 14:24:52 +07:00
a0f132ec64 Disable kinetic panning on mobile by default, add setting for it 2026-01-27 14:23:43 +07:00
925f26ae5d Update status doc 2026-01-27 14:08:27 +07:00
58bb8831f3 Prevent autofocus on mobile
Makes it difficult to fine-tune the location first
2026-01-27 14:06:26 +07:00
585837cae7 1.10.1 2026-01-27 13:47:09 +07:00
42c5282844 Don't show GMaps link for private bookmarks 2026-01-27 13:46:43 +07:00
8a0603c65e 1.10.0 2026-01-27 13:38:33 +07:00
8e3187f38d Improve place-create button 2026-01-27 13:37:59 +07:00
a73e5cda6a Clean up code comments 2026-01-27 13:25:54 +07:00
0212fa359b Change console statements to debug or warn 2026-01-27 12:58:36 +07:00
8c58a76030 Create new places
And find them in search
2026-01-27 12:58:23 +07:00
a10f87290a Update status doc 2026-01-27 11:32:21 +07:00
152 changed files with 27835 additions and 731 deletions

File diff suppressed because it is too large Load Diff

View 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.

View 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`

View 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

View 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.

View 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)

View File

@@ -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)

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

View File

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

View File

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

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

View File

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

View File

@@ -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)

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

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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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.

View File

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

View File

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

View File

@@ -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)

View File

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

View File

@@ -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)

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

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

View File

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

View File

@@ -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)

View File

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

View File

@@ -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)

View File

@@ -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)

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

View File

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.

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

View File

@@ -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.

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

View File

@@ -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.

View File

@@ -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)

View File

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

View File

@@ -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)

View File

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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View 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)

View File

@@ -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)

View File

@@ -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.

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
View File

@@ -0,0 +1,3 @@
# OpenStreetMap OAuth
VITE_OSM_CLIENT_ID=jIn8l5mT8FZOGYiIYXG1Yvj_2FZKB9TJ1edZwOJPsRU
VITE_OSM_OAUTH_URL=https://www.openstreetmap.org

View 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

View File

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

View 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 }}

View File

@@ -1,3 +1,7 @@
export default {
extends: ['stylelint-config-standard'],
rules: {
'no-descending-specificity': null,
'property-no-vendor-prefix': null,
},
};

View File

@@ -1,120 +1,76 @@
# Project Status: Marco
**Last Updated:** Mon Jan 26 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.
- Uses a **heuristic** (distance + type matching) to link visual clicks to API results (handling data desynchronization).
- **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.
- `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.
- **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 & Data Optimization
### 4. Routing & Architecture
- **Explicit URLs:** Implemented routing support for 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.
- **Performance:** Optimized navigation to prevent redundant network requests. Clicking a map pin passes the existing data object to the route, skipping the `model` hook (no re-fetch) while maintaining correct deep-linkable URLs via a custom `serialize` hook in `PlaceRoute`.
- **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. Features
- **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 -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
3. **Navigation:** Selected place is checked against bookmarks; if found, it uses the Bookmark object. Otherwise, it uses the OSM object.
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
6. 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/styles/app.css`: Mobile CSS fixes (font sizes, control positioning).
- `package.json`: New scripts and dependencies.
- `README.md`: Updated documentation.
## Next Steps & Pending Tasks
1. **Mobile Polish:** Verify "Locate Me" animation on iOS Safari.
2. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections.
3. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
4. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
5. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
## 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.

View File

@@ -1,4 +1,3 @@
<br>
<div align="center">

View File

@@ -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,23 +28,38 @@ 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="icon-btn"
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
class="user-btn"
class="user-btn btn-press"
type="button"
aria-label="User Menu"
{{on "click" this.toggleUserMenu}}

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

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

View 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>
}

View 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>
}

View 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>
}

View File

@@ -1,53 +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 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,
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() {
@@ -59,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}}

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +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 editTitle = '';
@tracked editDescription = '';
@tracked showLists = false;
constructor() {
super(...arguments);
this.resetEditFields();
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'
);
}
@action
resetEditFields() {
this.editTitle = this.name;
this.editDescription = this.place.description || '';
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
}
@action
startEditing() {
if (!this.place.createdAt) return; // Only allow editing saved places
this.resetEditFields();
if (!this.isSaved) return; // Only allow editing saved places
this.isEditing = true;
}
@@ -53,81 +54,144 @@ export default class PlaceDetails extends Component {
}
@action
async saveChanges(event) {
event.preventDefault();
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) {
await this.args.onSave({
...this.place,
title: this.editTitle,
description: this.editDescription,
...changes,
});
}
this.isEditing = false;
}
@action
updateTitle(e) {
this.editTitle = e.target.value;
}
@action
updateDescription(e) {
this.editDescription = e.target.value;
}
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() {
@@ -138,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() {
@@ -154,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() {
@@ -165,44 +242,35 @@ export default class PlaceDetails extends Component {
}
get gmapsUrl() {
const id = this.place.gmapsId || this.place.osmId;
if (!id) return null;
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}}
<form class="edit-form" {{on "submit" this.saveChanges}}>
<div class="form-group">
<label for="edit-title">Title</label>
<input
id="edit-title"
type="text"
value={{this.editTitle}}
{{on "input" this.updateTitle}}
class="form-control"
/>
</div>
<div class="form-group">
<label for="edit-desc">Description</label>
<textarea
id="edit-desc"
value={{this.editDescription}}
{{on "input" this.updateDescription}}
class="form-control"
rows="3"
></textarea>
</div>
<div class="edit-actions">
<button type="submit" class="btn btn-blue">Save</button>
<button type="button" class="btn btn-outline" {{on "click" this.cancelEditing}}>Cancel</button>
</div>
</form>
<PlaceEditForm
@place={{this.place}}
@onSave={{this.saveChanges}}
@onCancel={{this.cancelEditing}}
/>
{{else}}
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}
</p>
{{#if this.place.description}}
{{#if this.showDescription}}
<p class="place-description">
{{this.place.description}}
</p>
@@ -210,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}}
@@ -242,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}}
@@ -305,7 +427,7 @@ export default class PlaceDetails extends Component {
{{#if this.osmUrl}}
<p class="content-with-icon">
<Icon @name="map" @title="OSM ID" />
<Icon @name="map" />
<span>
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
OpenStreetMap
@@ -314,14 +436,20 @@ export default class PlaceDetails extends Component {
</p>
{{/if}}
<p class="content-with-icon">
<Icon @name="map" @title="OSM ID" />
<span>
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
Google Maps
</a>
</span>
</p>
{{#if this.gmapsUrl}}
<p class="content-with-icon">
<Icon @name="map" />
<span>
<a
href={{this.gmapsUrl}}
target="_blank"
rel="noopener noreferrer"
>
Google Maps
</a>
</span>
</p>
{{/if}}
</div>
</div>

View File

@@ -0,0 +1,80 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceEditForm extends Component {
@tracked title = '';
@tracked description = '';
constructor() {
super(...arguments);
this.title = this.args.place?.title || '';
this.description = this.args.place?.description || '';
}
get shouldAutofocus() {
if (typeof window !== 'undefined') {
return window.innerWidth > 768;
}
return false;
}
@action
handleSubmit(event) {
event.preventDefault();
if (this.args.onSave) {
this.args.onSave({
title: this.title,
description: this.description,
});
}
}
@action
updateTitle(e) {
this.title = e.target.value;
}
@action
updateDescription(e) {
this.description = e.target.value;
}
<template>
<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"
value={{this.title}}
{{on "input" this.updateTitle}}
class="form-control"
placeholder="Name of the place"
autofocus={{this.shouldAutofocus}}
/>
</div>
<div class="form-group">
<label for="edit-desc">Description</label>
<textarea
id="edit-desc"
value={{this.description}}
{{on "input" this.updateDescription}}
class="form-control"
rows="3"
placeholder="Add some details..."
></textarea>
</div>
<div class="edit-actions">
<button type="submit" class="btn btn-blue">Save</button>
<button
type="button"
class="btn btn-outline"
{{on "click" @onCancel}}
>Cancel</button>
</div>
</form>
</template>
}

View 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>
}

View File

@@ -4,12 +4,32 @@ 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;
@service router;
@service mapUi;
@action
createNewPlace() {
const qp = this.router.currentRoute.queryParams;
const lat = qp.lat;
const lon = qp.lon;
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 },
});
}
}
@action
selectPlace(place) {
@@ -31,52 +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.log('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();
}
// Update selection to the new saved place object
// This updates the local UI state immediately without a route refresh
if (this.args.onUpdate) {
// When deleting, we revert to a "fresh" object or just close.
// Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice.
// 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) {
// Similar logic for select if needed, but we usually close.
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: [],
@@ -88,7 +100,7 @@ export default class PlacesSidebar extends Component {
try {
const savedPlace = await this.storage.storePlace(placeData);
console.log('Place saved:', placeData.title);
console.debug('Place saved:', placeData.title);
// Notify parent to refresh map bookmarks
if (this.args.onBookmarkChange) {
@@ -115,7 +127,7 @@ export default class PlacesSidebar extends Component {
async updateBookmark(updatedPlace) {
try {
const savedPlace = await this.storage.updatePlace(updatedPlace);
console.log('Place updated:', savedPlace.title);
console.debug('Place updated:', savedPlace.title);
// Notify parent to refresh map/lists
if (this.args.onBookmarkChange) {
@@ -132,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">
@@ -142,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"
@@ -174,20 +196,39 @@ 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
)}}</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
type="button"
class="btn btn-outline create-place"
{{on "click" this.createNewPlace}}
>
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
Create new place
</button>
{{/if}}
</div>
</div>

View 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>
}

View File

@@ -1,75 +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';
export default class SettingsPane extends Component {
@service settings;
@action
updateApi(event) {
this.settings.updateOverpassApi(event.target.value);
}
<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="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
View 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>
}

View File

@@ -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
View 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
View 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

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

View 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" />&#10; <defs
id="defs1"></defs>&#10; <g
id="Layer_x0020_1"
inkscape:label="Layer 1"
inkscape:groupmode="layer"
transform="translate(-66.822266,-0.16483529)">&#10; <metadata
id="CorelCorpID_0Corel-Layer" />&#10; <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)"
/>&#10; </g>&#10;</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

4
app/icons/wikipedia.svg Normal file
View 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

View 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);
};
});

View File

@@ -8,5 +8,9 @@ export default class Router extends EmberRouter {
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
View 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();
}
}

View 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');
}
}
}

View File

@@ -9,9 +9,13 @@ 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.log(`Fetching explicit OSM ${type}:`, osmId);
console.debug(`Fetching explicit OSM ${type}:`, osmId);
return this.loadOsmPlace(osmId, type);
}
@@ -20,7 +24,7 @@ export default class PlaceRoute extends Route {
let bookmark = this.storage.findPlaceById(id);
if (bookmark) {
console.log('Found in bookmarks:', bookmark.title);
console.debug('Found in bookmarks:', bookmark.title);
return bookmark;
}
@@ -31,7 +35,7 @@ export default class PlaceRoute extends Route {
async waitForSync() {
if (this.storage.initialSyncDone) return;
console.log('Waiting for initial storage sync...');
console.debug('Waiting for initial storage sync...');
const timeout = 5000;
const start = Date.now();
@@ -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();
@@ -56,11 +83,14 @@ export default class PlaceRoute extends Route {
deactivate() {
// Clear the pin when leaving the route
this.mapUi.clearSelection();
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
this.mapUi.returnToSearch = false;
}
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;
@@ -71,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