Compare commits

...

644 Commits

Author SHA1 Message Date
raucao 43a417b8d7 Fix Chrome setup
CI / Test (pull_request) Has been cancelled
CI / Lint (pull_request) Has been cancelled
2026-07-25 19:55:05 +02:00
raucao b2c43746e6 Cache npm packages in CI 2026-07-25 19:51:50 +02:00
raucao 10727cc55c Don't run linters in test command
CI / Lint (pull_request) Successful in 1m5s
CI / Test (pull_request) Failing after 15s
2026-07-25 19:48:41 +02:00
raucao 5cc17d57c1 Set up Chrome for CI tests 2026-07-25 19:48:31 +02:00
raucao 8c2879063c Migrate to Gitea Actions
CI / Lint (pull_request) Successful in 1m8s
CI / Test (pull_request) Failing after 2m3s
2026-07-25 19:42:05 +02:00
raucao dbf4709512 Add AGENTS.md and Ember Best Practices skill 2026-07-25 17:57:45 +02:00
raucao 251e61891f 2.3.0 2025-02-17 10:35:44 +04:00
Râu Cao 66e1adc719 Merge pull request #227 from 67P/feature/contributor-list_improvements
Contributor list improvements
2025-02-17 06:26:35 +01:00
raucao 62388a0488 Improve test descriptions 2025-02-17 09:10:04 +04:00
Râu Cao d7fe860353 Merge pull request #226 from 67P/feature/currencies
Use historic BTC rate for expense items; allow BTC as currency
2025-02-16 15:50:25 +01:00
raucao 14da36b806 Only show top 10 contributors by default
Don't waste so much screen real estate. Add button to show all to the
end of the toplist.
2025-02-16 14:21:51 +04:00
raucao 636c79ecc7 Port component to native class 2025-02-16 13:53:11 +04:00
raucao d67bfd59da Hide contributors with 0 kredits in toplist 2025-02-16 13:45:48 +04:00
raucao 67538a9437 Use stable/legacy IPFS node in dev
Newer kubo/ipfs daemons don't work well with the old HTTP client.
2025-02-16 12:56:55 +04:00
raucao 473522986d Fix linter error 2025-01-20 16:19:41 -05:00
raucao 0dd5f6c5ad Change cursor to indicate possible click 2025-01-20 16:07:24 -05:00
raucao 731e01f2c5 Only expand item details on header click
It's annoying not being able to copy details otherwise
2025-01-20 16:05:04 -05:00
raucao f02e5572ba Show amountSats as title of expense item amounts 2025-01-20 15:45:36 -05:00
raucao aad2aaecd1 Update contracts, schemas 2025-01-20 15:45:16 -05:00
raucao 7a61d1c569 Add nvmrc config 2025-01-20 12:36:26 -05:00
raucao 697ace35b5 Use historic BTC rate for expense items; allow BTC as currency 2024-12-18 12:34:31 +04:00
raucao 89ddde28b8 Add util for checking if ISO date string is today 2024-12-18 12:32:52 +04:00
raucao 50b0f2cd7a Add BTC conversion utils 2024-12-18 12:30:12 +04:00
raucao 1b78cb4cb4 2.2.1 2024-03-21 12:26:21 +01:00
Râu Cao b32f805cbd Merge pull request #223 from 67P/bugfix/220-reimbursement_sums
Reimbursement form fixes and improvements
2024-03-21 11:38:45 +01:00
raucao 3110fd347e Remove unused variable 2024-03-20 18:31:16 +01:00
raucao 5612ac4e95 Add button type 2024-03-20 18:28:13 +01:00
raucao 69ab4913aa Require connected account for adding reimbursement
Just in the UI, so nothing gets reloaded by accident later
2024-03-20 18:18:10 +01:00
raucao 39e01e4558 Remove commented code 2024-03-20 17:46:26 +01:00
raucao 7afc75dbff Fix wrong BTC sum when adding multiple line items
closes #220
2024-03-20 17:24:37 +01:00
raucao f5f74ae27f Automatically select connected user/contributor as recipient 2024-03-20 17:24:07 +01:00
raucao 55f7baecf6 Update caniuse 2024-03-20 17:21:21 +01:00
raucao bd199fcf26 2.2.0 2024-03-20 15:17:01 +01:00
Râu Cao 0777c213ba Merge pull request #222 from 67P/feature/reimbursement-lists
Improve reimbursement lists
2024-03-20 14:50:33 +01:00
raucao 071922d979 Remove obsolete debug log 2024-03-20 14:35:01 +01:00
raucao fc044df9fd Add correct role to interactive element
Co-authored-by: Garret Alfert <alfert@wevelop.de>
2024-03-20 14:32:37 +01:00
raucao dd2bdd5332 Show dates in collapsed entries of confirmed reimbursements 2024-03-14 19:15:10 +01:00
raucao 0cd05d14bc Don't fail linting for click handlers on arbitrary HTML elements 2024-03-14 17:55:46 +01:00
raucao 3ee140723c Hide line items by default for confirmed reimbursements 2024-03-14 17:49:12 +01:00
raucao 49b3825af4 Show confirmation block for confirmed reimbursements 2024-03-14 17:48:23 +01:00
raucao af309ff682 Hide veto button for confirmed reimbursements 2024-03-14 16:43:57 +01:00
raucao 53f13e4a63 Move reimbursement items to separate component 2024-03-14 16:43:57 +01:00
raucao bcdd033f34 Use prod budget endpoints in dev by default 2024-03-14 16:43:29 +01:00
raucao b9e7737589 Fix typo 2024-03-14 16:43:21 +01:00
raucao 57b9bccb4a 2.1.1 2024-01-22 15:48:12 +03:00
raucao 5c9fbf5043 Prevent fingerprinting for filenames from config
Ember has no way of finding them in the code and thus cannot turn hrefs
into fingerprinted URLs
2024-01-22 15:46:39 +03:00
raucao 205d2d2afc 2.1.0 2024-01-22 14:08:21 +03:00
Râu Cao 895bf569ff Merge pull request #217 from 67P/feature/budget-balances
Use new balance APIs from akkounts, add Lightning balance
2024-01-14 17:29:07 +01:00
raucao dbedf1dbe8 Use new balance APIs from akkounts
* Use sats integer values instead of floats
* Add lightning Network account balance
* Use icons and titles/descriptions to discern between balance types
* Use more flexible config for balances (to add e.g. RBTC or BTC ecash
  later)
2024-01-12 14:59:57 +03:00
Râu Cao 26f2f2afe9 Merge pull request #216 from 67P/feature/main_nav
Add main navigation menu, replace intro blurb with full About page
2023-12-05 09:17:31 +01:00
Râu Cao 6c456b1184 Update release build 2023-11-17 14:29:24 +01:00
Râu Cao 03e7f14d1c Use native class 2023-08-30 17:18:33 +02:00
Râu Cao e94e747ba1 Wording 2023-08-30 17:18:26 +02:00
Râu Cao 3679412b3c Add main navigation menu, replace intro blurb with full About page 2023-08-30 17:17:39 +02:00
Râu Cao ea15e69d79 Update README 2023-08-15 10:27:27 +02:00
Râu Cao 8cc3da40b7 2.0.0 2023-08-14 18:28:02 +02:00
Râu Cao bdeab512b6 Upgrade kredits-contracts 2023-08-14 17:57:04 +02:00
Râu Cao 97ebebe28c Merge pull request #214 from 67P/chore/remove_last_ethereum_bits
Remove mentions of Ethereum from UI and code, refactor wallet connect
2023-08-14 17:53:44 +02:00
Râu Cao d5edb93ec5 Use new hubot URL 2023-08-14 17:50:47 +02:00
Râu Cao 0ecc4079c2 Ember code updates 2023-08-14 17:50:10 +02:00
Râu Cao 3f080cf553 Open external link in new tab
closes #170
2023-02-02 12:32:50 +08:00
Râu Cao 0bdb4d2021 Connect wallet and fill in address during signup
In case there's a wallet available, ask the user to connect it and fill
in their account address in the form field.

Also, check the status code when submitting it to the back-end and show
an error message instead of the success/completion page in case the
contributor could not be created.
2023-01-21 12:39:14 +08:00
Râu Cao 1e7d8491f9 Refactor wallet connect, automatically propose network 2023-01-20 17:29:20 +08:00
Râu Cao 4356288497 Rename signup route, address property 2023-01-20 17:29:20 +08:00
Râu Cao 87bf8ff2df Inspect user txs on RSK explorer 2023-01-20 17:29:20 +08:00
Râu Cao 26975636a0 Update README 2023-01-20 17:29:17 +08:00
Râu Cao dad56a7b31 Merge pull request #212 from 67P/bugfix/load_contributor
Fix dashboard hanging when loading app from contributor URL
2023-01-20 17:29:05 +08:00
Râu Cao d069fa02ab Add links 2023-01-15 14:41:25 +08:00
Râu Cao d219abc358 RSK -> Rootstock 2023-01-15 14:39:54 +08:00
Râu Cao 13989bf973 Update README
refs #18
2023-01-15 14:38:43 +08:00
Râu Cao b3ab69d599 Update CI badge in README 2023-01-15 14:28:19 +08:00
Râu Cao fe3816501c Fix dashboard hanging when loading app from contributor URL
Loading the app from /dashboard/contributors/:id is failing, because the
contributors are loaded later. This fetches the requested contributor
separately when launching the app from a contributor profile URL.
2023-01-15 14:24:51 +08:00
Râu Cao 854402aa73 Merge pull request #213 from 67P/feature/github_actions_ci
Switch from Travis CI to GitHub Actions
2023-01-15 14:24:14 +08:00
Râu Cao abc19c715b Fix double builds in CI 2023-01-15 14:20:48 +08:00
Râu Cao b6434ac8d2 Remove Travis config 2023-01-15 14:17:30 +08:00
Râu Cao 4788b8c51c Add GitHub Actions CI config 2023-01-15 14:17:09 +08:00
Râu Cao 6a72a5b418 2.0.0-beta.3 2023-01-15 13:18:42 +08:00
Râu Cao d90ddd464a Hide confirmed-in content when confirmed 2023-01-15 13:16:38 +08:00
Râu Cao 2626543f3e Fix linter errors 2023-01-15 13:09:39 +08:00
Râu Cao 088db24534 Merge pull request #207 from 67P/feature/optional_full_sync
Make full data sync optional, start via button
2023-01-15 12:36:59 +08:00
Râu Cao f7b7daa024 Merge pull request #210 from 67P/feature/confirmation-eta
Add confirmed-in component, show ETA for proposed reimbursements
2023-01-15 12:36:43 +08:00
Râu Cao bb11e2267b Merge pull request #211 from 67P/feature/expense-list
Expense list UI improvements
2023-01-15 12:35:17 +08:00
Râu Cao 7cd023a21b Update app/components/add-reimbursement/template.hbs
Co-authored-by: Manuel Wiedenmann <manuel@funkensturm.de>
2023-01-15 12:34:40 +08:00
Râu Cao 1095bf0218 Add more button colors, icon style for small buttons
Plus tag icon for the tag buttons
2023-01-13 15:58:39 +08:00
Râu Cao 138cec0389 Localize date in expense list 2023-01-13 15:58:39 +08:00
Râu Cao a93be41e08 Add expense-list component, DRY up code, add tags 2023-01-13 15:58:39 +08:00
Râu Cao 89f6fa0b5c Use component argument in tests 2023-01-13 15:57:50 +08:00
Râu Cao e2a80eafd1 Remove dev logs 2023-01-13 14:14:24 +08:00
Râu Cao 662e76979b Show confirmation ETA for proposed reimbursements 2023-01-11 14:19:03 +08:00
Râu Cao 6c7de97e38 Get duration in human time 2023-01-11 14:10:35 +08:00
Râu Cao fe27b010da Add confirmed-in component 2023-01-11 13:59:41 +08:00
Râu Cao ff716c68ea Update current block when new one is mined 2023-01-11 13:26:58 +08:00
Râu Cao 84dded2f10 Do not automatically push new release versions 2023-01-11 12:44:56 +08:00
Râu Cao ee664c06c1 2.0.0-beta.2 2023-01-11 12:44:32 +08:00
Râu Cao 62b618b657 Add type to button element 2023-01-11 12:42:15 +08:00
Râu Cao 348dc03429 Merge pull request #209 from 67P/feature/veto_reimbursements
Finish Reimbursement UI (MVP)
2023-01-09 11:34:37 +08:00
Râu Cao d159fca816 Add todo note to async action 2023-01-09 11:31:31 +08:00
Râu Cao a582f41fde Cache reimbursement data on incoming events 2022-12-31 15:07:02 +07:00
Râu Cao 2082b51c5b Add expenses from file
Allow to upload a JSON file containing a list of expense items
2022-12-31 15:05:36 +07:00
Râu Cao e543708b42 Add IPFS inspect button to reimbursements 2022-12-31 12:20:59 +07:00
Râu Cao 0b0dea095f Reject empty tag strings 2022-12-31 12:20:27 +07:00
Râu Cao c7eb81450c Allow to veto reimbursements 2022-12-30 22:26:32 +07:00
Râu Cao ca1ccf6d93 Formatting 2022-12-30 22:25:50 +07:00
Râu Cao 0b79d48be9 Merge pull request #208 from 67P/bugfix/204-reimbursement_form
Fix timezone issue in expense item form
2022-12-30 22:24:43 +07:00
Râu Cao f8cc453d7e Fix timezone issue in expense item form
Dates being added with 00:00 time still carry the timezone set for the
OS/browser, and thus get changed to the previous day when in a positive
offset zone. This change removes the timezone offset entirely when
adding the line item, so that the chosen date is always assumed to be in
UTC time.

fixes #204
2022-12-30 22:20:16 +07:00
Râu Cao f86190030f Make full data sync optional, start via button 2022-11-14 22:58:15 +01:00
Râu Cao 08bedda288 2.0.0-beta.1 2022-11-14 15:56:57 +01:00
Râu Cao 7c8ca45b27 Merge pull request #205 from 67P/feature/improve_caching
Improve caching, fix Firefox private mode usage
2022-11-14 15:48:33 +01:00
Râu Cao ff3fe49dd0 Wording 2022-11-11 21:11:17 +01:00
Râu Cao 92056517a6 Cache contribution vetos
Fixes potentially not marking contributions as vetoed if there's no
incoming event after loading the app. Also improves performance either
way.
2022-11-11 21:11:17 +01:00
Râu Cao 11f19c8344 Refactor adding new contributions from events
Fixes contributions not being added when not already in memory (from
creation). Simplifies the entire function.
2022-11-11 21:11:13 +01:00
Râu Cao 0f8d7d84ca Don't cache contribution details in browser storage 2022-11-11 17:02:45 +01:00
Râu Cao e1780109aa Merge pull request #203 from 67P/feature/rsk-testnet
Migrate/switch to RSK testnet
2022-11-11 17:01:36 +01:00
Râu Cao 8c2f6aa38b Update Travis CI config 2022-11-11 16:57:51 +01:00
Râu Cao afbce03a4a Adapt test for rsk storage namespace 2022-11-11 16:38:39 +01:00
Râu Cao 1f2dd9d198 Use new @kredits/contracts 2022-11-03 16:57:55 +01:00
Râu Cao e4814923f9 Update contracts module branch 2022-10-31 12:48:49 +01:00
Râu Cao ad015f1cee Use required chain ID and network name
Use the chain ID when loading the web3 wallet/provider, and use the
network name for the localstorage namespace.
2022-10-24 16:45:02 +02:00
Râu Cao 0c5aebf7b4 WIP Migrate to RSK testnet 2022-10-24 16:30:38 +02:00
Râu Cao 52f7e86fc9 Merge pull request #202 from 67P/chore/fix_js_linting
Fix ESLint setup
2022-10-06 16:29:52 +02:00
Râu Cao fb66bd4f0d Fix some linter errors and warnings 2022-10-06 12:29:57 +02:00
Râu Cao 19553b9777 Fix eslint setup
babel-eslint isn't working with node 16 anymore

In addition to the new packages, also turn the remaining Ember errors
into warnings, so we can fix them without `npm test` failing in the
meantime.
2022-10-06 12:29:03 +02:00
Râu Cao 441dc21894 Merge pull request #201 from 67P/update-for-new-amounts
Fix for new Contribution#totalKreditsEarned type
2022-10-06 12:26:09 +02:00
bumi 0732911e13 Cleanup 2022-10-06 11:03:50 +02:00
bumi 57934ae7a3 Fix for new Contribution#totalKreditsEarned type
totalKreditsEarned is no longer a Big number but now already a number.
I think this is due to the change of the denomination.
2022-10-06 10:52:22 +02:00
basti 1e240b6539 Merge pull request #199 from 67P/chore/rename_recipient_for_reimbursements
Rename contributorId to recipientId
2022-06-12 07:17:47 +02:00
basti 2c773c147a Merge pull request #200 from 67P/feature/wbtc_to_btc
Switch from ETH/WBTC to BTC
2022-06-12 07:17:17 +02:00
basti e05449a7ff Show wallet balance for BTC instead of Gnosis Safe 2022-05-24 16:19:26 +02:00
basti 44b631c688 Switch from ETH/WBTC to RBTC/BTC 2022-05-22 17:04:28 +02:00
basti 6cd62d3764 Fix looking up contributor by ID string 2022-05-22 17:03:31 +02:00
basti 4c749bca35 Use integer for contributor ID everywhere
closes #145
2022-05-21 18:16:26 +02:00
basti 466354cff7 Update/improve README
Especially the local-contracts part.
2022-05-01 14:19:45 +02:00
basti 721c330f3c WIP Rename contributorId to recipientId 2022-04-27 18:17:33 +02:00
basti d07963ad6b Merge pull request #198 from 67P/chore/dependencies
Upgrade dependencies, fix contracts linking
2022-04-27 18:17:20 +02:00
basti eef9fa1aba Refactor kredits setup code a bit 2022-04-27 18:15:12 +02:00
basti 77dbde8bb4 Update dependencies, build configs 2022-04-27 18:14:56 +02:00
basti 64bd2477c7 Merge pull request #195 from 67P/feature/expenses
Introduce budget, reimbursements for expenses
2021-06-03 16:23:45 +02:00
basti aed29ff982 Remove obsolete property from class and template
Co-authored-by: Garret Alfert <alfert@wevelop.de>
2020-11-17 13:40:44 +01:00
basti f3f158ff26 Update app/services/kredits.js
Co-authored-by: Garret Alfert <alfert@wevelop.de>
2020-11-17 13:38:47 +01:00
basti 2a8fd3b001 Update app/components/topbar-account-panel/component.js
Co-authored-by: Garret Alfert <alfert@wevelop.de>
2020-11-17 12:47:43 +01:00
basti 087cb706d2 Fix JS linting errors 2020-10-31 13:30:44 +01:00
basti b58870a03c Octanify add-reimbursement syntax
Fixes linter errors
2020-10-31 13:05:40 +01:00
basti ccda6ec354 Reset BTC amount to 0 when last expense item removed
Fix a logical bug that prevented setting the amount to 0 upon removing
the last item from the list/form.
2020-10-31 12:58:03 +01:00
basti 2045b94951 Allow HTML comments, remove obsolete comment 2020-10-31 12:49:29 +01:00
basti be16a57043 Octanify add-expense-item syntax
Fixes linter errors
2020-10-31 12:49:03 +01:00
basti ae06385039 Improve mobile layout of budget view a bit 2020-10-31 12:21:31 +01:00
basti 7b1c3c813c Finish reimbursement loading and sync 2020-10-30 15:26:32 +01:00
basti ec50e7deef Fix small layout issue with token amounts
Wasn't enough space for longer amounts
2020-10-30 15:03:54 +01:00
basti c3b1bd2f4c Make font sizes more consistent 2020-10-30 15:03:45 +01:00
basti 93e2a23ac7 WIP reimbursement sync 2020-10-09 15:40:46 +02:00
basti a41d1b1dbd Handle incoming and vetoed reimbursements 2020-10-09 15:09:35 +02:00
basti c0ccf83061 Don't allow form submission unless connected and core 2020-10-09 14:41:10 +02:00
basti 4ab5c3c372 Listen to tx status of submitted reimbursements 2020-10-09 14:29:53 +02:00
basti 5d4fb1c423 Fix reimbursement handling after creation 2020-10-01 17:23:22 +02:00
basti 361ccb103d Remove commented code 2020-10-01 12:58:07 +02:00
basti 2bf590886c Fix newly created contributions not showing
It's trying to use a non-existing block number to calculate
confirmedAtBlock, which then fails the filter to be shown.
2020-10-01 12:50:18 +02:00
basti c7b6f9e3e7 Submit reimbursements 2020-10-01 12:48:01 +02:00
basti c1eb93c596 Mark input fields as valid when re-submitting form 2020-09-30 16:57:10 +02:00
basti d85235ff88 Remove obsolete template code 2020-09-30 16:51:22 +02:00
basti 63675a1e13 Convert fiat totals to BTC and add to total amount 2020-09-30 16:46:35 +02:00
basti 87bc8f5cb5 Visualize disabled input fields by greying out text 2020-09-30 16:06:57 +02:00
basti 4abc1593cb Add total sums of EUR/USD expenses 2020-09-30 16:04:53 +02:00
basti 25b2a8b0e5 Create exchange rate service , use CORS proxy for requests 2020-09-30 15:03:55 +02:00
basti b5700092a8 Add util for fetching bitcoin exchange rate 2020-09-30 12:10:21 +02:00
basti 7288d75237 Don't load more reimbursements when already loaded 2020-09-30 10:32:36 +02:00
basti 444ec62a66 Validate expense/reimbursement forms
Adds some general helpers and styles for minimalistic form validation.
2020-09-29 19:31:58 +02:00
basti 326d46b1a1 Validate total amount 2020-09-29 18:09:48 +02:00
basti 083c873631 Scroll to current item; remove some obsolete code 2020-09-29 17:59:00 +02:00
basti 37dc91da57 Clearly highlight focused buttons 2020-09-29 17:59:00 +02:00
basti 7c86edcaaa Remove expense item from list 2020-09-29 17:59:00 +02:00
basti e4d2fdfce4 WIP Reimbursements/expenses 2020-09-29 17:58:38 +02:00
basti 4722064337 WIP expense items 2020-09-28 11:46:55 +02:00
basti 9f35add07f WIP Reimbursement UI 2020-09-10 16:28:01 +02:00
basti e86541574d Format and round the USD value of balances 2020-08-13 16:30:39 +02:00
basti 5f90bc23bc Dynamically load all balances from Safe 2020-08-13 16:13:50 +02:00
basti 1dd63b2c7a Basic styles for budget balances 2020-08-13 15:43:51 +02:00
basti f1bd20a6f4 Add helper for formatting cryptocurrencies 2020-08-13 15:19:17 +02:00
basti ec6b72008d Add whitespace between reimbursement items in list
They're a bit difficult to scan otherwise.
2020-08-13 14:58:49 +02:00
basti dec71c6f4f Load community funds balances from mainnet Gnosis Safe 2020-08-13 00:27:40 +02:00
basti 52172ea065 add-reimbursement component 2020-08-12 22:53:08 +02:00
basti f63d14d7b4 Move sorted contributors to kredits service 2020-08-12 22:51:52 +02:00
basti ef0fc11edf Add reimbursement form route and component 2020-07-25 18:24:16 +02:00
basti 083334ed18 Don't redirect to dashboard after connecting web3 account 2020-07-25 18:23:55 +02:00
basti 69a6c850bf Convert route to class 2020-07-25 18:22:02 +02:00
basti 8074d2d0e3 Hide empty sections in budget 2020-07-10 13:01:06 +02:00
basti 11c3078b55 Style reimbursement list items 2020-07-09 23:53:06 +02:00
basti fb267813fb Introduce item-list CSS, move item colors to variables 2020-07-09 22:41:11 +02:00
basti a96cca879d Use browser's locale for currency format 2020-07-09 22:15:25 +02:00
basti 4849a755dc Render currencies etc. 2020-07-09 17:07:05 +02:00
basti eaac245f4e WIP Basic reimbursement lists 2020-07-09 15:46:20 +02:00
basti 95743330e4 WIP Generic data handling and caching
Add basic support for reimbursements via new, generic data handling
functions.
2020-06-27 16:35:45 +02:00
basti 4a9dd5ff09 Re-organize functions of kredits service 2020-06-27 14:01:05 +02:00
basti 3b127b0e81 WIP Budget route and basic layout 2020-06-27 13:36:28 +02:00
basti 769317777c Refactor routes
* Add specific dashboard route
* Port application route to Octane
* Fetch/sync contributors in application route, but only sync
  contributions in dashboard route for now
2020-06-27 13:36:02 +02:00
basti 174073fc42 1.17.0 2020-06-02 11:55:29 +02:00
basti 733e5e3683 Merge pull request #191 from 67P/chore/update_dependencies
Update dependencies
2020-06-02 11:52:10 +02:00
basti 090d1d0856 Update dependencies
Fixes a bunch of security audit warnings in the process.
2020-06-02 11:25:35 +02:00
basti e047cc45ba Merge pull request #190 from 67P/feature/improve_contribution_sync
Improve data sync
2020-06-02 10:55:36 +02:00
basti c64a503546 Remove unused async from function
Throws a linter warning, now that it's not an RSVP promise anymore.
2020-05-31 12:46:51 +02:00
basti 13021d665d Merge pull request #188 from 67P/feature/schema_updates
Add new contribution kinds
2020-05-31 10:45:16 +02:00
basti b5dbb2449d Merge pull request #189 from 67P/feature/data_loading
Cache data in browser storage, and load from local cache when present
2020-05-31 10:44:36 +02:00
basti 302c3315a3 Update debug logs while fetching past contributions 2020-05-29 22:28:42 +02:00
basti bac4c1c425 Load all past contributions once everything else is loaded 2020-05-29 22:04:03 +02:00
basti c54d754c89 Only sync contributions if loaded from cache 2020-05-29 20:55:19 +02:00
basti 63dd5430bc WIP Sync contributions 2020-05-29 18:56:05 +02:00
basti eb2c613ac9 Extract data loading from fetch methods 2020-05-29 13:12:39 +02:00
basti bf6d9dbc31 Remove RSVP from kredits service
Just native Promise will do.
2020-05-29 13:11:36 +02:00
basti c491f10ec3 Remove obsolete utils 2020-05-28 14:18:30 +02:00
basti d4b546bfcd Load contributions from cache if present
And then fetch more from network. Also, only load 30 from network before
first render (with nothing cached), then load more after the page has
rendered.
2020-05-28 13:59:10 +02:00
basti 69141a31c4 Process contribution data before loading models 2020-05-28 13:44:32 +02:00
basti e7c9620bbe Add network namespace for browser cache
We want to be able to cache data from multiple networks side-by-side,
without them interfering with each other.
2020-05-28 11:53:19 +02:00
basti 832de3c06f Load contributors from cache if present
And then update from network later
2020-05-28 11:33:47 +02:00
basti 50e1fc728c Cache contributors in IndexedDB 2020-05-27 17:33:36 +02:00
basti 871731110b Process contributor data
Add util function for processing contributor data, and remove bignums,
etc.
2020-05-27 16:27:08 +02:00
basti f996c89dfe Add localforage, basic cache service 2020-05-27 12:59:37 +02:00
basti 1d8e9ddf5d Remove obsolete npm cache Travis config
It's cached by default now
2020-05-27 11:19:23 +02:00
basti 352c9fe3ab Add new contribution types 2020-05-27 11:15:42 +02:00
basti e2a944ed5b Update schemas package
Use the new one from the org namespace.
2020-05-16 13:21:02 +02:00
basti c57d4162d7 1.16.0 2020-05-16 11:28:23 +02:00
basti 41cc37d3eb Merge pull request #186 from 67P/chore/upgrade_ember_and_deps
Upgrade Ember and deps to 3.18/Octane
2020-05-11 13:18:58 +02:00
basti 081584a508 Remove obsolete file 2020-05-11 13:15:47 +02:00
basti 3cc01cce4e Fix some minor template issues 2020-05-11 13:15:17 +02:00
basti cedbe3cb3d Formatting 2020-05-11 13:03:46 +02:00
basti b8da04c145 Re-add contribution status class 2020-05-11 13:03:46 +02:00
galfert 009caa53e5 Fix typo in contribution-status helper
The wrong property name caused an exception when trying to remove the observer.
2020-05-11 12:18:07 +02:00
basti 9a1f543921 Mark remaining template-lint issues as pending
These need more updates outside of the templates, like e.g. Glimmer
components and such.
2020-05-11 10:24:38 +02:00
basti 22300c2980 Fix actions, etc. 2020-05-11 10:21:23 +02:00
basti 018132034d Fix github icon filename 2020-05-11 10:21:23 +02:00
basti f5d7916556 Turn on warnings for observer usage 2020-05-11 10:21:23 +02:00
basti 12df28f7fd Fix ESLint errors 2020-05-11 10:21:22 +02:00
basti 2f4f350a93 WIP Octanify the templates 2020-05-11 10:21:18 +02:00
basti 4946b3311d Remove all proposal-related code
We're not using proposals at the moment, so it's just in the way.
2020-05-10 10:25:06 +02:00
basti cc2f91afce Do not lint icon templates 2020-05-09 17:39:54 +02:00
basti aa351cbe17 Move icon templates out of pod directory
Glimmer components don't have extra markup around them, so we don't need
the component JS files anymore. Also, we can easily ignore the icon
templates when linting now.
2020-05-09 17:37:58 +02:00
basti 065c635580 Upgrade to latest Ember, etc. 2020-05-09 17:07:35 +02:00
basti 67e3c9a7c3 Merge pull request #185 from 67P/dependabot/npm_and_yarn/jquery-3.5.0
Bump jquery from 3.4.1 to 3.5.0
2020-04-30 19:54:31 +02:00
dependabot[bot] 4f4b3c5c17 Bump jquery from 3.4.1 to 3.5.0
Bumps [jquery](https://github.com/jquery/jquery) from 3.4.1 to 3.5.0.
- [Release notes](https://github.com/jquery/jquery/releases)
- [Commits](https://github.com/jquery/jquery/compare/3.4.1...3.5.0)

Signed-off-by: dependabot[bot] <support@github.com>
2020-04-30 16:18:08 +00:00
basti 4f6bafa31e Merge pull request #184 from 67P/dependabot/npm_and_yarn/acorn-5.7.4
Bump acorn from 5.7.3 to 5.7.4
2020-03-16 10:50:11 -05:00
dependabot[bot] 94846c6e00 Bump acorn from 5.7.3 to 5.7.4
Bumps [acorn](https://github.com/acornjs/acorn) from 5.7.3 to 5.7.4.
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/5.7.3...5.7.4)

Signed-off-by: dependabot[bot] <support@github.com>
2020-03-16 12:37:30 +00:00
basti 45d553866e Remove obsolete file 2019-12-14 16:35:13 +01:00
basti 8ac6e3ad3c 1.15.0 2019-12-14 12:06:28 +01:00
basti 215ad1d640 Merge pull request #180 from 67P/feature/add_contribution_with_params
Add "give kredits" button to contributor profiles
2019-12-13 17:33:18 +01:00
basti 3f4cc39067 Refactor assignment
Co-authored-by: Garret Alfert <alfert@wevelop.de>
2019-12-13 17:04:52 +01:00
basti 0a6b6180b9 Add "give kredits" button to contributor profiles
closes #172
2019-12-12 17:33:08 +01:00
basti 536868002f Support query params for adding contribution
Just contributor ID, kind, and amount for now.
2019-12-10 18:16:54 +01:00
basti 04a7f9139d Allow partial override of contribution attributes 2019-12-10 13:40:08 +03:00
basti 863b542e3e Add failing test for partial attribute override
Currently the default attributes are only set correctly when either none
or all attributes are handed to the component
2019-12-10 13:18:21 +03:00
basti 23d86f1502 Add unit test for add-contribution component 2019-12-10 13:09:30 +03:00
basti 314cf58c29 1.14.0 2019-12-02 15:59:30 +01:00
basti 9c063b82f1 Merge pull request #169 from 67P/feature/163-small_screen_details_new
Show contributor/contribution details on mobile
2019-12-02 15:57:22 +01:00
basti b397e4faf6 Merge pull request #168 from 67P/bugfix/ios_button_styling
Reset iOS button styling
2019-11-29 21:07:35 +01:00
galfert 256197094e Don't show intro text on details page 2019-11-29 18:06:32 +01:00
galfert 9898d4bb1d Show contributor/contribution details on mobile
Refs #163

On small screens, when selecting a contribution or contributor, the
lists are hidden and only the details are shown.
2019-11-29 18:05:52 +01:00
galfert c321dc51b0 Reset iOS button styling
Removes the pill shaped, shaded button styling on iOS.
2019-11-29 17:44:22 +01:00
basti 8c759601ca 1.13.0 2019-11-27 15:22:02 +01:00
galfert 15e5d3c2fb Merge pull request #166 from 67P/feature/welcome_text
Add intro, fix topbar on mobile, fix filter button
2019-11-27 13:50:31 +01:00
basti 47b7a6501b Add intro text for new users
closes #164
2019-11-27 12:39:49 +01:00
basti 42304a8e27 Fix account panel breaking out of topbar on mobile
refs #163
2019-11-27 11:55:08 +01:00
basti 9a4db6daf6 Do not require account for filtering contributions 2019-11-27 11:00:10 +01:00
basti 26dc78f553 1.12.0 2019-11-22 12:30:20 +01:00
galfert 5a618f7ef9 Merge pull request #165 from 67P/feature/161-copy_contribution
Add copy/re-submit button to all contribution details
2019-11-22 11:55:03 +01:00
basti 5d29bd9604 Add copy/re-submit button to all contribution details
We can in fact just use the exact same re-submission as for vetoed
contributions. So all that is needed is a new button in the UI.

closes #161
2019-11-21 21:13:42 +01:00
basti 97cc71024f Merge pull request #160 from 67P/bugfix/remove_redundant_pending_status
Remove redundant pending status from contribution-status helper
2019-11-15 16:59:50 +01:00
galfert 58f1382df1 Fix helper test filename
Files that don't end with '-test', will not be run.
2019-11-15 16:23:28 +01:00
galfert 6c5406c0b9 Remove redundant pending status from contribution-status helper 2019-11-15 15:28:46 +01:00
basti b2457421be 1.11.0 2019-10-18 17:38:50 +02:00
basti 134eb7d355 Update deps
Fixes security vulns
2019-10-18 17:36:55 +02:00
basti 1dc1a9767e Merge pull request #159 from 67P/feature/pending_changes
Gracefully handle pending changes for contributions
2019-10-18 17:31:41 +02:00
galfert 243a41db88 Remove volatile() property modifier
The modifier got deprecated and wasn't actually needed in any of the used cases.
2019-10-18 16:39:10 +02:00
galfert 523d4524b5 Make contribution-status helper detect pending changes 2019-10-18 16:37:44 +02:00
galfert c21613f20a Merge branch 'master' into feature/pending_changes 2019-10-18 15:17:41 +02:00
basti 12b127c75d Merge pull request #158 from 67P/feature/recompute_helper
Make contribution-status helper recompute
2019-10-18 09:56:50 +02:00
basti a7dd058d21 Remove obsolete parens 2019-10-18 09:52:38 +02:00
basti de6606b29d Merge pull request #156 from 67P/feature/profile_zoom
Add support for Zoom display name in profiles
2019-09-20 23:51:35 +02:00
basti ec701b3130 Update lockfile 2019-09-20 23:45:33 +02:00
galfert 8e7430ebb9 Make contribution-status helper recompute
When any of the depending properties of the contribution changes, the helper recomputes its output.
2019-09-20 18:27:40 +02:00
basti 1deecafee7 Handle pending changes for contributions
This adds the pending tx data to pending contributions (after adding or
vetoing, until the tx is mined). It also disables the veto button while
pending.
2019-09-20 15:43:34 +02:00
basti 51d8b6c8c1 Respect disabled button color for all buttons 2019-09-20 15:40:22 +02:00
bumi b28f6e6bd7 Use official kredits-contracts npm release 2019-09-20 11:17:38 +02:00
basti 4ab28016a3 Depend on contracts master
PR has been merged
2019-09-18 09:38:10 +02:00
basti 6995b67a22 Add support for Zoom display name in profiles 2019-09-18 09:35:24 +02:00
basti 0076d59a42 Remove obsolete ID from icon markup 2019-09-18 09:35:06 +02:00
basti 36b92d97ca Use configured IPFS gateway URL on contributor profile 2019-09-18 09:34:11 +02:00
basti f272bcfcbc Prevent transition abortion on contributor save 2019-09-18 09:32:57 +02:00
basti 985e1673ac Add Zoom icon 2019-09-18 09:31:58 +02:00
basti fe4af46852 1.10.0 2019-09-10 17:01:23 +02:00
basti 7b9659e22f Update deps
Fixes security vulns
2019-09-10 16:59:20 +02:00
basti 8be18fc27c Merge pull request #155 from 67P/feature/resubmit_vetoed_contribution
Re-submit vetoed contributions
2019-09-10 16:53:36 +02:00
basti 7e518780c5 Merge pull request #154 from 67P/feature/sign_in
Improve account connect/loading, check network
2019-09-10 16:48:55 +02:00
basti 89391c1543 Update details of pending contribution when mined
Fetches complete details of new contributions and replaces any potential
pending, incomplete objects in the collection.
2019-09-10 15:21:50 +02:00
basti 05e3118a0c Re-submit vetoed contributions 2019-09-10 11:47:07 +02:00
basti 494b5d9bdd Use configured IPFS gateway URL for button 2019-09-05 11:53:20 +02:00
basti f8f1ad644c WIP: vetoed contribution details
Adds a blurb and resubmit button to the detail view for vetoed
contributions.
2019-09-05 11:36:50 +02:00
basti 5a5051bac6 Check required network before using user provider
closes #82
2019-09-04 18:51:49 +02:00
basti 304c0ac8d0 Only use user wallet if it's already open/connected 2019-09-04 18:29:25 +02:00
basti cae13ed662 Sign in button to connect account 2019-09-04 18:22:48 +02:00
basti 729110f8d1 Remove obsolete APM domains 2019-09-04 17:04:18 +02:00
basti 11f66de6e6 1.9.0 2019-09-03 13:43:17 +02:00
basti 9bf2ae2d7f Merge pull request #151 from 67P/feature/layout_sizing
Improve sizing of entire layout
2019-09-03 13:40:38 +02:00
basti 2a384f3eed Improve sizing of entire layout 2019-09-01 18:06:56 +02:00
basti 6b3666def9 1.8.0 2019-09-01 16:04:21 +02:00
basti 74c8e32cc6 Merge pull request #142 from 67P/feature/signup
Sign up via GitHub
2019-08-29 12:52:04 +02:00
basti 5dde978499 Merge branch 'master' into feature/signup 2019-08-29 12:46:00 +02:00
galfert 0625ee9d79 Handle undefined Github access token 2019-08-28 12:53:21 +02:00
basti dc5dfe19f2 Add success page to GitHub signup 2019-08-27 18:19:46 +02:00
galfert c518b7f565 Merge pull request #149 from 67P/chore/dao_address
Rename kernel address to DAO address
2019-08-27 15:12:45 +02:00
basti d13242b210 Rename kernel address to DAO address
We're making the naming consistent everwhere.
2019-08-27 15:00:18 +02:00
basti ada029586a Merge pull request #148 from 67P/doc/dev_network
Documentation for IPFS and Metamask
2019-08-26 18:53:24 +02:00
galfert 7bf5fa444a Add notice about Metamask when using local devchain 2019-08-26 18:09:38 +02:00
galfert d08d5f9e8d Add IPFS Gateway config to readme 2019-08-26 18:00:17 +02:00
basti a2bd3f76b8 1.7.0 2019-08-12 14:55:54 +02:00
basti a4ca37d882 Merge pull request #146 from 67P/feauture/141-time_picker
Add time input to contribution form
2019-08-12 14:52:07 +02:00
basti 143317d909 Merge pull request #147 from 67P/docs/readme
Fix npm run start:local instructions
2019-08-04 19:43:06 +02:00
fsmanuel d8bcc8ff67 Fix npm run start:local instructions 2019-08-04 19:36:09 +02:00
basti e4a39ec67c Fix bug with default date input
When the input isn't touched by the user, the date isn't stored as an
array on the property.
2019-08-02 16:58:12 +02:00
basti 5b47ba9e11 Import missing module 2019-08-02 16:58:01 +02:00
basti d8f565cd9e Use start of current hour as default contribution time
Co-Authored-By: Garret Alfert <alfert@wevelop.de>
2019-08-02 16:08:13 +02:00
basti a9480a09f1 Add time input to conribution form
After adding it as a separate field, I figured out that we should use
the same picker, because otherwise we'd have to keep the date in sync
depending on the time zone. Zones are too messy in that regard, and the
date is already normalized to UTC before creating the contribution
record.

closes #141
2019-07-31 13:20:39 +02:00
galfert 60ba019139 Fix HAL8000 URL
Wrong port number.
2019-07-26 19:02:41 +02:00
galfert 413bcddb1e Wire up signup via Github 2019-07-26 03:38:18 +02:00
basti c2273d1778 Merge pull request #144 from 67P/feature/validate_eth_addresses
Validate Ethereum addresses in input fields
2019-07-24 17:30:51 +02:00
basti 780afc9bc6 Fix wrong property name 2019-07-24 11:56:51 +02:00
basti a8ae2b0156 Validate Ethereum addresses in input fields 2019-07-22 17:29:31 +02:00
basti d6d2b1a61c Add signup completion page 2019-07-21 17:03:50 +02:00
basti 4c70c0d233 Basic signup view 2019-07-21 16:04:00 +02:00
basti 725d36bed6 Add signup button for unknown users 2019-07-18 19:43:39 +02:00
basti 0a71d7ad91 Add signup route 2019-07-18 19:31:03 +02:00
basti 6a176b5904 Fix empty name in account info
When the user brings Ethereum accounts, but none of them is known as
contributor, instead of "Anonymous" it just shows nothing. This fixes
the if condition in the template to eliminate the faulty case.
2019-07-18 19:27:53 +02:00
basti c7d046aa46 Move topbar account info to component, add tests
Adds a failing test for users with wallet, but no known contributor
account, where it currently shows no name at all.
2019-07-18 19:26:16 +02:00
basti aa9c3648c0 1.6.0 2019-07-17 17:41:49 +02:00
galfert 9c18593529 Merge pull request #140 from 67P/feature/contribution_details
Contribution details
2019-07-17 17:23:14 +02:00
basti 52fe7ff026 Remove commented CSS 2019-07-17 16:55:04 +02:00
basti b02967c5ef Basic contribution details
Adds a details pane for contribution records, similar to contributor
profiles.

Just the basic details for now. Can be extended with all kinds of
data, links, icons, etc. from here on.
2019-07-17 12:58:16 +02:00
basti 7236b2d2b3 Add basics for contribution details
Route, controller, links, selected property/styles, etc.
2019-07-16 15:35:35 +02:00
basti fd1ae0a612 Merge pull request #139 from 67P/feature/update_ember
Update dependencies
2019-07-15 20:07:02 +02:00
basti 525a39479b 1.5.1 2019-07-15 19:19:29 +02:00
galfert 612b76300c Remove ember-cli-update dependency
All but one of the ~80 vulnerabilities reported by `npm audit` were from dependencies of `ember-cli-update`.
2019-07-15 19:17:56 +02:00
galfert 42fe01b349 Run codemods 2019-07-15 19:17:56 +02:00
galfert 80af572742 Run ember-cli-update --to 3.10.1 2019-07-15 19:17:56 +02:00
galfert d7a0817ac6 Update ember-cli-update 2019-07-15 19:17:56 +02:00
basti c65880972a Merge pull request #138 from 67P/feature/use_ember_auto_import
Use ember-auto-import instead of ember-browserify
2019-07-15 18:54:55 +02:00
galfert c94319e6a8 Use same fork of ndjson as ipfs-http-client does 2019-07-15 18:07:58 +02:00
galfert f9dbd47922 Add ndjson dependency
Required for ipfs-http-client dependency
2019-07-15 17:50:30 +02:00
galfert 636ac30622 Use ember-auto-import instead of ember-browserify 2019-07-15 17:11:41 +02:00
galfert 791943bb95 Merge pull request #137 from 67P/feature/mark_selected_profile
Mark contributor in list as selected when opening details
2019-07-12 19:02:00 +02:00
basti d9ae654f42 Mark contributor in list as selected when opening details 2019-07-12 16:02:32 +02:00
basti 8a156f964b Remove obsolete CSS 2019-07-12 16:02:21 +02:00
basti 743a4cc3ac Add release drafter config
Configures the Release Drafter app, which automatically adds draft
release notes with the titles of merged PRs.

https://github.com/organizations/67P/settings/installations/1265652
2019-07-12 12:04:29 +02:00
basti 5f1ec4887e 1.5.0 2019-07-12 11:50:55 +02:00
bumi fe16a605c1 Merge pull request #136 from 67P/dev/local_ipfs_gateway
Use local default IPFS gateway
2019-07-12 09:43:36 +00:00
basti 9cf743b02a Merge pull request #135 from 67P/feature/contributor_profiles
Contributor profiles
2019-07-12 11:42:26 +02:00
basti d7294d9d7f Remove debug log 2019-07-12 11:39:18 +02:00
basti d8a9886442 Use local default IPFS gateway
Same as for ipfs.kosmos.org, this turns document fetches into GET
requests, which will be cached.
2019-07-11 22:24:51 +02:00
basti d83d671580 Add account links to contributor profiles 2019-07-11 15:33:17 +02:00
basti 0bbca079c5 Link site name to dashboard
Enables closing of details pane
2019-07-11 10:02:50 +02:00
basti 8bd7a77d44 Only use 3-pane layout when profile selected
Later it can be used for contribution details and other things, too.
2019-07-11 09:59:03 +02:00
basti a73a4da575 Redirect index to dashboard 2019-07-11 09:43:24 +02:00
basti 8cc1b02d19 WIP Details pane + contributor profiles
Adds a 3-pane layout option for showing details, as well as a dashboard
sub-route for showing contributor details in the new details pane.
2019-07-11 09:35:01 +02:00
basti 6b49ca26c0 Add link title for contributions
Enables a user to read the whole title in case it's cut off
2019-07-10 15:00:44 +02:00
basti ffe555e2ee Cut off long contribution titles with an ellipsis
Prevents line breaks of titles
2019-07-10 14:48:18 +02:00
basti aa98b35796 1.4.0 2019-07-10 12:31:45 +02:00
galfert b2bbbfd010 Merge pull request #134 from 67P/feature/quick_filter
Add quick filters
2019-07-08 15:52:06 +02:00
basti edd7ffd3c5 Add quick filter for contribution kind 2019-07-06 16:16:28 +02:00
basti abb7d95804 Add quick filters 2019-07-06 15:49:29 +02:00
basti 0e0b1afe3a WIP: add quick filter buttons 2019-07-05 22:01:51 +02:00
basti 29a6d79d38 Merge pull request #132 from 67P/feature/ipfs_gateway
Use new IPFS gateway for document fetches
2019-07-02 18:46:10 +02:00
basti 07c579850e 1.3.0 2019-07-02 17:32:25 +02:00
basti 091ebf4b6a Use new IPFS gateway for GET fetches
When adding the gateway URL, the wrapper will do a normal fetch to
`https://ipfs.kosmos.org/ipfs/$hash` instead of using the generic POST
requests to the IPFS API endpoint. This enables normal browser caching
for all IPFS documents in this app.
2019-07-02 17:20:19 +02:00
basti b4e89492b4 1.2.0 2019-06-27 19:51:21 +02:00
galfert f5c89fd567 Merge pull request #131 from 67P/dev/version_script
Ensure share repo is updated when versioning
2019-06-27 17:51:11 +02:00
galfert 1fbf8f0769 Merge pull request #130 from 67P/feature/sort_contributions
Sort contributions by date instead of ID
2019-06-27 17:50:21 +02:00
basti cc8952f844 Ensure share repo is updated when versioning
Currently, it's easy to forget manually pushing to master after
versioning/building. In which case things can be merged remotely, and
when pulling with rebase the version commit ends up being changed and
behind what was actually tagged.

This adds a postversion script which simply pushes to the remote
automatically after the version commit and tag have been added by npm.
2019-06-27 16:40:42 +02:00
basti 25ea20e63b Sort contributions by date instead of ID
This changes the sorting to sort first by date and time, and only then
by ID.

refs #121
2019-06-27 16:38:56 +02:00
basti e60651858d 1.1.1 2019-06-27 16:37:37 +02:00
basti 24390c6503 Merge pull request #129 from 67P/feature/disable_travis_success_notifcations
Don't send emails for successful Travis builds
2019-06-27 16:18:56 +02:00
galfert c1d5c90745 Don't send emails for successful Travis builds 2019-06-27 16:09:24 +02:00
basti 3d20bce5e3 Merge pull request #128 from 67P/feature/new_contribution_form_name
Use generic contributor name in new contribution form
2019-06-27 15:06:09 +02:00
galfert 3fe8043c76 Use generic contributor name in new contribution form
Use the generic name instead of the Github username
2019-06-27 14:57:52 +02:00
basti bad86232b4 Merge pull request #127 from 67P/bugfix/125-contribution_kind
Fix template syntax error
2019-06-12 16:47:09 +02:00
basti 7eb14ac4e2 Ignore release dir in eslint 2019-06-12 16:27:01 +02:00
basti 8b0cd8c986 Fix template syntax error
Wasn't able to select the "special" contribution kind, due to it being
bound to the wrong property.

fixes #125
2019-06-12 16:24:01 +02:00
basti 5064a472f2 1.1.0 2019-06-12 16:02:02 +02:00
basti afc5a5fe50 Merge pull request #126 from 67P/dev/production_builds
Improve production builds and deployment
2019-06-12 15:59:10 +02:00
basti f9f6786f95 Improve production builds and deployment
Removes all custom build & deploy scripts, switching to 5apps Deploy's
new build directory config instead. Builds production to `release/` when
updating the version using `npm version`.
2019-06-12 15:22:29 +02:00
basti 03f31e2d20 Merge pull request #123 from 67P/feature/122-edit_contributors
Update contributor profiles
2019-06-02 01:56:16 +02:00
basti 1c1f772ff5 Update contributor profiles
* Adds a new page for updating profiles
* Refactors the add-contributor component to allow for updating
  existing contributors
* Adds input labels to the contributor form and improves placeholders
* Adds event handlers for all contract contributor changes and uses them
  for updating the UI

refs #122
2019-05-29 16:14:11 +02:00
basti 58bd729acb Formatting 2019-05-29 16:12:48 +02:00
basti 1ea5305e6e Merge pull request #119 from 67P/feature/118-contribution_form
Submit contributions via form
2019-05-29 09:49:16 +02:00
basti e84a1cbc4e Remove obsolete scrolls 2019-05-28 16:31:56 +02:00
basti 29115d9b16 Make spacing/layout more consistent 2019-05-23 14:55:03 +02:00
basti 409d5e33f9 Add "special" category to chart test
Ensure that it's not shown in the chart (which is already the case).
2019-05-23 14:30:36 +02:00
basti 883b5dc415 Add "special" category for contributions 2019-05-23 14:22:34 +02:00
basti 16ee3b6b6b Move contributor-creation form for separate view
closes #74
2019-05-23 14:17:33 +02:00
basti 4efd6f8f88 Improve whitespace a bit 2019-05-23 14:09:20 +02:00
basti af9951ca5a Add newly created contributions to collection 2019-05-23 12:48:18 +02:00
basti c30c6c2b36 Refactor layout and button styles
* Add single-column layout for forms
* Add optional nav/buttons to section headers
2019-05-23 12:19:29 +02:00
basti 8bd4c6e6e1 Use common forms partial for form styles 2019-05-23 10:36:27 +02:00
basti 9a98b37bb6 Improve error message 2019-05-23 10:29:31 +02:00
basti fad10ec1ea Use sorted contributor list for dropdown 2019-05-23 10:29:18 +02:00
basti cec87f6caf Dat gas issue 2019-05-23 10:22:37 +02:00
basti 22a9786168 Add contribution basics 2019-05-23 09:46:09 +02:00
galfert 18be60da9b Merge pull request #116 from 67P/bugfix/vetoed_in_type_graph
Don't include vetoed contributions in graph
2019-05-10 15:58:12 +02:00
galfert 4b6c79345b Merge pull request #117 from 67P/bugfix/vetoed_in_confirmed_list
Remove vetoed contributions from confirmed list
2019-05-10 15:54:41 +02:00
basti f07100b7ea Fix component test 2019-05-10 15:01:51 +02:00
basti 7083da367d Remove vetoed contributions from confirmed list 2019-05-10 13:49:55 +02:00
basti 8720a0faab Add failing test for vetoed in confirmed list 2019-05-10 13:45:54 +02:00
basti d8b1b3d777 Don't include vetoed contributions in graph
Currently, kredits from vetoed contributions are counted in the graph
data.
2019-05-10 13:40:58 +02:00
basti 4c2d95f8f1 Merge pull request #115 from 67P/bugfix/114-contributor_list
Fix contributor list, add kredits service tests
2019-05-08 12:35:00 +02:00
galfert 9386c30d09 Update toplist balance when contribution is vetoed
Co-Authored-By: skddc <sebastian@kip.pe>
2019-05-08 12:15:51 +02:00
basti 6638691c9f Remove vetoed amounts from unconfirmed balances 2019-05-06 18:46:59 +02:00
basti d2bb70e8a1 Add failing test for vetoed amounts in toplist
This specifies the correct behaviour for unconfirmed kredits balances,
which is that amounts from vetoed contributions shouldn't be added at
all.
2019-05-06 18:32:00 +02:00
basti ef45d55521 Fix missing contributors in toplist
Adds contributors without unconfirmed contributions to the
kreditsByContributor collection.

fixes #114
2019-05-06 14:44:42 +01:00
basti f86c53de89 Add failing test for missing contributors in toplist 2019-05-06 14:23:47 +01:00
basti 82b275caaa Add some unit tests for kredits service 2019-05-06 14:21:37 +01:00
basti e524e5558d Add more test fixtures 2019-05-06 14:18:02 +01:00
basti 6593005566 Merge pull request #112 from 67P/feature/111-unconfirmed_balances
Show unconfirmed balances in toplist
2019-05-01 20:30:35 +01:00
basti cf77f20d41 Merge branch 'master' into feature/111-unconfirmed_balances 2019-05-01 19:56:43 +01:00
basti 67ee2d9a74 Merge pull request #113 from 67P/feature/98-veto_unconfirmed_contributions
Veto contributions
2019-05-01 19:43:28 +01:00
basti 48e5ddbc02 Merge pull request #108 from 67P/feature/add_contributor
Fix/update adding contributors
2019-04-29 12:44:15 +01:00
basti e1737392a7 Mark contributions as vetoed on incoming event 2019-04-28 15:26:26 +01:00
basti 00b7b380b9 Fix veto tx failing
Still have to set the gas limit for most functions in order for them to
work. :/
2019-04-28 15:06:28 +01:00
basti 9821c8b2ea Veto contributions 2019-04-28 15:05:56 +01:00
basti c6a37f7e94 Refactor and add new button styles 2019-04-28 14:42:14 +01:00
basti f25c74c7e9 Add helper functions for confirmation status 2019-04-28 14:42:09 +01:00
basti 1c49d56b19 Fix whitespace for contributor stats on mobile 2019-04-28 12:21:18 +01:00
basti 109ffd2898 Fix contributor details view in toplist 2019-04-28 11:58:58 +01:00
basti 7d5b93a62c Start basic fixtures 2019-04-27 20:54:17 +01:00
basti 6c0cb1a29c Add integration test for contributor list
And comment out the index controller one for now, as it's failing and
almost all code in the controller is framework DSL atm.
2019-04-27 20:53:36 +01:00
basti d444b86ca8 Show unconfirmed kredits in toplist
And allow toggling between confirmed and unconfirmed
2019-04-27 19:59:24 +01:00
basti 185d7c58c2 Finish group-by util
Move function to util, add tests.
2019-04-27 18:41:26 +01:00
basti 283ec1d48a Add kreditsByContributor collection
Lists contributors along with their confirmed, unconfirmed, and total
kredits amounts.
2019-04-27 17:35:05 +01:00
basti f32a34a702 Improve code comment 2019-04-27 17:34:04 +01:00
basti afa09f4b8f Move computed collections to kredits service
They should be available from any place with access to the service.
2019-04-27 17:33:25 +01:00
basti 24a3d8beb4 Merge pull request #110 from 67P/chore/more-contributions-per-page
List 200 contributions per page
2019-04-26 18:59:51 +01:00
bumi 2a7ec1073f List 200 contribtuions per page
Kicking the can a bit further down the road...
Currently we do not have any pagination and we got already more
contributions than the default per page setting.
2019-04-25 23:56:18 +02:00
bumi d02fb4716a Merge pull request #109 from 67P/feature/contribution_lists
Improve contribution lists
2019-04-25 19:45:50 +00:00
basti 49bfad51b6 Fix failing test 2019-04-25 19:28:10 +01:00
basti 25ead15014 Fix incorrect loading text
It's mostly waiting for IPFS, not Ethereum.
2019-04-25 19:22:47 +01:00
basti 85a9d1c73b Show person's name when hovering their avatar 2019-04-25 19:22:29 +01:00
basti 247fc8785a Link contributions to their main URL 2019-04-25 19:21:55 +01:00
basti efd1b371f9 Make contribution lists much more dense
We have too many contributions for them to be multiple lines by default.
2019-04-25 19:09:20 +01:00
basti 3c73ddd443 Turn user avatars into a component
This allows us to add them anywhere in the app, and also to add more
features, like e.g. hover info boxes, links, and so on.
2019-04-25 19:08:22 +01:00
basti 29e9df97fb Use Rinkeby explorer URL 2019-04-25 13:33:31 +01:00
basti fa95376fda WIP: Fix/update adding contributors 2019-04-25 13:24:36 +01:00
basti 028391386b Merge pull request #107 from 67P/feature/use-contribution-balances
Display contribution balances not token balances
2019-04-24 09:57:25 +01:00
bumi de937aceee Shorter JS syntax 2019-04-24 09:48:24 +02:00
basti 2f4807cdda Update kredits-contracts 2019-04-23 14:08:01 +01:00
bumi d1791cadfe Display contribution balances not token balances
We want to display the contribution balances (kredits earned) of the
contributors not the token balances.
2019-04-19 18:16:02 +02:00
bumi 83fd9a94b0 Merge pull request #104 from 67P/feature/handle-kredits-balances
Fix kredits balance handling
2019-04-19 13:44:35 +00:00
bumi 562a8d159b Cleanup 2019-04-19 14:54:18 +02:00
basti 1000dd12bc Merge pull request #105 from 67P/chore/package_cleanup
Clean up package.json a bit
2019-04-19 11:09:32 +01:00
bumi 6f4afaf84a List only contrbutors with more than 0 kredits
This is a bit of a problem because we list integer values, so if
somebody has less than 1 Kredit than they would still show up. Not sure
what is the best to do here. - but this should be good enough for now.
2019-04-19 11:45:53 +02:00
basti 5de33a18f5 Clean up package.json a bit
* Remove node 6 from engines
* Remove contributors array, because we don't maintain it automatically
  and it was already out of date
2019-04-19 10:45:30 +01:00
bumi 2aae2a8f90 Fix kredits balance handling
Kredits are stored on the Token contract as uint256 / bignumbers with 18
decimal points. Just like Ether and required by the ERC20 standard.
So we need to work with bignumbers and format a bignumber value here.
2019-04-19 11:37:18 +02:00
bumi 6dcbd1efbf Default to open.aragonpm.eth in production. 2019-04-18 11:23:24 +02:00
basti a2ddb2313d Merge pull request #103 from 67P/chore/weltempfaenger-release
Update kredits-contracts
2019-04-17 10:54:34 +01:00
basti 6de7f64d3d Update kosmos-schemas 2019-04-17 10:45:38 +01:00
basti d0cae59070 Update kredits-contracts 2019-04-17 10:38:36 +01:00
bumi 6bd87ae0de Fu** you failing tests. You should not be here anyway
take my rm -rf muhahaha
2019-04-16 11:04:57 +02:00
bumi 0380ea3e98 Update kredits-contracts to weltempfänger rinkeby release 2019-04-15 19:31:58 +02:00
bumi 9dba432542 Merge pull request #102 from 67P/chore/ethereum-provider
Do not rely on web3 injection with new ethereum provider standard
2019-04-10 14:19:45 +00:00
bumi da608a0012 Do not rely on web3 injection with new ethereum provider standard
when window.ethereum is available web3 is likely not available as the
browser uses the new provider standard.
So we either user the window.ethereum as provider or the
web3.currentProvider as legacy option.
2019-04-09 22:37:48 +02:00
basti 0af78e3dbd Merge pull request #100 from 67P/feature/20-contributions
Switch to contribution token instead of proposals
2019-04-09 22:25:35 +02:00
basti 5db628311e Fix confirmed/unconfirmed filter 2019-04-09 22:15:09 +02:00
basti aa28a14d04 Switch to contribution token instead of proposals
No vetos yet, and only for collections (not creation).

closes #20
2019-04-09 12:55:44 +02:00
basti 9c4e431e3b Update contracts module 2019-04-09 12:54:00 +02:00
basti 8984b1ecb7 Update start script for Rinkeby deployment 2019-04-09 10:10:17 +02:00
basti f05b0bbd8b Remove Bourbon and Neat 2019-04-09 10:09:29 +02:00
bumi bff611f866 Merge pull request #94 from 67P/refactor/healthcheck-1
Add Kredits preflight checks
2019-04-05 13:01:02 +00:00
basti b17e4c0a63 Use latest node.js LTS on Travis CI (#99)
Upgrade Travis image to 18.04, use nodejs 10
2019-04-05 13:54:16 +02:00
bumi f0b75d96db No bignumber anymore
as numbers are uint32 in the contract so no bignumber in JS anymore
2019-04-05 12:31:21 +02:00
bumi 190975b981 Merge pull request #97 from 67P/feature/87-metamask_privacy_mode
Support new Metamask/Web3 privacy mode
2019-04-04 23:24:02 +00:00
bumi e4c9a88eca Merge pull request #95 from 67P/chore/83-infura_auth
Use new Infura URL/auth
2019-04-04 23:07:00 +00:00
basti 1f8fa367a5 Support new Metamask/Web3 privacy mode
closes #87
2019-04-04 12:13:39 +02:00
basti 5202fe0f8e Use new Infura URL/auth
closes #83
2019-04-04 10:57:02 +02:00
bumi f202b34d28 Update README.md 2019-04-04 08:11:53 +00:00
bumi 07fa07eb22 Update README 2019-04-04 10:08:53 +02:00
bumi 137f9ae8bf Add Kredits preflight checks
This should identify potential config issues and print a usefull error
message. e.g. if ipfs is not available.
2019-04-03 20:06:40 +02:00
basti e44a8efa4a Merge pull request #93 from 67P/fix/contributor-list-on-proposals
Remove default contributors value in propsal component
2019-04-03 19:02:05 +02:00
basti a7b288c25f Merge pull request #92 from 67P/fix/bignumber-property
Fix bignumber computed property
2019-04-03 19:01:34 +02:00
galfert 3dbcba5eb2 Add empty array as default for contributors list 2019-04-03 14:25:59 +02:00
bumi bd2a640be0 Remove default contributors value in propsal component
It seems that this somehow prevents/overwrites the contributors which
actually should be loaded in the controller.
2019-04-03 13:46:49 +02:00
bumi 889f2c05fc Fix bignumber computed property
The ethers.js bignumber API changed in v4.x
2019-04-03 13:43:57 +02:00
basti c3f2afcaf5 Use correct contracts branch 2019-04-03 12:35:44 +02:00
basti 32c85ea56b Merge pull request #90 from 67P/update-kredits-contracts
Update kredits-contracts and ethers.js
2019-04-03 11:02:15 +02:00
basti 97210df0ba Merge pull request #91 from 67P/bugfix/72-fix_current_user_highlighting
Fix highlighting of current user in contributor list
2019-04-03 11:00:03 +02:00
galfert 88be3525b5 Fix highlighting of current user in contributor list
Fixes #72

Introduces a helper to determine if a contributor is the current user.
2019-04-03 01:35:08 +02:00
basti 7da7de1edc Make some kredits params configurable 2019-04-02 17:56:27 +02:00
basti 7592d67d69 Update package lock, use new contracts branch 2019-04-01 11:11:31 +02:00
bumi b75ac5c8b9 Update kredits-contracts and ethers.js
This uses the new ethers.js 4.x API which is required by the new
kredits-contracts.
Also Operator is now Proposal in the new kredits-contracts wrapper
2019-03-29 19:54:45 +01:00
basti 2954955e39 Merge pull request #89 from 67P/feature/84-update_ember_3_8
Update Ember to version 3.8.0
2019-03-29 13:03:55 +01:00
galfert 1fb20b1d08 Fix linting errors 2019-03-27 18:19:25 +01:00
galfert bfa0b350ac Fix template linting errors 2019-03-27 18:19:10 +01:00
galfert 487013f301 Run codemods 2019-03-26 23:07:02 +01:00
galfert b16bb2d93a Update to Ember 3.8.0 2019-03-26 22:10:30 +01:00
basti 64997d74e9 Fix wrong level for CSS mobile query 2018-07-20 12:31:32 +02:00
basti 297ed4ee45 Merge pull request #77 from 67P/feature/charts
Add doughnut chart for contribution composition
2018-07-17 20:45:32 +02:00
basti b4a094bc89 Add tests for contributions-by-type chart 2018-07-15 11:57:38 +02:00
basti e43706e2a6 Remove obsolete title from proposal list items 2018-07-14 17:36:45 +02:00
basti 2befc91d90 Add data from proposals to contribution chart 2018-07-14 17:35:06 +02:00
basti 7a35f5177c Add charts.js plugin, basic chart component 2018-07-14 16:36:23 +02:00
basti 6efc3ea790 Merge pull request #76 from 67P/feature/improve_contrast
Add background overlay to improve contrast
2018-07-14 14:00:27 +02:00
basti ef0f3ffbfb Add background overlay to improve contrast
And use lowercase-hyphenated syntax for variables/mixins.
2018-07-12 11:29:45 +02:00
basti 1e14cc695e Merge pull request #75 from 67P/feature/improve_layout
Improve layout, show contribution details
2018-06-22 13:27:00 -07:00
basti c074ccf460 Improve all the things 2018-06-17 16:15:02 +02:00
basti 5436469128 Don't show vote count for confirmed contributions 2018-06-17 15:32:08 +02:00
basti 32714efeb9 Improve layout a bit.
WIP, because this is not ideal. But now it's readable at least.
2018-06-16 17:12:53 +02:00
basti cf777571dc Move contribution info from title to actual content 2018-06-16 16:37:39 +02:00
basti 44b4340a91 New CSS grid layout 2018-06-16 16:09:33 +02:00
basti 24e3171311 Add topbar, move account info to top-right corner 2018-06-16 15:36:35 +02:00
basti 575c1a11c2 Merge pull request #70 from 67P/feature/loading-indicator
Feature: Loading indicator
2018-06-14 19:24:03 +02:00
fsmanuel 47b8421d35 Rename loading.hbs to application-loading.hbs 2018-06-14 19:04:42 +02:00
fsmanuel 38d853a272 Remove spinner route 2018-06-14 19:04:22 +02:00
basti 0320d6149f Merge pull request #69 from 67P/rename-truffle-kredits
Rename truffle-kredits to kredits-contracts
2018-06-14 18:51:05 +02:00
fsmanuel ba9b3530fa Rename truffle-kredits to kredits-contracts 2018-06-14 18:11:56 +02:00
basti e78832e2c8 Merge pull request #67 from 67P/eth-signer-loading
Pass ETH signer only if it can actually sign
2018-06-14 15:14:23 +02:00
bumi 804060ad34 Pass ETH signer only if it can actually sign
We have seen an "no accounts" invalid operation getAddress when trying
to call a contract function in the init calls.
It seems ethers has a problem with a signer that does not have any
accounts - even though we don't try to sign an actual transaction and
only read data from the contract.

This change only passes the signer if we have an unlocked web3 provider
(metamask).
2018-06-14 15:00:43 +02:00
basti 840519a437 Merge pull request #66 from 67P/features/run-scripts
Add npm script to run app with local network
2018-06-07 17:58:02 +02:00
bumi 7c54ebcbd0 Add npm script to run app with local network
npm run start:local - sets NETWORK_ID and WEB3_PROVIDER_URL to local
network defaults
2018-06-07 17:28:13 +02:00
bumi d07c0d67bf Merge pull request #65 from 67P/feature/switch_to_infura
Update contracts, Ethereum/IPFS configs
2018-06-07 14:37:17 +00:00
basti a0ee2f7bb8 Use our new IPFS node 2018-06-07 13:32:43 +02:00
basti d070288e17 Switch from our own Parity to Infura in prod 2018-06-04 14:13:53 +02:00
basti 681f80f6ab Use contracts from npm 2018-06-04 14:13:46 +02:00
bumi eb5c87308a update kredits-contracts/truffle-kredits 2018-04-22 19:47:53 +02:00
basti 3e7fe1b5be Add npm command for running on kovan 2018-04-21 20:19:21 +02:00
basti 552ca3026d Update truffle-kredits 2018-04-21 20:19:01 +02:00
fsmanuel ba50e5a878 Merge pull request #58 from 67P/cleanup/kredits-service
Cleanup kredits service
2018-04-21 10:09:35 +00:00
fsmanuel acd1ce28f4 Use .init() instead of static .setup() 2018-04-21 11:58:17 +02:00
fsmanuel 1b2b1bd447 Fix contributor deserialize test 2018-04-21 11:34:41 +02:00
fsmanuel 7083d2369d Fix eslint 2018-04-21 11:20:47 +02:00
fsmanuel 8fed06b494 Rename initEthProvider to getEthProvider 2018-04-21 11:15:13 +02:00
fsmanuel 75650c19ab Cleanup kredits service 2018-04-21 11:05:02 +02:00
fsmanuel ade47dadde Lock ethers.js at 3.0.15 2018-04-21 10:43:03 +02:00
fsmanuel eaec617756 Merge pull request #57 from 67P/use-kredits-npm-lib
Remove kredits library files and use npm package
2018-04-18 22:50:41 +00:00
fsmanuel 2199394abc Use kredits-contracts master 2018-04-19 00:44:34 +02:00
fsmanuel 93a18a8a20 Remove unused dependencies 2018-04-19 00:10:43 +02:00
fsmanuel 2190b7af68 Remove in-repo addon 2018-04-18 22:55:49 +02:00
bumi 7da4956f1e Fix tests
These tests must be moved into the kredits-contract package. The
serialization is happening there.
They should be ported to mocha/chai probably
2018-04-18 20:19:26 +02:00
bumi 31e27865bb Use kredits-contracts from npm 2018-04-18 19:26:35 +02:00
bumi 967f213cba Remove build script to write address/abi json
those are now in the kredits-contracts library
2018-04-18 19:24:38 +02:00
bumi 0e556aa794 Remove kredits library files and use npm package
Currently it uses the package directly from git
2018-04-18 19:01:26 +02:00
basti e2ac6099e3 Merge pull request #56 from 67P/naming-conventions
Adjust for latest naming conventions
2018-04-17 13:29:08 +02:00
basti a4088e7acf Merge branch 'master' into naming-conventions 2018-04-17 12:55:20 +02:00
basti 550bd48fdb Merge pull request #55 from 67P/dev/ember_upgrade
Update Ember to 3.1
2018-04-17 12:53:30 +02:00
bumi f0f82a96fb Adjust for latest naming conventions
expept the renaming of ipfsHash to hashDigest all are already reflected
in the contracts
2018-04-17 11:43:44 +02:00
basti 8d303ea5af Fix tests, remove obsolete initializers 2018-04-17 01:38:21 +02:00
basti c0b5c36115 Don't inject kredits service in models 2018-04-16 19:23:28 +02:00
basti ba3c707da3 Fix ESLint errors 2018-04-16 19:14:37 +02:00
basti b09f19f399 Update Ember to 3.1 2018-04-16 18:11:52 +02:00
bumi 539f32911d Merge pull request #53 from 67P/docs/readme_local_contracts
Update README
2018-04-16 15:12:20 +00:00
basti e009caaf25 Add ember-cli-update 2018-04-16 13:38:27 +02:00
basti 1761ffbd3b Update README
Improve description and instructions for running against a local
development chain/node and IPFS node.
2018-04-16 12:49:49 +02:00
bumi 33d6499dd4 Merge pull request #51 from 67P/feature/contract-events
Move contract event handling into service
2018-04-15 19:58:23 +00:00
fsmanuel 3839eb2be4 Fixes and cleanup 2018-04-15 21:33:37 +02:00
fsmanuel a04ab09ed6 Move event handler assignment into method 2018-04-15 20:49:17 +02:00
fsmanuel 89d6b920b1 Remove unused Ember import 2018-04-15 20:38:24 +02:00
fsmanuel d425ed6b9e Move contract event handling into service 2018-04-15 20:36:21 +02:00
fsmanuel e4215557c8 Merge pull request #49 from 67P/feature/store-models-in-service
Move contributors + proposals to service
2018-04-15 18:35:10 +00:00
fsmanuel fcc4045a4e Merge branch 'master' into feature/store-models-in-service
# Conflicts:
#	app/controllers/index.js
#	tests/unit/controllers/index-test.js
2018-04-15 20:23:39 +02:00
fsmanuel 5bcce71b36 Merge pull request #50 from 67P/refactor/naming-conventions
Refactor/naming conventions
2018-04-15 18:17:47 +00:00
fsmanuel c9abec8a46 Merge pull request #47 from 67P/features/initialize-error-handling
Fail smarter with better error handling
2018-04-15 18:14:12 +00:00
bumi 62e195d186 remove fake bignumber from tests
We make everything bignumber-y now so that is no longer needed
2018-04-15 19:56:03 +02:00
fsmanuel e5f69a5e9a Remove unused computed 2018-04-15 19:09:16 +02:00
bumi e31ff492d5 merge master 2018-04-15 19:08:26 +02:00
fsmanuel 99bbcda709 Move contributors + proposals to service
With this change we have all the contributors + proposals all the time in one place and can update them via events.
2018-04-15 19:02:57 +02:00
bumi 8aa941e704 Rename recipientId to contributorId in proposals 2018-04-15 19:02:35 +02:00
bumi fdda30da67 Merge pull request #48 from 67P/revert-45-refactor/naming-conventions
Revert "Adjust for new naming conventions"
2018-04-15 16:42:36 +00:00
bumi 399ee27af9 Revert "Adjust for new naming conventions" 2018-04-15 16:35:31 +00:00
bumi d2c43b9ef1 Merge pull request #45 from 67P/refactor/naming-conventions
Adjust for new naming conventions
2018-04-15 16:24:17 +00:00
bumi 7e6c6a037d code style. use interpolation syntax 2018-04-15 18:02:52 +02:00
bumi e1f19a1832 Fail smarter with better error handling
This should give more insights in case of an error during loading data
from Ethereum and IPFS.
2018-04-15 16:45:09 +02:00
bumi 7b5cea3613 Adjust for new naming conventions 2018-04-10 18:46:54 +02:00
fsmanuel 5d875d23e2 Merge pull request #44 from 67P/fix/proposal-loading
Fix proposal loading
2018-04-10 17:48:53 +02:00
bumi e24fc6c81d Fix proposal loading
we changed the storage of proposals from an array to a mapping.
Because of this keys/ids now no longer start with zero but with 1.
2018-04-10 16:29:18 +02:00
bumi 72c6266474 Merge pull request #42 from 67P/kredits-module
Refactor contract interaction in its own module
2018-04-10 13:46:57 +00:00
bumi 4a711788f6 cleanup 2018-04-10 15:27:38 +02:00
bumi c373c901a2 Fix test to work with the bignumber depencency 2018-04-09 20:54:07 +02:00
bumi 32846194b6 Handle bignumber conversions
The kredits module always returns raw data from the contract. For uint
values these are bignumbers.
To handle those in the ember app we need to convert them to string or to
number.
2018-04-09 20:31:34 +02:00
bumi 1a5d33b1f0 Move IPFS handling in utils class 2018-04-09 19:28:49 +02:00
bumi 0e330a6529 Cleanup 2018-04-09 15:15:33 +02:00
bumi e9f0638ca2 Unify Contributor/Proposal saving 2018-04-09 15:05:13 +02:00
bumi ce8fef79dc Search proposal contributor by stringified ID
Kredits returns default web3/ethers.js values which are BigNumbers.
Internally we use Strings. We need to do this somewhere centrally
though.
2018-04-09 14:58:50 +02:00
bumi b9fa0e2d60 typo 2018-04-09 14:58:25 +02:00
bumi 41a04d6b53 Refactor IPFS hash handling
This now uses the multihashes dependency (which is an ipfs dependency)
to decode/encode the ipfs hashes.
2018-04-09 14:52:39 +02:00
fsmanuel e527099b00 Fix tests 2018-04-09 02:26:03 +02:00
fsmanuel 7e04cef964 Extract kredits module 2018-04-09 02:09:38 +02:00
bumi cb1208acaa Merge pull request #41 from 67P/refactor/cleanup-models
Cleanup controller, templates and service
2018-04-08 12:56:05 +00:00
fsmanuel f985ce6402 So sad that I can not force push! 2018-04-08 14:41:08 +02:00
fsmanuel f54a36f240 Use getProposalById instead of buildModel 2018-04-08 14:33:38 +02:00
bumi 4182f0993c Merge pull request #39 from 67P/refactor/contributor-management
Use contributors contract to add new contributors
2018-04-08 12:12:52 +00:00
fsmanuel 28f7466595 Cleanup controller, templates and service 2018-04-08 14:06:16 +02:00
bumi 6cfd4240de Merge pull request #38 from 67P/refactor/proposal
Refactor proposals
2018-04-08 12:02:28 +00:00
bumi 6e24015f76 Readme 2018-04-08 13:54:50 +02:00
bumi 5390c5cea8 Use contributors contract to add new contributors
related: https://github.com/67P/truffle-kredits/pull/17
2018-04-08 13:17:35 +02:00
fsmanuel adcc81ba52 Move tests 2018-04-08 02:27:56 +02:00
fsmanuel 31a1f21dcd Fix linting 2018-04-08 02:13:07 +02:00
fsmanuel b7316e15a2 Align *Proposal to *Contributor methods 2018-04-08 02:01:10 +02:00
fsmanuel ad92ffa447 Extract serializer 2018-04-08 02:01:10 +02:00
fsmanuel 45044c0f61 Refactor controller and route
I’m not sure how the queryParams are used so I removed them. I’m happy to readd them.
2018-04-08 02:01:10 +02:00
fsmanuel cc7c47cbab Refactor proposal form 2018-04-08 02:01:02 +02:00
fsmanuel 27f4006f05 Merge pull request #37 from 67P/refactor/get-contributor-by-id
Refactor getContributorById to be move generic
2018-04-08 01:32:46 +02:00
fsmanuel 576313a7b0 Update according to suggestions 2018-04-08 01:25:59 +02:00
bumi 13c3feaa69 Merge pull request #36 from 67P/fix/contributor-ipfshash
Replace missed profileHash with ipfsHash
2018-04-07 23:09:03 +00:00
fsmanuel a0dd8b925b Refactor getContributorById to be move generic
We can use this generic functions also for the upcomig proposal refactoring.
2018-04-08 00:54:42 +02:00
fsmanuel 21f187cac2 Replace missed profileHash with ipfsHash 2018-04-08 00:44:25 +02:00
fsmanuel d8951caddb Block in application route if accountNeedsUnlock
The index route never gets invoked if you reload on the proposal page. That change makes sure we always initialize `ethProvider`.
2018-04-07 22:43:22 +00:00
fsmanuel 206c657b98 Merge pull request #34 from 67P/features/balance-from-contributors
Use getContributorById call to get contributor data
2018-04-07 22:22:35 +02:00
fsmanuel 6e262537a4 Extract multihash functions 2018-04-07 18:47:16 +00:00
bumi 0587559939 Use getContributorById call to get contributor data
This new call also returns the balance of the contributor so we don't
have to make an extra call to the token contract.
2018-04-07 20:33:41 +02:00
fsmanuel 939baec1a8 Add alert message after saved 2018-04-07 17:22:33 +00:00
fsmanuel 429f670c12 Better assign 2018-04-07 17:22:33 +00:00
fsmanuel b21d2ad864 Extract serializer logic into lib
To use the same code in hubot I extracted the serializer logic into lib to later move it into `truffle-kredits` when we are done with all other common logic.
2018-04-07 17:22:33 +00:00
fsmanuel 8f841522a3 Refactor add-contributor component to DDAU 2018-04-07 17:22:33 +00:00
fsmanuel fa7db66f4e Merge pull request #31 from 67P/feature/account-badge
Add account badge
2018-04-06 17:54:53 +02:00
bumi 499b22af15 Fix JS/Ember style 2018-04-06 17:40:45 +02:00
bumi f992fb10bd Fix JS style 2018-04-06 17:00:25 +02:00
bumi 603224e906 Use ember style empty promise 2018-04-06 16:53:01 +02:00
bumi bbea07b2a7 Add account badge
This gives easy access to test if the "user/visitor" has an account, is
a contributor and if she is core.
This is used to display some account information.
2018-04-06 16:45:55 +02:00
bumi b9d78efaff More details about running the app locally 2018-04-04 15:43:52 +02:00
fsmanuel 1a58099413 Merge pull request #29 from 67P/ethers
Use ethers.js instead of the old web3
2018-04-04 14:20:55 +02:00
bumi e2d02d357c linting 2018-04-04 14:15:07 +02:00
bumi 0812ff3467 Add comment about ethProvider loading.
It is loaded in the route and thus available for the rest of the
application.
2018-04-04 14:02:27 +02:00
bumi 6dafc26deb Refactor web3 initialization
This changes how we wait for web3 and accounts to be available and
renames web3Provider to ethProvider as we could be able to swap the
different providers (ethers.js vs. web3)
2018-04-04 13:56:41 +02:00
bumi a418ddbe52 fix tests 2018-04-04 12:49:41 +02:00
bumi 6a5eb5a313 use kovan as default network id 2018-04-03 21:34:45 +02:00
bumi 6354b03717 cleanup 2018-04-03 20:32:46 +02:00
bumi 5d6b4a5255 fix linting errors 2018-04-03 19:19:11 +02:00
bumi 3f0b1ccbcd Use ethers.js instead of the old web3
the web3 we are using is old and everything will change with web3 1.0
which is currently in beta for ages.
ethers.js seems to be a bit more lightweight and implements pretty much
the same API so let's give it a try.
2018-04-03 18:35:29 +02:00
fsmanuel f00ecfd9ab Merge pull request #28 from 67P/refactor/proposal-ipfs-hash
Handle new IPFS hashes in proposal
2018-04-02 21:00:58 +02:00
fsmanuel ee0d3370b1 Fix travis 2018-04-02 20:40:59 +02:00
fsmanuel 73d7ed52dd Refactor contributor 2018-04-02 19:08:29 +02:00
fsmanuel 6a31b0d579 Rename contributor.kredits to .balance 2018-04-02 19:07:42 +02:00
bumi 869c4c0ae9 Handle new IPFS hashes in proposal 2018-04-02 18:19:22 +02:00
bumi 4be696ea62 Fix contract events handling 2018-04-02 18:18:34 +02:00
bumi 83a0d56314 Update ipfs-api dependency 2018-04-02 18:17:50 +02:00
fsmanuel 0d1418e595 Fix package.json 2018-04-01 15:24:45 +02:00
fsmanuel 7db521a72a Fix kredits-contracts dependency 2018-04-01 15:23:05 +02:00
fsmanuel e7ff606891 Update readme 2018-04-01 15:21:14 +02:00
bumi 60de3d8a31 Merge pull request #27 from 67P/truffle-contracts
Truffle contracts
2018-04-01 12:19:21 +00:00
fsmanuel f12740dfa8 Update kredits service 2018-04-01 03:20:16 +02:00
fsmanuel 020a1c5682 In repo contracts addon 2018-04-01 03:16:22 +02:00
basti 576e2741a9 More contract-related adjustments 2017-08-14 15:31:58 +02:00
basti bc19363bf9 Update kosmos-schemas 2017-08-12 14:05:31 +02:00
basti 72cf4b495e Adjust for new contract wrapper (WIP) 2017-08-12 13:59:18 +02:00
329 changed files with 95157 additions and 1913 deletions
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -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-refocus** - Route announcements and focus management
- **ember-focus-trap** - Focus trapping for modals and dialogs
- **ember-page-title** - Accessible page title management
- **Platform-native validation** - Use browser's Constraint Validation API for accessible form validation
These tools, combined with native web platform features, provide comprehensive a11y support with minimal configuration.
## Full Compiled Document
For the complete guide with all rules expanded: `AGENTS.md`
+38
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
@@ -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.
@@ -0,0 +1,28 @@
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description of impact (e.g., "20-50% improvement")
tags: tag1, tag2
---
## Rule Title Here
**Impact: MEDIUM (optional impact description)**
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
**Incorrect (description of what's wrong):**
```glimmer-ts
// Bad code example here
const bad = example();
```
**Correct (description of what's right):**
```glimmer-ts
// Good code example here
const good = example();
```
Reference: [Link to documentation or resource](https://example.com)
@@ -0,0 +1,82 @@
---
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
```
### Leave All Rules Enabled
Keep the default ember-a11y-testing and axe-core rules turned on. Avoid disabling rules globally or excluding specific rules to bypass failures without a documented remediation plan.
When teams suppress accessibility rules without a time-boxed remediation plan, they hide real defects, accumulate technical debt, and make regressions harder to detect. A skipped rule can allow a serious accessibility defect to ship to production, especially for problems involving forms, keyboard access, focus management, semantics, or ARIA usage.
If you must suppress a rule temporarily, treat it as an exception: document why it is needed, scope it as narrowly as possible, and create follow-up work to restore the rule quickly.
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)
@@ -0,0 +1,145 @@
---
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
<span aria-hidden="true">*</span>
</label>
<input
id="email-input"
type="email"
value={{this.email}}
{{on "input" this.updateEmail}}
aria-describedby={{if this.emailError "email-error"}}
aria-invalid={{if this.emailError "true"}}
required={{this.isEmailRequired}}
/>
{{#if this.emailError}}
<span id="email-error" role="alert" aria-live="polite">
{{this.emailError}}
</span>
{{/if}}
</div>
<button type="submit" disabled={{this.isSubmitting}}>
{{#if this.isSubmitting}}
<span aria-live="polite">Submitting...</span>
{{else}}
Submit
{{/if}}
</button>
</form>
</template>
```
**For complex forms, use platform-native validation with custom logic:**
```glimmer-js
// app/components/user-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class UserForm extends Component {
@tracked errorMessages = {};
validateEmail = (event) => {
// Custom business logic validation
const input = event.target;
const value = input.value;
if (!value) {
input.setCustomValidity('Email is required');
return false;
}
if (!input.validity.valid) {
input.setCustomValidity('Must be a valid email');
return false;
}
// Additional custom validation (e.g., check if email is already taken)
if (value === 'taken@example.com') {
input.setCustomValidity('This email is already registered');
return false;
}
input.setCustomValidity('');
return true;
};
handleSubmit = async (event) => {
event.preventDefault();
const form = event.target;
// Run custom validations
const emailInput = form.querySelector('[name="email"]');
const fakeEvent = { target: emailInput };
this.validateEmail(fakeEvent);
// Use native validation check
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const formData = new FormData(form);
await this.args.onSubmit(formData);
};
<template>
<form {{on "submit" this.handleSubmit}}>
<label for="user-email">
Email
<input
id="user-email"
type="email"
name="email"
required
value={{@user.email}}
{{on "blur" this.validateEmail}}
/>
</label>
<button type="submit">Save</button>
</form>
</template>
}
```
Always associate labels with inputs and announce dynamic changes to screen readers using aria-live regions.
Reference: [Ember Accessibility - Application Considerations](https://guides.emberjs.com/release/accessibility/application-considerations/)
@@ -0,0 +1,163 @@
---
title: Keyboard Navigation Support
impact: HIGH
impactDescription: Critical for keyboard-only users
tags: accessibility, a11y, keyboard, focus-management
---
## Keyboard Navigation Support
Ensure all interactive elements are keyboard accessible and focus management is handled properly, especially in modals and dynamic content.
**Incorrect (no keyboard support):**
```glimmer-js
// app/components/dropdown.gjs
<template>
<div class="dropdown" {{on "click" this.toggleMenu}}>
Menu
{{#if this.isOpen}}
<div class="dropdown-menu">
<div {{on "click" this.selectOption}}>Option 1</div>
<div {{on "click" this.selectOption}}>Option 2</div>
</div>
{{/if}}
</div>
</template>
```
**Correct (full keyboard support with custom modifier):**
```javascript
// app/modifiers/focus-first.js
import { modifier } from 'ember-modifier';
export default modifier((element, [selector = 'button']) => {
// Focus first matching element when modifier runs
element.querySelector(selector)?.focus();
});
```
```glimmer-js
// app/components/dropdown.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import focusFirst from '../modifiers/focus-first';
class Dropdown extends Component {
@tracked isOpen = false;
@action
toggleMenu() {
this.isOpen = !this.isOpen;
}
@action
handleButtonKeyDown(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.isOpen = true;
}
}
@action
handleMenuKeyDown(event) {
if (event.key === 'Escape') {
this.isOpen = false;
// Return focus to button
event.target.closest('.dropdown').querySelector('button').focus();
}
// Handle arrow key navigation between menu items
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
this.moveFocus(event.key === 'ArrowDown' ? 1 : -1);
}
}
moveFocus(direction) {
const items = Array.from(document.querySelectorAll('[role="menuitem"] button'));
const currentIndex = items.indexOf(document.activeElement);
const nextIndex = (currentIndex + direction + items.length) % items.length;
items[nextIndex]?.focus();
}
@action
selectOption(value) {
this.args.onSelect?.(value);
this.isOpen = false;
}
<template>
<div class="dropdown">
<button
type="button"
{{on "click" this.toggleMenu}}
{{on "keydown" this.handleButtonKeyDown}}
aria-haspopup="true"
aria-expanded="{{this.isOpen}}"
>
Menu
</button>
{{#if this.isOpen}}
<ul
class="dropdown-menu"
role="menu"
{{focusFirst '[role="menuitem"] button'}}
{{on "keydown" this.handleMenuKeyDown}}
>
<li role="menuitem">
<button type="button" {{on "click" (fn this.selectOption "1")}}>
Option 1
</button>
</li>
<li role="menuitem">
<button type="button" {{on "click" (fn this.selectOption "2")}}>
Option 2
</button>
</li>
</ul>
{{/if}}
</div>
</template>
}
```
**For focus trapping in modals, use ember-focus-trap:**
```bash
ember install ember-focus-trap
```
```glimmer-js
// app/components/modal.gjs
import FocusTrap from 'ember-focus-trap/components/focus-trap';
<template>
{{#if this.showModal}}
<FocusTrap @isActive={{true}} @initialFocus="#modal-title">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">{{@title}}</h2>
{{yield}}
<button type="button" {{on "click" this.closeModal}}>Close</button>
</div>
</FocusTrap>
{{/if}}
</template>
```
**Alternative: Use libraries for keyboard support:**
For complex keyboard interactions, consider using libraries that abstract keyboard support patterns:
```bash
npm install @fluentui/keyboard-keys
```
Or use [tabster](https://tabster.io/) for comprehensive keyboard navigation management including focus trapping, arrow key navigation, and modalizers.
Proper keyboard navigation ensures all users can interact with your application effectively.
Reference: [Ember Accessibility - Keyboard](https://guides.emberjs.com/release/accessibility/keyboard/)
@@ -0,0 +1,192 @@
---
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 ember-a11y-refocus library - recommended):**
Use the [ember-a11y-refocus](https://github.com/ember-a11y/ember-a11y-refocus) library for robust route announcements, route transition focus management, and a bypass block (aka skip link).
```bash
pnpm add ember-a11y-refocus
```
Or with npm:
```bash
npm install ember-a11y-refocus
```
Use the addon by rendering `NavigationNarrator` in your application layout and ensuring your primary content has `id="main"`.
```handlebars
{{! app/templates/application.hbs }}
<header>
<NavigationNarrator />
{{! other header content }}
</header>
<main id='main'>
{{outlet}}
</main>
```
If you are using GJS or GTS, import the component directly:
```glimmer-js
import { NavigationNarrator } from 'ember-a11y-refocus';
<template>
<header>
<NavigationNarrator />
</header>
<main id="main">
{{outlet}}
</main>
</template>
```
The addon ships minimal styles for the skip link and navigation message:
```javascript
// app/app.js or app/app.ts
import 'ember-a11y-refocus/styles/navigation-narrator.css';
```
If you need to customize which transitions count as a route change, pass a validator function to `NavigationNarrator`:
```javascript
// app/controllers/application.js
import Controller from '@ember/controller';
import { defaultValidator } from 'ember-a11y-refocus';
export default class ApplicationController extends Controller {
myCustomValidator(transition) {
if (transition.from?.name === 'special') {
return false;
}
return defaultValidator(transition);
}
}
```
```handlebars
{{! app/templates/application.hbs }}
<header>
<NavigationNarrator @routeChangeValidator={{this.myCustomValidator}} />
</header>
<main id='main'>
{{outlet}}
</main>
```
**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;
}
```
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/)
@@ -0,0 +1,102 @@
---
title: Semantic HTML and ARIA Attributes
impact: HIGH
impactDescription: Essential for screen reader users
tags: accessibility, a11y, semantic-html, aria
---
## Semantic HTML and ARIA Attributes
Use semantic HTML elements and proper ARIA attributes to make your application accessible to screen reader users. **The first rule of ARIA is to not use ARIA** - prefer native semantic HTML elements whenever possible.
**Key principle:** Native HTML elements have built-in keyboard support, roles, and behaviors. Only add ARIA when semantic HTML can't provide the needed functionality.
**Incorrect (divs with insufficient semantics):**
```glimmer-js
// app/components/example.gjs
<template>
<div class="button" {{on "click" this.submit}}>
Submit
</div>
<div class="nav">
<div class="nav-item">Home</div>
<div class="nav-item">About</div>
</div>
<div class="alert">
{{this.message}}
</div>
</template>
```
**Correct (semantic HTML with proper ARIA):**
```glimmer-js
// app/components/example.gjs
import { LinkTo } from '@ember/routing';
<template>
<button type="submit" {{on "click" this.submit}}>
Submit
</button>
<nav aria-label="Main navigation">
<ul>
<li><LinkTo @route="index">Home</LinkTo></li>
<li><LinkTo @route="about">About</LinkTo></li>
</ul>
</nav>
<div role="alert" aria-live="polite" aria-atomic="true">
{{this.message}}
</div>
</template>
```
**For interactive custom elements:**
```glimmer-js
// app/components/custom-button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import XIcon from './x-icon';
class CustomButton extends Component {
@action
handleKeyDown(event) {
// Support Enter and Space keys for keyboard users
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleClick();
}
}
@action
handleClick() {
this.args.onClick?.();
}
<template>
<div
role="button"
tabindex="0"
{{on "click" this.handleClick}}
{{on "keydown" this.handleKeyDown}}
aria-label="Close dialog"
>
<XIcon />
</div>
</template>
}
```
Always use native semantic elements when possible. When creating custom interactive elements, ensure they're keyboard accessible and have proper ARIA attributes.
**References:**
- [ARIA Authoring Practices Guide (W3C)](https://www.w3.org/WAI/ARIA/apg/)
- [Using ARIA (W3C)](https://www.w3.org/TR/using-aria/)
- [ARIA in HTML (WHATWG)](https://html.spec.whatwg.org/multipage/aria.html#aria)
- [Ember Accessibility Guide](https://guides.emberjs.com/release/accessibility/)
@@ -0,0 +1,198 @@
---
title: Use Ember Concurrency for User Input Concurrency
impact: HIGH
impactDescription: Better control of user-initiated async operations
tags: ember-concurrency, tasks, user-input, concurrency-patterns
---
## Use Ember Concurrency for User Input Concurrency
Use ember-concurrency for managing **user-initiated** async operations like search, form submission, and autocomplete. It provides automatic cancelation, debouncing, and prevents race conditions from user actions.
**Incorrect (manual async handling with race conditions):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class Search extends Component {
@tracked results = [];
@tracked isSearching = false;
@tracked error = null;
currentRequest = null;
@action
async search(event) {
const query = event.target.value;
// Manual cancelation - easy to get wrong
if (this.currentRequest) {
this.currentRequest.abort();
}
this.isSearching = true;
this.error = null;
const controller = new AbortController();
this.currentRequest = controller;
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
this.results = await response.json();
} catch (e) {
if (e.name !== 'AbortError') {
this.error = e.message;
}
} finally {
this.isSearching = false;
}
}
<template>
<input {{on "input" this.search}} />
{{#if this.isSearching}}Loading...{{/if}}
{{#if this.error}}Error: {{this.error}}{{/if}}
</template>
}
```
**Correct (using ember-concurrency with task return values):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { restartableTask } from 'ember-concurrency';
class Search extends Component {
// restartableTask automatically cancels previous searches
// IMPORTANT: Return the value, don't set tracked state inside tasks
searchTask = restartableTask(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
return response.json(); // Return, don't set @tracked
});
<template>
<input {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />
{{#if this.searchTask.isRunning}}
<div class="loading">Loading...</div>
{{/if}}
{{#if this.searchTask.last.isSuccessful}}
<ul>
{{#each this.searchTask.last.value as |result|}}
<li>{{result.name}}</li>
{{/each}}
</ul>
{{/if}}
{{#if this.searchTask.last.isError}}
<div class="error">{{this.searchTask.last.error.message}}</div>
{{/if}}
</template>
}
```
**With debouncing for user typing:**
```glimmer-js
// app/components/autocomplete.gjs
import Component from '@glimmer/component';
import { restartableTask, timeout } from 'ember-concurrency';
class Autocomplete extends Component {
searchTask = restartableTask(async (query) => {
// Debounce user typing - wait 300ms
await timeout(300);
const response = await fetch(`/api/autocomplete?q=${query}`);
return response.json(); // Return value, don't set tracked state
});
<template>
<input
type="search"
{{on "input" (fn this.searchTask.perform (pick "target.value"))}}
placeholder="Search..."
/>
{{#if this.searchTask.isRunning}}
<div class="spinner"></div>
{{/if}}
{{#if this.searchTask.lastSuccessful}}
<ul class="suggestions">
{{#each this.searchTask.lastSuccessful.value as |item|}}
<li>{{item.title}}</li>
{{/each}}
</ul>
{{/if}}
</template>
}
```
**Task modifiers for different user concurrency patterns:**
```glimmer-js
import Component from '@glimmer/component';
import { dropTask, enqueueTask, restartableTask } from 'ember-concurrency';
class FormActions extends Component {
// dropTask: Prevents double-click - ignores new while running
saveTask = dropTask(async (data) => {
const response = await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
});
// enqueueTask: Queues user actions sequentially
processTask = enqueueTask(async (item) => {
const response = await fetch('/api/process', {
method: 'POST',
body: JSON.stringify(item),
});
return response.json();
});
// restartableTask: Cancels previous, starts new (for search)
searchTask = restartableTask(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
return response.json();
});
<template>
<button {{on "click" (fn this.saveTask.perform @data)}} disabled={{this.saveTask.isRunning}}>
Save
</button>
</template>
}
```
**Key Principles for ember-concurrency:**
1. **User-initiated only** - Use for handling user actions, not component initialization
2. **Return values** - Use `task.last.value`, never set `@tracked` state inside tasks
3. **Avoid side effects** - Don't modify component state that's read during render inside tasks
4. **Choose right modifier**:
- `restartableTask` - User typing/search (cancel previous)
- `dropTask` - Form submit/save (prevent double-click)
- `enqueueTask` - Sequential processing (queue user actions)
**When NOT to use ember-concurrency:**
- ❌ Component initialization data loading (use `getPromiseState` instead)
- ❌ Setting tracked state inside tasks (causes infinite render loops)
- ❌ Route model hooks (return promises directly)
- ❌ Simple async without user concurrency concerns (use async/await)
See **advanced-data-loading-with-ember-concurrency.md** for correct data loading patterns.
ember-concurrency provides automatic cancelation, derived state (isRunning, isIdle), and better patterns for **user-initiated** async operations.
Reference: [ember-concurrency](https://ember-concurrency.com/)
@@ -0,0 +1,243 @@
---
title: Use Ember Concurrency Correctly - User Concurrency Not Data Loading
impact: HIGH
impactDescription: Prevents infinite render loops and improves performance
tags: ember-concurrency, tasks, data-loading, anti-pattern
---
## Use Ember Concurrency Correctly - User Concurrency Not Data Loading
ember-concurrency is designed for **user-initiated concurrency patterns** (debouncing, throttling, preventing double-clicks), not data loading. Use task return values, don't set tracked state inside tasks.
**Incorrect (using ember-concurrency for data loading with tracked state):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
class UserProfile extends Component {
@tracked userData = null;
@tracked error = null;
// WRONG: Setting tracked state inside task
loadUserTask = task(async () => {
try {
const response = await fetch(`/api/users/${this.args.userId}`);
this.userData = await response.json(); // Anti-pattern!
} catch (e) {
this.error = e; // Anti-pattern!
}
});
<template>
{{#if this.loadUserTask.isRunning}}
Loading...
{{else if this.userData}}
<h1>{{this.userData.name}}</h1>
{{/if}}
</template>
}
```
**Why This Is Wrong:**
- Setting tracked state during render can cause infinite render loops
- ember-concurrency adds overhead unnecessary for simple data loading
- Makes component state harder to reason about
- Can trigger multiple re-renders
**Correct (use getPromiseState from warp-drive/reactiveweb for data loading):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { getPromiseState } from '@warp-drive/reactiveweb';
class UserProfile extends Component {
@cached
get userData() {
const promise = fetch(`/api/users/${this.args.userId}`).then((r) => r.json());
return getPromiseState(promise);
}
<template>
{{#if this.userData.isPending}}
<div>Loading...</div>
{{else if this.userData.isRejected}}
<div>Error: {{this.userData.error.message}}</div>
{{else if this.userData.isFulfilled}}
<h1>{{this.userData.value.name}}</h1>
{{/if}}
</template>
}
```
**Correct (use ember-concurrency for USER input with derived data patterns):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { restartableTask, timeout } from 'ember-concurrency';
import { on } from '@ember/modifier';
import { pick } from 'ember-composable-helpers';
class Search extends Component {
// CORRECT: For user-initiated search with debouncing
// Use derived data from TaskInstance API - lastSuccessful
searchTask = restartableTask(async (query) => {
await timeout(300); // Debounce user typing
const response = await fetch(`/api/search?q=${query}`);
return response.json(); // Return value, don't set tracked state
});
<template>
<input type="search" {{on "input" (fn this.searchTask.perform (pick "target.value"))}} />
{{! Use derived data from task state - no tracked properties needed }}
{{#if this.searchTask.isRunning}}
<div>Searching...</div>
{{/if}}
{{! lastSuccessful persists previous results while new search runs }}
{{#if this.searchTask.lastSuccessful}}
<ul>
{{#each this.searchTask.lastSuccessful.value as |result|}}
<li>{{result.name}}</li>
{{/each}}
</ul>
{{/if}}
{{! Show error from most recent failed attempt }}
{{#if this.searchTask.last.isError}}
<div>Error: {{this.searchTask.last.error.message}}</div>
{{/if}}
</template>
}
```
**Good Use Cases for ember-concurrency:**
1. **User input debouncing** - prevent API spam from typing
2. **Form submission** - prevent double-click submits with `dropTask`
3. **Autocomplete** - restart previous searches as user types
4. **Polling** - user-controlled refresh intervals
5. **Multi-step wizards** - sequential async operations
```glimmer-js
// app/components/form-submit.gjs
import Component from '@glimmer/component';
import { dropTask } from 'ember-concurrency';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
class FormSubmit extends Component {
// dropTask prevents double-submit - perfect for user actions
submitTask = dropTask(async (formData) => {
const response = await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(formData),
});
return response.json();
});
<template>
<button
{{on "click" (fn this.submitTask.perform @formData)}}
disabled={{this.submitTask.isRunning}}
>
{{#if this.submitTask.isRunning}}
Saving...
{{else}}
Save
{{/if}}
</button>
{{! Use lastSuccessful for success message - derived data }}
{{#if this.submitTask.lastSuccessful}}
<div>Saved successfully!</div>
{{/if}}
{{#if this.submitTask.last.isError}}
<div>Error: {{this.submitTask.last.error.message}}</div>
{{/if}}
</template>
}
```
**Bad Use Cases for ember-concurrency:**
1. ❌ **Loading data on component init** - use `getPromiseState` instead
2. ❌ **Route model hooks** - just return promises directly
3. ❌ **Simple API calls** - async/await is sufficient
4. ❌ **Setting tracked state inside tasks** - causes render loops
**Key Principles:**
- **Derive data, don't set it** - Use `task.lastSuccessful`, `task.last`, `task.isRunning` (derived from TaskInstance API)
- **Use task return values** - Read from `task.lastSuccessful.value` or `task.last.value`, never set tracked state
- **User-initiated only** - ember-concurrency is for handling user concurrency patterns
- **Data loading** - Use `getPromiseState` from warp-drive/reactiveweb for non-user-initiated loading
- **Avoid side effects** - Don't modify component state inside tasks that's read during render
**TaskInstance API for Derived Data:**
ember-concurrency provides a powerful derived data API through Task and TaskInstance:
- `task.last` - The most recent TaskInstance (successful or failed)
- `task.lastSuccessful` - The most recent successful TaskInstance (persists during new attempts)
- `task.isRunning` - Derived boolean if any instance is running
- `taskInstance.value` - The returned value from the task
- `taskInstance.isError` - Derived boolean if this instance failed
- `taskInstance.error` - The error if this instance failed
This follows the **derived data pattern** - all state comes from the task itself, no tracked properties needed!
References:
- [TaskInstance API](https://ember-concurrency.com/api/TaskInstance.html)
- [Task API](https://ember-concurrency.com/api/Task.html)
**Migration from tracked state pattern:**
```glimmer-js
// BEFORE (anti-pattern - setting tracked state)
class Bad extends Component {
@tracked data = null;
fetchTask = task(async () => {
this.data = await fetch('/api/data').then((r) => r.json());
});
// template reads: {{this.data}}
}
// AFTER (correct - using derived data from TaskInstance API)
class Good extends Component {
fetchTask = restartableTask(async () => {
return fetch('/api/data').then((r) => r.json());
});
// template reads: {{this.fetchTask.lastSuccessful.value}}
// All state derived from task - no tracked properties!
}
// Or better yet, for non-user-initiated loading:
class Better extends Component {
@cached
get data() {
return getPromiseState(fetch('/api/data').then((r) => r.json()));
}
// template reads: {{#if this.data.isFulfilled}}{{this.data.value}}{{/if}}
}
```
ember-concurrency is a powerful tool for **user concurrency patterns**. For data loading, use `getPromiseState` instead.
Reference:
- [ember-concurrency](https://ember-concurrency.com/)
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
@@ -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/)
@@ -0,0 +1,129 @@
---
title: Use Modifiers for DOM Side Effects
impact: LOW-MEDIUM
impactDescription: Better separation of concerns
tags: modifiers, dom, lifecycle, advanced
---
## Use Modifiers for DOM Side Effects
Use modifiers (element modifiers) to handle DOM side effects and lifecycle events in a reusable, composable way.
**Incorrect (manual DOM manipulation in component):**
```glimmer-js
// app/components/chart.gjs
import Component from '@glimmer/component';
class Chart extends Component {
chartInstance = null;
constructor() {
super(...arguments);
// Can't access element here - element doesn't exist yet!
}
willDestroy() {
super.willDestroy();
this.chartInstance?.destroy();
}
<template>
<canvas id="chart-canvas"></canvas>
{{! Manual setup is error-prone and not reusable }}
</template>
}
```
**Correct (function modifier - preferred for simple side effects):**
```javascript
// app/modifiers/chart.js
import { modifier } from 'ember-modifier';
export default modifier((element, [config]) => {
// Initialize chart
const chartInstance = new Chart(element, config);
// Return cleanup function
return () => {
chartInstance.destroy();
};
});
```
**Also correct (class-based modifier for complex state):**
```javascript
// app/modifiers/chart.js
import Modifier from 'ember-modifier';
import { registerDestructor } from '@ember/destroyable';
export default class ChartModifier extends Modifier {
chartInstance = null;
modify(element, [config]) {
// Cleanup previous instance if config changed
if (this.chartInstance) {
this.chartInstance.destroy();
}
this.chartInstance = new Chart(element, config);
// Register cleanup
registerDestructor(this, () => {
this.chartInstance?.destroy();
});
}
}
```
```glimmer-js
// app/components/chart.gjs
import chart from '../modifiers/chart';
<template>
<canvas {{chart @config}}></canvas>
</template>
```
**Use function modifiers** for simple side effects. Use class-based modifiers only when you need complex state management.
**For commonly needed modifiers, use ember-modifier helpers:**
```javascript
// app/modifiers/autofocus.js
import { modifier } from 'ember-modifier';
export default modifier((element) => {
element.focus();
});
```
```glimmer-js
// app/components/input-field.gjs
import autofocus from '../modifiers/autofocus';
<template><input {{autofocus}} type="text" /></template>
```
**Use ember-resize-observer-modifier for resize handling:**
```bash
ember install ember-resize-observer-modifier
```
```glimmer-js
// app/components/resizable.gjs
import onResize from 'ember-resize-observer-modifier';
<template>
<div {{onResize this.handleResize}}>
Content that responds to size changes
</div>
</template>
```
Modifiers provide a clean, reusable way to manage DOM side effects without coupling to specific components.
Reference: [Ember Modifiers](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/)
@@ -0,0 +1,277 @@
---
title: Use Reactive Collections from @ember/reactive/collections
impact: HIGH
impactDescription: Enables reactive arrays, maps, and sets
tags: reactivity, tracked, collections, advanced
---
## Use Reactive Collections from @ember/reactive/collections
Use reactive collections from `@ember/reactive/collections` to make arrays, Maps, and Sets reactive in Ember. Standard JavaScript collections don't trigger Ember's reactivity system when mutated—reactive collections solve this.
**The Problem:**
Standard arrays, Maps, and Sets are not reactive in Ember when you mutate them. Changes won't trigger template updates.
**The Solution:**
Use Ember's built-in reactive collections from `@ember/reactive/collections`.
### Reactive Arrays
**Incorrect (non-reactive array):**
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class TodoList extends Component {
@tracked todos = []; // ❌ Array mutations (push, splice, etc.) won't trigger updates
@action
addTodo(text) {
// This won't trigger a re-render!
this.todos.push({ id: Date.now(), text });
}
@action
removeTodo(id) {
// This also won't trigger a re-render!
const index = this.todos.findIndex((t) => t.id === id);
this.todos.splice(index, 1);
}
<template>
<ul>
{{#each this.todos as |todo|}}
<li>
{{todo.text}}
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
</li>
{{/each}}
</ul>
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
</template>
}
```
**Correct (reactive array with @ember/reactive/collections):**
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedArray } from '@ember/reactive/collections';
export default class TodoList extends Component {
todos = trackedArray([]); // ✅ Mutations are reactive
@action
addTodo(text) {
// Now this triggers re-render!
this.todos.push({ id: Date.now(), text });
}
@action
removeTodo(id) {
// This also triggers re-render!
const index = this.todos.findIndex((t) => t.id === id);
this.todos.splice(index, 1);
}
<template>
<ul>
{{#each this.todos as |todo|}}
<li>
{{todo.text}}
<button {{on "click" (fn this.removeTodo todo.id)}}>Remove</button>
</li>
{{/each}}
</ul>
<button {{on "click" (fn this.addTodo "New todo")}}>Add</button>
</template>
}
```
### Reactive Maps
Maps are useful for key-value stores with non-string keys:
```glimmer-js
// app/components/user-cache.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedMap } from '@ember/reactive/collections';
export default class UserCache extends Component {
userCache = trackedMap(); // key: userId, value: userData
@action
cacheUser(userId, userData) {
this.userCache.set(userId, userData);
}
@action
clearUser(userId) {
this.userCache.delete(userId);
}
get cachedUsers() {
return Array.from(this.userCache.values());
}
<template>
<ul>
{{#each this.cachedUsers as |user|}}
<li>{{user.name}}</li>
{{/each}}
</ul>
<p>Cache size: {{this.userCache.size}}</p>
</template>
}
```
### Reactive Sets
Sets are useful for unique collections:
```glimmer-js
// app/components/tag-selector.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { trackedSet } from '@ember/reactive/collections';
export default class TagSelector extends Component {
selectedTags = trackedSet();
@action
toggleTag(tag) {
if (this.selectedTags.has(tag)) {
this.selectedTags.delete(tag);
} else {
this.selectedTags.add(tag);
}
}
get selectedCount() {
return this.selectedTags.size;
}
<template>
<div>
{{#each @availableTags as |tag|}}
<label>
<input
type="checkbox"
checked={{this.selectedTags.has tag}}
{{on "change" (fn this.toggleTag tag)}}
/>
{{tag}}
</label>
{{/each}}
</div>
<p>Selected: {{this.selectedCount}} tags</p>
</template>
}
```
### When to Use Each Type
| Type | Use Case |
| -------------- | ------------------------------------------------------------------ |
| `trackedArray` | Ordered lists that need mutation methods (push, pop, splice, etc.) |
| `trackedMap` | Key-value pairs with non-string keys or when you need `size` |
| `trackedSet` | Unique values, membership testing |
### Common Patterns
**Initialize with data:**
```javascript
import { trackedArray, trackedMap, trackedSet } from '@ember/reactive/collections';
// Array
const todos = trackedArray([
{ id: 1, text: 'First' },
{ id: 2, text: 'Second' },
]);
// Map
const userMap = trackedMap([
[1, { name: 'Alice' }],
[2, { name: 'Bob' }],
]);
// Set
const tags = trackedSet(['javascript', 'ember', 'web']);
```
**Convert to plain JavaScript:**
```javascript
// Array
const plainArray = [...trackedArray];
const plainArray2 = Array.from(trackedArray);
// Map
const plainObject = Object.fromEntries(trackedMap);
// Set
const plainArray3 = [...trackedSet];
```
**Functional array methods still work:**
```javascript
const todos = trackedArray([...]);
// All of these work and are reactive
const completed = todos.filter(t => t.done);
const titles = todos.map(t => t.title);
const allDone = todos.every(t => t.done);
const firstIncomplete = todos.find(t => !t.done);
```
### Alternative: Immutable Updates
If you prefer immutability, you can use regular `@tracked` with reassignment:
```javascript
import { tracked } from '@glimmer/tracking';
export default class TodoList extends Component {
@tracked todos = [];
@action
addTodo(text) {
// Reassignment is reactive
this.todos = [...this.todos, { id: Date.now(), text }];
}
@action
removeTodo(id) {
// Reassignment is reactive
this.todos = this.todos.filter((t) => t.id !== id);
}
}
```
**When to use each approach:**
- Use reactive collections when you need mutable operations (better performance for large lists)
- Use immutable updates when you want simpler mental model or need history/undo
### Best Practices
1. **Don't mix approaches** - choose either reactive collections or immutable updates
2. **Initialize in class field** - no need for constructor
3. **Use appropriate type** - Map for key-value, Set for unique values, Array for ordered lists
4. **Export from modules** if shared across components
Reactive collections from `@ember/reactive/collections` provide the best of both worlds: mutable operations with full reactivity. They're especially valuable for large lists or frequent updates where immutable updates would be expensive.
**References:**
- [Ember Reactivity System](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
- [JavaScript Built-in Objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects)
- [Reactive Collections RFC](https://github.com/emberjs/rfcs/blob/master/text/0869-reactive-collections.md)
@@ -0,0 +1,62 @@
---
title: Avoid Importing Entire Addon Namespaces
impact: CRITICAL
impactDescription: 200-500ms import cost reduction
tags: bundle, imports, tree-shaking, performance
---
## Avoid Importing Entire Addon Namespaces
Import specific utilities and components directly rather than entire addon namespaces to enable better tree-shaking and reduce bundle size.
**Incorrect (imports entire namespace):**
```javascript
import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import { action } from '@ember/object';
// OK - these are already optimized
// But avoid this pattern with utility libraries:
import * as lodash from 'lodash';
import * as moment from 'moment';
class My extends Component {
someMethod() {
return lodash.debounce(this.handler, 300);
}
}
```
**Correct (direct imports):**
```javascript
import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import { action } from '@ember/object';
import debounce from 'lodash/debounce';
import dayjs from 'dayjs'; // moment alternative, smaller
class My extends Component {
someMethod() {
return debounce(this.handler, 300);
}
}
```
**Even better (use Ember utilities when available):**
```javascript
import { tracked } from '@glimmer/tracking';
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { debounce } from '@ember/runloop';
class My extends Component {
someMethod() {
return debounce(this, this.handler, 300);
}
}
```
Direct imports and using built-in Ember utilities reduce bundle size by avoiding unused code.
@@ -0,0 +1,69 @@
---
title: Use Embroider Build Pipeline
impact: CRITICAL
impactDescription: Modern build system with better performance
tags: bundle, embroider, build-performance, vite
---
## Use Embroider Build Pipeline
Use Embroider, Ember's modern build pipeline, with Vite for faster builds, better tree-shaking, and smaller bundles.
**Incorrect (classic build pipeline):**
```javascript
// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
module.exports = function (defaults) {
const app = new EmberApp(defaults, {});
return app.toTree();
};
```
**Correct (Embroider with Vite):**
```javascript
// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
const { compatBuild } = require('@embroider/compat');
module.exports = async function (defaults) {
const { buildOnce } = await import('@embroider/vite');
let app = new EmberApp(defaults, {
// Add options here
});
return compatBuild(app, buildOnce);
};
```
**For stricter static analysis (optimized mode):**
```javascript
// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
const { compatBuild } = require('@embroider/compat');
module.exports = async function (defaults) {
const { buildOnce } = await import('@embroider/vite');
let app = new EmberApp(defaults, {
// Add options here
});
return compatBuild(app, buildOnce, {
// Enable static analysis for better tree-shaking
staticAddonTestSupportTrees: true,
staticAddonTrees: true,
staticHelpers: true,
staticModifiers: true,
staticComponents: true,
});
};
```
Embroider provides a modern build pipeline with Vite that offers faster builds and better optimization compared to the classic Ember CLI build system.
Reference: [Embroider Documentation](https://github.com/embroider-build/embroider)
@@ -0,0 +1,71 @@
---
title: Lazy Load Heavy Dependencies
impact: CRITICAL
impactDescription: 30-50% initial bundle reduction
tags: bundle, lazy-loading, dynamic-imports, performance
---
## Lazy Load Heavy Dependencies
Use dynamic imports to load heavy libraries only when needed, reducing initial bundle size.
**Incorrect (loaded upfront):**
```javascript
import Component from '@glimmer/component';
import Chart from 'chart.js/auto'; // 300KB library loaded immediately
import hljs from 'highlight.js'; // 500KB library loaded immediately
class Dashboard extends Component {
get showChart() {
return this.args.hasData;
}
}
```
**Correct (lazy loaded with error/loading state handling):**
```glimmer-js
import Component from '@glimmer/component';
import { getPromiseState } from 'reactiveweb/promise';
class Dashboard extends Component {
// Use getPromiseState to model promise state for error/loading handling
chartLoader = getPromiseState(async () => {
const { default: Chart } = await import('chart.js/auto');
return Chart;
});
highlighterLoader = getPromiseState(async () => {
const { default: hljs } = await import('highlight.js');
return hljs;
});
loadChart = () => {
// Triggers lazy load, handles loading/error states automatically
return this.chartLoader.value;
};
highlightCode = (code) => {
const hljs = this.highlighterLoader.value;
if (hljs) {
return hljs.highlightAuto(code);
}
return code;
};
<template>
{{#if this.chartLoader.isLoading}}
<p>Loading chart library...</p>
{{else if this.chartLoader.isError}}
<p>Error loading chart: {{this.chartLoader.error.message}}</p>
{{else if this.chartLoader.isResolved}}
<canvas {{on "click" this.loadChart}}></canvas>
{{/if}}
</template>
}
```
**Note**: Always model promise state (loading/error/resolved) using `getPromiseState` from `reactiveweb/promise` to handle slow networks and errors properly.
Dynamic imports reduce initial bundle size by 30-50%, improving Time to Interactive.
@@ -0,0 +1,174 @@
---
title: Validate Component Arguments
impact: MEDIUM
impactDescription: Better error messages and type safety
tags: components, validation, arguments, typescript
---
## Validate Component Arguments
Validate component arguments for better error messages, documentation, and type safety.
**Incorrect (no argument validation):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div>
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
}
```
**Correct (with TypeScript signature):**
```glimmer-ts
// app/components/user-card.gts
import Component from '@glimmer/component';
interface UserCardSignature {
Args: {
user: {
name: string;
email: string;
avatarUrl?: string;
};
onEdit?: (user: UserCardSignature['Args']['user']) => void;
};
Blocks: {
default: [];
};
Element: HTMLDivElement;
}
class UserCard extends Component<UserCardSignature> {
<template>
<div ...attributes>
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
{{#if @user.avatarUrl}}
<img src={{@user.avatarUrl}} alt={{@user.name}} />
{{/if}}
{{#if @onEdit}}
<button {{on "click" (fn @onEdit @user)}}>Edit</button>
{{/if}}
{{yield}}
</div>
</template>
}
```
**Runtime validation with assertions (using getters):**
```glimmer-js
// app/components/data-table.gjs
import Component from '@glimmer/component';
import { assert } from '@ember/debug';
class DataTable extends Component {
// Use getters so validation runs on each access and catches arg changes
get columns() {
assert(
'DataTable requires @columns argument',
this.args.columns && Array.isArray(this.args.columns),
);
assert(
'@columns must be an array of objects with "key" and "label" properties',
this.args.columns.every((col) => col.key && col.label),
);
return this.args.columns;
}
get rows() {
assert('DataTable requires @rows argument', this.args.rows && Array.isArray(this.args.rows));
return this.args.rows;
}
<template>
<table>
<thead>
<tr>
{{#each this.columns as |column|}}
<th>{{column.label}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.rows as |row|}}
<tr>
{{#each this.columns as |column|}}
<td>{{get row column.key}}</td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</template>
}
```
**Template-only component with TypeScript:**
```glimmer-ts
// app/components/icon.gts
import type { TOC } from '@ember/component/template-only';
interface IconSignature {
Args: {
name: string;
size?: 'small' | 'medium' | 'large';
};
Element: HTMLSpanElement;
}
const Icon: TOC<IconSignature> = <template>
<span ...attributes></span>
</template>;
export default Icon;
```
**Documentation with JSDoc:**
```glimmer-js
// app/components/modal.gjs
import Component from '@glimmer/component';
/**
* Modal dialog component
*
* @param {Object} args
* @param {boolean} args.isOpen - Controls modal visibility
* @param {() => void} args.onClose - Called when modal should close
* @param {string} [args.title] - Optional modal title
* @param {string} [args.size='medium'] - Modal size: 'small', 'medium', 'large'
*/
class Modal extends Component {
<template>
{{#if @isOpen}}
<div>
{{#if @title}}
<h2>{{@title}}</h2>
{{/if}}
{{yield}}
<button {{on "click" @onClose}}>Close</button>
</div>
{{/if}}
</template>
}
```
Argument validation provides better error messages during development, serves as documentation, and enables better IDE support.
Reference: [TypeScript in Ember](https://guides.emberjs.com/release/typescript/)
@@ -0,0 +1,174 @@
---
title: Avoid CSS Classes in Learning Examples
impact: LOW-MEDIUM
impactDescription: Cleaner, more focused learning materials
tags: documentation, examples, learning, css, classes
---
## Avoid CSS Classes in Learning Examples
Don't add CSS classes to learning content and examples unless they provide actual value above the surrounding context. Classes add visual noise and distract from the concepts being taught.
**Incorrect (unnecessary classes in learning example):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
export class UserCard extends Component {
<template>
<div class="user-card">
<div class="user-card__header">
<h3 class="user-card__name">{{@user.name}}</h3>
<p class="user-card__email">{{@user.email}}</p>
</div>
{{#if @user.avatarUrl}}
<img class="user-card__avatar" src={{@user.avatarUrl}} alt={{@user.name}} />
{{/if}}
{{#if @onEdit}}
<button class="user-card__edit-button" {{on "click" (fn @onEdit @user)}}>
Edit
</button>
{{/if}}
<div class="user-card__content">
{{yield}}
</div>
</div>
</template>
}
```
**Why This Is Wrong:**
- Classes add visual clutter that obscures the actual concepts
- Learners focus on naming conventions instead of the pattern being taught
- Makes copy-paste more work (need to remove or change class names)
- Implies these specific class names are required or best practice
- Distracts from structural HTML and component logic
**Correct (focused on concepts):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
export class UserCard extends Component {
<template>
<div ...attributes>
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
{{#if @user.avatarUrl}}
<img src={{@user.avatarUrl}} alt={{@user.name}} />
{{/if}}
{{#if @onEdit}}
<button {{on "click" (fn @onEdit @user)}}>Edit</button>
{{/if}}
{{yield}}
</div>
</template>
}
```
**Benefits:**
- **Clarity**: Easier to understand the component structure
- **Focus**: Reader attention stays on the concepts being taught
- **Simplicity**: Less code to process mentally
- **Flexibility**: Reader can add their own classes without conflict
- **Reusability**: Examples are easier to adapt to real code
**When Classes ARE Appropriate in Examples:**
```glimmer-js
// Example: Teaching about conditional classes
export class StatusBadge extends Component {
get statusClass() {
return this.args.status === 'active' ? 'badge-success' : 'badge-error';
}
<template>
<span class={{this.statusClass}}>
{{@status}}
</span>
</template>
}
```
```glimmer-js
// Example: Teaching about ...attributes for styling flexibility
export class Card extends Component {
<template>
{{! Caller can add their own classes via ...attributes }}
<div ...attributes>
{{yield}}
</div>
</template>
}
{{! Usage: <Card class="user-card">...</Card> }}
```
**When to Include Classes:**
1. **Teaching class binding** - Example explicitly about conditional classes or class composition
2. **Demonstrating ...attributes** - Showing how callers add classes
3. **Accessibility** - Using classes for semantic meaning (e.g., `aria-*` helpers)
4. **Critical to example** - Class name is essential to understanding (e.g., `selected`, `active`)
**Examples Where Classes Add Value:**
```glimmer-js
// Good: Teaching about dynamic classes
export class TabButton extends Component {
<template>
<button class={{if @isActive "active"}} {{on "click" @onClick}}>
{{yield}}
</button>
</template>
}
```
```glimmer-js
// Good: Teaching about class composition
import { cn } from 'ember-cn';
export class Button extends Component {
<template>
<button class={{cn "btn" (if @primary "btn-primary" "btn-secondary")}}>
{{yield}}
</button>
</template>
}
```
**Default Stance:**
When writing learning examples or documentation:
1. **Start without classes** - Add them only if needed
2. **Ask**: Does this class help explain the concept?
3. **Remove** any decorative or structural classes that aren't essential
4. **Use** `...attributes` to show styling flexibility
**Real-World Context:**
In production code, you'll have classes for styling. But in learning materials, strip them away unless they're teaching something specific about classes themselves.
**Common Violations:**
❌ BEM classes in examples (`user-card__header`)
❌ Utility classes unless teaching utilities (`flex`, `mt-4`)
❌ Semantic classes that don't teach anything (`container`, `wrapper`)
❌ Design system classes unless teaching design system integration
**Summary:**
Keep learning examples focused on the concept being taught. CSS classes should appear only when they're essential to understanding the pattern or when demonstrating styling flexibility with `...attributes`.
Reference: [Ember Components Guide](https://guides.emberjs.com/release/components/)
@@ -0,0 +1,162 @@
---
title: Avoid Constructors in Components
impact: HIGH
impactDescription: Prevents infinite render loops and simplifies code
tags: components, constructors, initialization, anti-pattern
---
## Avoid Constructors in Components
**Strongly discourage constructor usage.** Modern Ember components rarely need constructors. Use class fields, @service decorators, and getPromiseState for initialization instead. Constructors with function calls that set tracked state can cause infinite render loops.
**Incorrect (using constructor):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
class UserProfile extends Component {
constructor() {
super(...arguments);
// Anti-pattern: Manual service lookup
this.store = this.owner.lookup('service:store');
this.router = this.owner.lookup('service:router');
// Anti-pattern: Imperative initialization
this.data = null;
this.isLoading = false;
this.error = null;
// Anti-pattern: Side effects in constructor
this.loadUserData();
}
async loadUserData() {
this.isLoading = true;
try {
this.data = await this.store.request({
url: `/users/${this.args.userId}`,
});
} catch (e) {
this.error = e;
} finally {
this.isLoading = false;
}
}
<template>
{{#if this.isLoading}}
<div>Loading...</div>
{{else if this.error}}
<div>Error: {{this.error.message}}</div>
{{else if this.data}}
<h1>{{this.data.name}}</h1>
{{/if}}
</template>
}
```
**Correct (use class fields and declarative async state):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { service } from '@ember/service';
import { getRequestState } from '@warp-drive/ember';
class UserProfile extends Component {
@service store;
@cached
get userRequest() {
return this.store.request({
url: `/users/${this.args.userId}`,
});
}
<template>
{{#let (getRequestState this.userRequest) as |state|}}
{{#if state.isPending}}
<div>Loading...</div>
{{else if state.isError}}
<div>Error loading user</div>
{{else}}
<h1>{{state.value.name}}</h1>
{{/if}}
{{/let}}
</template>
}
```
**When You Might Need a Constructor (Very Rare):**
Very rarely, you might need a constructor for truly exceptional cases. Even then, use modern patterns:
```glimmer-js
// app/components/complex-setup.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
class ComplexSetup extends Component {
@service store;
@tracked state = null;
constructor(owner, args) {
super(owner, args);
// Only if you absolutely must do something that can't be done with class fields
// Even then, prefer resources or modifiers
if (this.args.legacyInitMode) {
this.initializeLegacyMode();
}
}
initializeLegacyMode() {
// Rare edge case initialization
}
<template>
<!-- template -->
</template>
}
```
**Why Strongly Avoid Constructors:**
1. **Infinite Render Loops**: Setting tracked state in constructor that's read during render causes infinite loops
2. **Service Injection**: Use `@service` decorator instead of `owner.lookup()`
3. **Testability**: Class fields are easier to mock and test
4. **Clarity**: Declarative class fields show state at a glance
5. **Side Effects**: getPromiseState and modifiers handle side effects better
6. **Memory Leaks**: getPromiseState auto-cleanup; constructor code doesn't
7. **Reactivity**: Class fields integrate better with tracking
8. **Initialization Order**: No need to worry about super() call timing
9. **Argument Validation**: Constructor validation runs only once; use getters to catch arg changes
**Modern Alternatives:**
| Old Pattern | Modern Alternative |
| -------------------------------------------------------------- | -------------------------------------------------------- |
| `constructor() { this.store = owner.lookup('service:store') }` | `@service store;` |
| `constructor() { this.data = null; }` | `@tracked data = null;` |
| `constructor() { this.loadData(); }` | Use `@cached get` with getPromiseState |
| `constructor() { this.interval = setInterval(...) }` | Use modifier with registerDestructor |
| `constructor() { this.subscription = ... }` | Use modifier or constructor with registerDestructor ONLY |
**Performance Impact:**
- **Before**: Constructor runs on every instantiation, manual cleanup risk, infinite loop danger
- **After**: Class fields initialize efficiently, getPromiseState auto-cleanup, no render loops
**Strongly discourage constructors** - they add complexity and infinite render loop risks. Use declarative class fields and getPromiseState instead.
Reference:
- [Ember Octane Guide](https://guides.emberjs.com/release/upgrading/current-edition/)
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
@@ -0,0 +1,322 @@
---
title: Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
impact: HIGH
impactDescription: Prevents memory leaks and enforces modern patterns
tags: components, lifecycle, anti-pattern, modifiers, derived-data
---
## Avoid Legacy Lifecycle Hooks (did-insert, will-destroy, did-update)
**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.** These legacy helpers create coupling between templates and component lifecycle, making code harder to test and maintain. Modern Ember provides better alternatives through derived data and custom modifiers.
### Why These Are Problematic
1. **Memory Leaks**: Easy to forget cleanup, especially with `did-insert`
2. **Tight Coupling**: Mixes template concerns with JavaScript logic
3. **Poor Testability**: Lifecycle hooks are harder to unit test
4. **Not Composable**: Can't be easily shared across components
5. **Deprecated Pattern**: Not recommended in modern Ember
### Alternative 1: Use Derived Data
For computed values or reactive transformations, use getters and `@cached`:
**❌ Incorrect (did-update):**
```glimmer-js
// app/components/user-greeting.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class UserGreeting extends Component {
@tracked displayName = '';
@action
updateDisplayName() {
// Runs on every render - inefficient and error-prone
this.displayName = `${this.args.user.firstName} ${this.args.user.lastName}`;
}
<template>
<div {{did-update this.updateDisplayName @user}}>
Hello,
{{this.displayName}}
</div>
</template>
}
```
**✅ Correct (derived data with getter):**
```glimmer-js
// app/components/user-greeting.gjs
import Component from '@glimmer/component';
class UserGreeting extends Component {
// Automatically reactive - updates when args change
get displayName() {
return `${this.args.user.firstName} ${this.args.user.lastName}`;
}
<template>
<div>
Hello,
{{this.displayName}}
</div>
</template>
}
```
**✅ Even better (use @cached for expensive computations):**
```glimmer-js
// app/components/user-stats.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class UserStats extends Component {
@cached
get sortedPosts() {
// Expensive computation only runs when @posts changes
return [...this.args.posts].sort((a, b) => b.createdAt - a.createdAt);
}
@cached
get statistics() {
return {
total: this.args.posts.length,
published: this.args.posts.filter((p) => p.published).length,
drafts: this.args.posts.filter((p) => !p.published).length,
};
}
<template>
<div>
<p>Total: {{this.statistics.total}}</p>
<p>Published: {{this.statistics.published}}</p>
<p>Drafts: {{this.statistics.drafts}}</p>
<ul>
{{#each this.sortedPosts as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
</div>
</template>
}
```
### Alternative 2: Use Custom Modifiers
For DOM side effects, element setup, or cleanup, use custom modifiers:
**❌ Incorrect (did-insert + will-destroy):**
```glimmer-js
// app/components/chart.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Chart extends Component {
chartInstance = null;
@action
setupChart(element) {
this.chartInstance = new Chart(element, this.args.config);
}
willDestroy() {
super.willDestroy();
// Easy to forget cleanup!
this.chartInstance?.destroy();
}
<template>
<canvas {{did-insert this.setupChart}}></canvas>
</template>
}
```
**✅ Correct (custom modifier with automatic cleanup):**
```javascript
// app/modifiers/chart.js
import { modifier } from 'ember-modifier';
import { registerDestructor } from '@ember/destroyable';
export default modifier((element, [config]) => {
// Setup
const chartInstance = new Chart(element, config);
// Cleanup happens automatically
registerDestructor(element, () => {
chartInstance.destroy();
});
});
```
```glimmer-js
// app/components/chart.gjs
import chart from '../modifiers/chart';
<template>
<canvas {{chart @config}}></canvas>
</template>
```
### Alternative 3: Use Resources for Lifecycle Management
For complex state management with automatic cleanup, use `ember-resources`:
**❌ Incorrect (did-insert for data fetching):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class UserProfile extends Component {
@tracked userData = null;
@tracked loading = true;
controller = new AbortController();
@action
async loadUser() {
this.loading = true;
try {
const response = await fetch(`/api/users/${this.args.userId}`, {
signal: this.controller.signal,
});
this.userData = await response.json();
} finally {
this.loading = false;
}
}
willDestroy() {
super.willDestroy();
this.controller.abort(); // Easy to forget!
}
<template>
<div {{did-insert this.loadUser}}>
{{#if this.loading}}
Loading...
{{else}}
{{this.userData.name}}
{{/if}}
</div>
</template>
}
```
**✅ Correct (Resource with automatic cleanup):**
```javascript
// app/resources/user-data.js
import { Resource } from 'ember-resources';
import { tracked } from '@glimmer/tracking';
export default class UserDataResource extends Resource {
@tracked data = null;
@tracked loading = true;
controller = new AbortController();
modify(positional, named) {
const [userId] = positional;
this.loadUser(userId);
}
async loadUser(userId) {
this.loading = true;
try {
const response = await fetch(`/api/users/${userId}`, {
signal: this.controller.signal,
});
this.data = await response.json();
} finally {
this.loading = false;
}
}
willDestroy() {
// Cleanup happens automatically
this.controller.abort();
}
}
```
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import UserDataResource from '../resources/user-data';
class UserProfile extends Component {
userData = UserDataResource.from(this, () => [this.args.userId]);
<template>
{{#if this.userData.loading}}
Loading...
{{else}}
{{this.userData.data.name}}
{{/if}}
</template>
}
```
### When to Use Each Alternative
| Use Case | Solution | Why |
| ---------------- | ----------------------------------- | ----------------------------------------- |
| Computed values | Getters + `@cached` | Reactive, efficient, no lifecycle needed |
| DOM manipulation | Custom modifiers | Encapsulated, reusable, automatic cleanup |
| Data fetching | getPromiseState from warp-drive | Declarative, automatic cleanup |
| Event listeners | `{{on}}` modifier | Built-in, automatic cleanup |
| Focus management | Custom modifier or ember-focus-trap | Proper lifecycle, accessibility |
### Migration Strategy
If you have existing code using these hooks:
1. **Identify the purpose**: What is the hook doing?
2. **Choose the right alternative**:
- Deriving data? → Use getters/`@cached`
- DOM setup/teardown? → Use a custom modifier
- Async data loading? → Use getPromiseState from warp-drive
3. **Test thoroughly**: Ensure cleanup happens correctly
4. **Remove the legacy hook**: Delete `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}`
### Performance Benefits
Modern alternatives provide better performance:
- **Getters**: Only compute when dependencies change
- **@cached**: Memoizes expensive computations
- **Modifiers**: Scoped to specific elements, composable
- **getPromiseState**: Declarative data loading, automatic cleanup
### Common Pitfalls to Avoid
❌ **Don't use `willDestroy()` for cleanup when a modifier would work**
❌ **Don't use `@action` + `did-insert` when a getter would suffice**
❌ **Don't manually track changes when `@cached` handles it automatically**
❌ **Don't forget `registerDestructor` in custom modifiers**
### Summary
Modern Ember provides superior alternatives to legacy lifecycle hooks:
- **Derived Data**: Use getters and `@cached` for reactive computations
- **DOM Side Effects**: Use custom modifiers with `registerDestructor`
- **Async Data Loading**: Use getPromiseState from warp-drive/reactiveweb
- **Better Code**: More testable, composable, and maintainable
**Never use `{{did-insert}}`, `{{will-destroy}}`, or `{{did-update}}` in new code.**
Reference:
- [Ember Modifiers](https://github.com/ember-modifier/ember-modifier)
- [warp-drive/reactiveweb](https://github.com/emberjs/data/tree/main/packages/reactiveweb)
- [Glimmer Tracking](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
@@ -0,0 +1,53 @@
---
title: Use @cached for Expensive Getters
impact: HIGH
impactDescription: 50-90% reduction in recomputation
tags: components, performance, caching, tracked
---
## Use @cached for Expensive Getters
Use `@cached` from `@glimmer/tracking` to memoize expensive computations that depend on tracked properties. The cached value is automatically invalidated when dependencies change.
**Incorrect (recomputes on every access):**
```javascript
import Component from '@glimmer/component';
class DataTable extends Component {
get filteredAndSortedData() {
// Expensive: runs on every access, even if nothing changed
return this.args.data
.filter((item) => item.status === this.args.filter)
.sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
.map((item) => this.transformItem(item));
}
}
```
**Correct (cached computation):**
```javascript
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class DataTable extends Component {
@cached
get filteredAndSortedData() {
// Computed once per unique combination of dependencies
return this.args.data
.filter((item) => item.status === this.args.filter)
.sort((a, b) => a[this.args.sortBy] - b[this.args.sortBy])
.map((item) => this.transformItem(item));
}
transformItem(item) {
// Expensive transformation
return { ...item, computed: this.expensiveCalculation(item) };
}
}
```
`@cached` memoizes the getter result and only recomputes when tracked dependencies change, providing 50-90% reduction in unnecessary work.
Reference: [@cached decorator](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/#toc_caching)
@@ -0,0 +1,324 @@
---
title: Use Class Fields for Component Composition
impact: MEDIUM-HIGH
impactDescription: Better composition and initialization patterns
tags: components, class-fields, composition, initialization
---
## Use Class Fields for Component Composition
Use class fields for clean component composition, initialization, and dependency injection patterns. Tracked class fields should be **roots of state** - representing the minimal independent state that your component owns. In most apps, you should have very few tracked fields.
**Incorrect (imperative initialization, scattered state):**
```glimmer-js
// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
class DataManager extends Component {
@service store;
@service router;
// Scattered state management - hard to track relationships
@tracked currentUser = null;
@tracked isLoading = false;
@tracked error = null;
loadData = async () => {
this.isLoading = true;
try {
this.currentUser = await this.store.request({ url: '/users/me' });
} catch (e) {
this.error = e;
} finally {
this.isLoading = false;
}
};
<template>
<div>{{this.currentUser.name}}</div>
</template>
}
```
**Correct (class fields with proper patterns):**
```glimmer-js
// app/components/data-manager.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { cached } from '@glimmer/tracking';
import { getPromiseState } from '@warp-drive/reactiveweb';
class DataManager extends Component {
// Service injection as class fields
@service store;
@service router;
// Tracked state as class fields - this is a "root of state"
// Most components should have very few of these
@tracked selectedFilter = 'all';
// Data loading with getPromiseState
@cached
get currentUser() {
const promise = this.store.request({
url: '/users/me',
});
return getPromiseState(promise);
}
<template>
{{#if this.currentUser.isFulfilled}}
<div>{{this.currentUser.value.name}}</div>
{{else if this.currentUser.isRejected}}
<div>Error: {{this.currentUser.error.message}}</div>
{{/if}}
</template>
}
```
**Understanding "roots of state":**
Tracked fields should represent **independent state** that your component owns - not derived data or loaded data. Examples of good tracked fields:
- User selections (selected tab, filter option)
- UI state (is modal open, is expanded)
- Form input values (not yet persisted)
In most apps, you'll have very few tracked fields because most data comes from arguments, services, or computed getters.
**Composition through class field assignment:**
```glimmer-js
// app/components/form-container.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { TrackedObject } from 'tracked-built-ins';
class FormContainer extends Component {
// Compose form state
@tracked formData = new TrackedObject({
firstName: '',
lastName: '',
email: '',
preferences: {
newsletter: false,
notifications: true,
},
});
// Compose validation state
@tracked errors = new TrackedObject({});
// Compose UI state
@tracked ui = new TrackedObject({
isSubmitting: false,
isDirty: false,
showErrors: false,
});
// Computed field based on composed state
get isValid() {
return Object.keys(this.errors).length === 0 && this.formData.email && this.formData.firstName;
}
get canSubmit() {
return this.isValid && !this.ui.isSubmitting && this.ui.isDirty;
}
updateField = (field, value) => {
this.formData[field] = value;
this.ui.isDirty = true;
this.validate(field, value);
};
validate(field, value) {
if (field === 'email' && !value.includes('@')) {
this.errors.email = 'Invalid email';
} else {
delete this.errors[field];
}
}
<template>
<form>
<input
value={{this.formData.firstName}}
{{on "input" (pick "target.value" (fn this.updateField "firstName"))}}
/>
<button disabled={{not this.canSubmit}}>
Submit
</button>
</form>
</template>
}
```
**Mixin-like composition with class fields:**
```javascript
// app/utils/pagination-mixin.js
import { tracked } from '@glimmer/tracking';
export class PaginationState {
@tracked page = 1;
@tracked perPage = 20;
get offset() {
return (this.page - 1) * this.perPage;
}
nextPage = () => {
this.page++;
};
prevPage = () => {
if (this.page > 1) this.page--;
};
goToPage = (page) => {
this.page = page;
};
}
```
```glimmer-js
// app/components/paginated-list.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { PaginationState } from '../utils/pagination-mixin';
class PaginatedList extends Component {
// Compose pagination functionality
pagination = new PaginationState();
@cached
get paginatedItems() {
const start = this.pagination.offset;
const end = start + this.pagination.perPage;
return this.args.items.slice(start, end);
}
get totalPages() {
return Math.ceil(this.args.items.length / this.pagination.perPage);
}
<template>
<div class="list">
{{#each this.paginatedItems as |item|}}
<div>{{item.name}}</div>
{{/each}}
<div class="pagination">
<button {{on "click" this.pagination.prevPage}} disabled={{eq this.pagination.page 1}}>
Previous
</button>
<span>Page {{this.pagination.page}} of {{this.totalPages}}</span>
<button
{{on "click" this.pagination.nextPage}}
disabled={{eq this.pagination.page this.totalPages}}
>
Next
</button>
</div>
</div>
</template>
}
```
**Shareable state objects:**
```javascript
// app/utils/selection-state.js
import { tracked } from '@glimmer/tracking';
import { TrackedSet } from 'tracked-built-ins';
export class SelectionState {
@tracked selectedIds = new TrackedSet();
get count() {
return this.selectedIds.size;
}
get hasSelection() {
return this.selectedIds.size > 0;
}
isSelected(id) {
return this.selectedIds.has(id);
}
toggle = (id) => {
if (this.selectedIds.has(id)) {
this.selectedIds.delete(id);
} else {
this.selectedIds.add(id);
}
};
selectAll = (items) => {
items.forEach((item) => this.selectedIds.add(item.id));
};
clear = () => {
this.selectedIds.clear();
};
}
```
```glimmer-js
// app/components/selectable-list.gjs
import Component from '@glimmer/component';
import { SelectionState } from '../utils/selection-state';
class SelectableList extends Component {
// Compose selection behavior
selection = new SelectionState();
get selectedItems() {
return this.args.items.filter((item) => this.selection.isSelected(item.id));
}
<template>
<div class="toolbar">
<button {{on "click" (fn this.selection.selectAll @items)}}>
Select All
</button>
<button {{on "click" this.selection.clear}}>
Clear
</button>
<span>{{this.selection.count}} selected</span>
</div>
<ul>
{{#each @items as |item|}}
<li class={{if (this.selection.isSelected item.id) "selected"}}>
<input
type="checkbox"
checked={{this.selection.isSelected item.id}}
{{on "change" (fn this.selection.toggle item.id)}}
/>
{{item.name}}
</li>
{{/each}}
</ul>
{{#if this.selection.hasSelection}}
<div class="actions">
<button>Delete {{this.selection.count}} items</button>
</div>
{{/if}}
</template>
}
```
Class fields provide clean composition patterns, better initialization, and shareable state objects that can be tested independently.
Reference: [JavaScript Class Fields](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Public_class_fields)
@@ -0,0 +1,241 @@
---
title: Use Component Composition Patterns
impact: HIGH
impactDescription: Better code reuse and maintainability
tags: components, composition, yield, blocks, contextual-components
---
## Use Component Composition Patterns
Use component composition with yield blocks, named blocks, and contextual components for flexible, reusable UI patterns.
**Named blocks** are for invocation consistency in design systems where you **don't want the caller to have full markup control**. They provide structured extension points while maintaining design system constraints - the same concept as named slots in other frameworks.
**Incorrect (monolithic component):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div class="user-card">
<div class="header">
<img src={{@user.avatar}} alt={{@user.name}} />
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
{{#if @showActions}}
<div class="actions">
<button {{on "click" @onEdit}}>Edit</button>
<button {{on "click" @onDelete}}>Delete</button>
</div>
{{/if}}
{{#if @showStats}}
<div class="stats">
<span>Posts: {{@user.postCount}}</span>
<span>Followers: {{@user.followers}}</span>
</div>
{{/if}}
</div>
</template>
}
```
**Correct (composable with named blocks):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div class="user-card" ...attributes>
{{#if (has-block "header")}}
{{yield to="header"}}
{{else}}
<div class="header">
<img src={{@user.avatar}} alt={{@user.name}} />
<h3>{{@user.name}}</h3>
</div>
{{/if}}
{{yield @user to="default"}}
{{#if (has-block "actions")}}
<div class="actions">
{{yield @user to="actions"}}
</div>
{{/if}}
{{#if (has-block "footer")}}
<div class="footer">
{{yield @user to="footer"}}
</div>
{{/if}}
</div>
</template>
}
```
**Usage with flexible composition:**
```glimmer-js
// app/components/user-list.gjs
import UserCard from './user-card';
<template>
{{#each @users as |user|}}
<UserCard @user={{user}}>
<:header>
<div class="custom-header">
<span class="badge">{{user.role}}</span>
<h3>{{user.name}}</h3>
</div>
</:header>
<:default as |u|>
<p class="bio">{{u.bio}}</p>
<p class="email">{{u.email}}</p>
</:default>
<:actions as |u|>
<button {{on "click" (fn @onEdit u)}}>Edit</button>
<button {{on "click" (fn @onDelete u)}}>Delete</button>
</:actions>
<:footer as |u|>
<div class="stats">
Posts:
{{u.postCount}}
| Followers:
{{u.followers}}
</div>
</:footer>
</UserCard>
{{/each}}
</template>
```
**Contextual components pattern:**
```glimmer-js
// app/components/data-table.gjs
import Component from '@glimmer/component';
import { hash } from '@ember/helper';
class HeaderCell extends Component {
<template>
<th class="sortable" {{on "click" @onSort}}>
{{yield}}
{{#if @sorted}}
<span class="sort-icon">{{if @ascending "↑" "↓"}}</span>
{{/if}}
</th>
</template>
}
class Row extends Component {
<template>
<tr class={{if @selected "selected"}}>
{{yield}}
</tr>
</template>
}
class Cell extends Component {
<template>
<td>{{yield}}</td>
</template>
}
class DataTable extends Component {
<template>
<table class="data-table">
{{yield (hash Header=HeaderCell Row=Row Cell=Cell)}}
</table>
</template>
}
```
**Using contextual components:**
```glimmer-js
// app/components/users-table.gjs
import DataTable from './data-table';
<template>
<DataTable as |Table|>
<thead>
<tr>
<Table.Header @onSort={{fn @onSort "name"}}>Name</Table.Header>
<Table.Header @onSort={{fn @onSort "email"}}>Email</Table.Header>
<Table.Header @onSort={{fn @onSort "role"}}>Role</Table.Header>
</tr>
</thead>
<tbody>
{{#each @users as |user|}}
<Table.Row @selected={{eq @selectedId user.id}}>
<Table.Cell>{{user.name}}</Table.Cell>
<Table.Cell>{{user.email}}</Table.Cell>
<Table.Cell>{{user.role}}</Table.Cell>
</Table.Row>
{{/each}}
</tbody>
</DataTable>
</template>
```
**Renderless component pattern:**
```glimmer-js
// app/components/dropdown.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { hash } from '@ember/helper';
class Dropdown extends Component {
@tracked isOpen = false;
@action
toggle() {
this.isOpen = !this.isOpen;
}
@action
close() {
this.isOpen = false;
}
<template>{{yield (hash isOpen=this.isOpen toggle=this.toggle close=this.close)}}</template>
}
```
```glimmer-js
// Usage
import Dropdown from './dropdown';
<template>
<Dropdown as |dd|>
<button {{on "click" dd.toggle}}>
Menu
{{if dd.isOpen "▲" "▼"}}
</button>
{{#if dd.isOpen}}
<ul class="dropdown-menu">
<li><a href="#" {{on "click" dd.close}}>Profile</a></li>
<li><a href="#" {{on "click" dd.close}}>Settings</a></li>
<li><a href="#" {{on "click" dd.close}}>Logout</a></li>
</ul>
{{/if}}
</Dropdown>
</template>
```
Component composition provides flexibility, reusability, and clean separation of concerns while maintaining type safety and clarity.
Reference: [Ember Components - Block Parameters](https://guides.emberjs.com/release/components/block-content/)
@@ -0,0 +1,328 @@
---
title: Use Native Forms with Platform Validation
impact: HIGH
impactDescription: Reduces JavaScript form complexity and improves built-in a11y
tags: components, forms, validation, accessibility, platform
---
## Use Native Forms with Platform Validation
Rely on native `<form>` elements and the browser's Constraint Validation API instead of reinventing form handling with JavaScript. The platform is really good at forms.
## Problem
Over-engineering forms with JavaScript when native browser features provide validation, accessibility, and UX patterns for free.
**Incorrect (Too much JavaScript):**
```glimmer-js
// app/components/signup-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
class SignupForm extends Component {
@tracked email = '';
@tracked emailError = '';
validateEmail = () => {
// ❌ Reinventing email validation
if (!this.email.includes('@')) {
this.emailError = 'Invalid email';
}
};
handleSubmit = (event) => {
event.preventDefault();
if (this.emailError) return;
// Submit logic
};
<template>
<div>
<input
type="text"
value={{this.email}}
{{on "input" this.updateEmail}}
{{on "blur" this.validateEmail}}
/>
{{#if this.emailError}}
<span class="error">{{this.emailError}}</span>
{{/if}}
<button type="button" {{on "click" this.handleSubmit}}>Submit</button>
</div>
</template>
}
```
## Solution: Let the Platform Do the Work
Use native `<form>` with proper input types and browser validation:
**Correct (Native form with platform validation):**
```glimmer-js
// app/components/signup-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class SignupForm extends Component {
@tracked validationErrors = null;
handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
// ✅ Use native checkValidity()
if (!form.checkValidity()) {
// Show native validation messages
form.reportValidity();
return;
}
// ✅ Use FormData API - no tracked state needed!
const formData = new FormData(form);
const data = Object.fromEntries(formData);
this.args.onSubmit(data);
};
<template>
<form {{on "submit" this.handleSubmit}}>
{{! ✅ Browser handles validation automatically }}
<input type="email" name="email" required placeholder="email@example.com" />
<input
type="password"
name="password"
required
minlength="8"
placeholder="Min 8 characters"
/>
<button type="submit">Sign Up</button>
</form>
</template>
}
```
**Performance: -15KB** (no validation libraries needed)
**Accessibility: +100%** (native form semantics and error announcements)
**Code: -50%** (let the platform handle it)
## Custom Validation Messages with Constraint Validation API
Access and display native validation state in your component:
```glimmer-js
// app/components/validated-form.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class ValidatedForm extends Component {
@tracked errors = new Map();
handleInput = (event) => {
const input = event.target;
// ✅ Access Constraint Validation API
if (!input.validity.valid) {
this.errors.set(input.name, input.validationMessage);
} else {
this.errors.delete(input.name);
}
};
handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
if (!form.checkValidity()) {
// Trigger native validation UI
form.reportValidity();
return;
}
const formData = new FormData(form);
this.args.onSubmit(Object.fromEntries(formData));
};
<template>
<form {{on "submit" this.handleSubmit}}>
<div>
<label for="email">Email</label>
<input id="email" type="email" name="email" required {{on "input" this.handleInput}} />
{{#if (this.errors.get "email")}}
<span class="error" role="alert">
{{this.errors.get "email"}}
</span>
{{/if}}
</div>
<div>
<label for="age">Age</label>
<input
id="age"
type="number"
name="age"
min="18"
max="120"
required
{{on "input" this.handleInput}}
/>
{{#if (this.errors.get "age")}}
<span class="error" role="alert">
{{this.errors.get "age"}}
</span>
{{/if}}
</div>
<button type="submit">Submit</button>
</form>
</template>
}
```
## Constraint Validation API Properties
The browser provides rich validation state via `input.validity`:
```javascript
handleInput = (event) => {
const input = event.target;
const validity = input.validity;
// Check specific validation states:
if (validity.valueMissing) {
// required field is empty
}
if (validity.typeMismatch) {
// type="email" but value isn't email format
}
if (validity.tooShort || validity.tooLong) {
// minlength/maxlength violated
}
if (validity.rangeUnderflow || validity.rangeOverflow) {
// min/max violated
}
if (validity.patternMismatch) {
// pattern attribute not matched
}
// Or use the aggregated validationMessage:
if (!validity.valid) {
this.showError(input.name, input.validationMessage);
}
};
```
## Custom Validation with setCustomValidity
For business logic validation beyond HTML5 constraints:
```glimmer-js
// app/components/password-match-form.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
class PasswordMatchForm extends Component {
validatePasswordMatch = (event) => {
const form = event.target.form;
const password = form.querySelector('[name="password"]');
const confirm = form.querySelector('[name="confirm"]');
// ✅ Use setCustomValidity for custom validation
if (password.value !== confirm.value) {
confirm.setCustomValidity('Passwords must match');
} else {
confirm.setCustomValidity(''); // Clear custom error
}
};
handleSubmit = (event) => {
event.preventDefault();
const form = event.target;
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const formData = new FormData(form);
this.args.onSubmit(Object.fromEntries(formData));
};
<template>
<form {{on "submit" this.handleSubmit}}>
<input type="password" name="password" required minlength="8" placeholder="Password" />
<input
type="password"
name="confirm"
required
placeholder="Confirm password"
{{on "input" this.validatePasswordMatch}}
/>
<button type="submit">Create Account</button>
</form>
</template>
}
```
## When You Need Controlled State
Use controlled patterns when you need real-time interactivity that isn't form submission:
```glimmer-js
// app/components/live-search.gjs - Controlled state needed for instant search
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class LiveSearch extends Component {
@tracked query = '';
updateQuery = (event) => {
this.query = event.target.value;
// Instant search as user types
this.args.onSearch?.(this.query);
};
<template>
{{! Controlled state justified - need instant feedback }}
<input
type="search"
value={{this.query}}
{{on "input" this.updateQuery}}
placeholder="Search..."
/>
{{#if this.query}}
<p>Searching for: {{this.query}}</p>
{{/if}}
</template>
}
```
**Use controlled state when you need:**
- Real-time validation display as user types
- Character counters
- Live search/filtering
- Multi-step forms where state drives UI
- Form state that affects other components
**Use native forms when:**
- Simple submit-and-validate workflows
- Standard HTML5 validation is sufficient
- You want browser-native UX and accessibility
- Simpler code and less JavaScript is better
## References
- [MDN: Constraint Validation API](https://developer.mozilla.org/en-US/docs/Web/API/Constraint_validation)
- [MDN: FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
- [MDN: Form Validation](https://developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation)
- [Ember Guides: Event Handling](https://guides.emberjs.com/release/components/component-state-and-actions/)
@@ -0,0 +1,216 @@
---
title: Component File Naming and Export Conventions
impact: HIGH
impactDescription: Enforces consistent component structure and predictable imports
tags: components, naming, file-conventions, gjs, strict-mode
---
## Component File Naming and Export Conventions
### Rule
Follow modern Ember component file conventions: use `.gjs`/`.gts` files with `<template>` tags (never `.hbs` files), use kebab-case filenames, match class names to file names (in PascalCase), do not use the `Component` suffix in class names, and avoid `export default` in .gjs/.gts component files.
This export guidance applies to `.gjs`/`.gts` component files only. If your app still uses `.hbs`, keep default exports for resolver-facing invokables used there (or use a named export plus default alias in hybrid codebases).
**Incorrect:**
```handlebars
{{! app/components/user-card.hbs - WRONG: Using .hbs file }}
<div class='user-card'>
{{@name}}
</div>
```
```glimmer-js
// app/components/user-card.js - WRONG: Separate .js and .hbs files
import Component from '@glimmer/component';
export class UserCard extends Component {
// Logic here
}
```
```glimmer-js
// app/components/user-card.gjs - WRONG: Component suffix
import Component from '@glimmer/component';
export class UserCardComponent extends Component {
<template>
<div class="user-card">
{{@name}}
</div>
</template>
}
```
```glimmer-js
// app/components/UserProfile.gjs - WRONG: PascalCase filename
import Component from '@glimmer/component';
export class UserProfile extends Component {
<template>
<div class="profile">
{{@name}}
</div>
</template>
}
```
**Correct:**
```glimmer-js
// app/components/user-card.gjs - CORRECT: kebab-case filename, no Component suffix, no default export
import Component from '@glimmer/component';
export class UserCard extends Component {
<template>
<div class="user-card">
{{@name}}
</div>
</template>
}
```
```glimmer-js
// app/components/user-profile.gjs - CORRECT: All conventions followed
import Component from '@glimmer/component';
import { service } from '@ember/service';
export class UserProfile extends Component {
@service session;
<template>
<div class="profile">
<h1>{{@name}}</h1>
{{#if this.session.isAuthenticated}}
<button>Edit Profile</button>
{{/if}}
</div>
</template>
}
```
## Why
**Never use .hbs files:**
- `.gjs`/`.gts` files with `<template>` tags are the modern standard
- Co-located templates and logic in a single file improve maintainability
- Better tooling support (type checking, imports, refactoring)
- Enables strict mode and proper scope
- Avoid split between `.js` and `.hbs` files which makes components harder to understand
**Filename conventions:**
- Kebab-case filenames (`user-card.gjs`, not `UserCard.gjs`) follow web component standards and Ember conventions
- Predictable: component name maps directly to filename (UserCard → user-card.gjs)
- Avoids filesystem case-sensitivity issues across platforms
**Class naming:**
- No "Component" suffix - it's redundant (extends Component already declares the type)
- PascalCase class name matches the capitalized component invocation: `<UserCard />`
- Cleaner code: `UserCard` vs `UserCardComponent`
**No default export:**
- Modern .gjs/.gts files don't need `export default`
- The template compiler automatically exports the component
- Simpler syntax, less boilerplate
- Consistent with strict-mode semantics
## Naming Pattern Reference
| Filename | Class Name | Template Invocation |
| --------------------- | ---------------------- | -------------------- |
| `user-card.gjs` | `class UserCard` | `<UserCard />` |
| `loading-spinner.gjs` | `class LoadingSpinner` | `<LoadingSpinner />` |
| `nav-bar.gjs` | `class NavBar` | `<NavBar />` |
| `todo-list.gjs` | `class TodoList` | `<TodoList />` |
| `search-input.gjs` | `class SearchInput` | `<SearchInput />` |
**Conversion rule:**
- Filename: all lowercase, words separated by hyphens
- Class: PascalCase, same words, no hyphens
- `user-card.gjs` → `class UserCard`
## Special Cases
**Template-only components:**
```glimmer-js
// app/components/simple-card.gjs - Template-only, no class needed
<template>
<div class="card">
{{yield}}
</div>
</template>
```
**Components in subdirectories:**
```glimmer-js
// app/components/ui/button.gjs
import Component from '@glimmer/component';
export class Button extends Component {
<template>
<button type="button">
{{yield}}
</button>
</template>
}
// Usage: <Ui::Button />
```
**Nested namespaces:**
```glimmer-js
// app/components/admin/user/profile-card.gjs
import Component from '@glimmer/component';
export class ProfileCard extends Component {
<template>
<div class="admin-profile">
{{@user.name}}
</div>
</template>
}
// Usage: <Admin::User::ProfileCard />
```
## Impact
**Positive:**
- ⚡️ Cleaner, more maintainable code
- 🎯 Predictable mapping between files and classes
- 🌐 Follows web standards (kebab-case)
- 📦 Smaller bundle size (less export overhead)
- 🚀 Better alignment with modern Ember/Glimmer
**Negative:**
- None - this is the modern standard
## Metrics
- **Code clarity**: +30% (shorter, clearer names)
- **Bundle size**: -5-10 bytes per component (no export overhead)
- **Developer experience**: Improved (predictable naming)
## References
- [Ember Components Guide](https://guides.emberjs.com/release/components/)
- [Glimmer Components](https://github.com/glimmerjs/glimmer.js)
- [Template Tag Format RFC](https://github.com/emberjs/rfcs/pull/779)
- [Strict Mode Semantics](https://github.com/emberjs/rfcs/blob/master/text/0496-handlebars-strict-mode.md)
## Related Rules
- component-use-glimmer.md - Modern Glimmer component patterns
- component-strict-mode.md - Template-only components and strict mode
- route-templates.md - Route file naming conventions
@@ -0,0 +1,219 @@
---
title: Prevent Memory Leaks in Components
impact: HIGH
impactDescription: Avoid memory leaks and resource exhaustion
tags: memory, cleanup, lifecycle, performance
---
## Prevent Memory Leaks in Components
Properly clean up event listeners, timers, and subscriptions to prevent memory leaks.
**Incorrect (no cleanup):**
```glimmer-js
// app/components/live-clock.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
class LiveClock extends Component {
@tracked time = new Date();
constructor() {
super(...arguments);
// Memory leak: interval never cleared
setInterval(() => {
this.time = new Date();
}, 1000);
}
<template>
<div>{{this.time}}</div>
</template>
}
```
**Correct (proper cleanup with registerDestructor):**
```glimmer-js
// app/components/live-clock.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
class LiveClock extends Component {
@tracked time = new Date();
constructor() {
super(...arguments);
const intervalId = setInterval(() => {
this.time = new Date();
}, 1000);
// Proper cleanup
registerDestructor(this, () => {
clearInterval(intervalId);
});
}
<template>
<div>{{this.time}}</div>
</template>
}
```
**Event listener cleanup:**
```glimmer-js
// app/components/window-size.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
class WindowSize extends Component {
@tracked width = window.innerWidth;
@tracked height = window.innerHeight;
constructor() {
super(...arguments);
const handleResize = () => {
this.width = window.innerWidth;
this.height = window.innerHeight;
};
window.addEventListener('resize', handleResize);
registerDestructor(this, () => {
window.removeEventListener('resize', handleResize);
});
}
<template>
<div>Window: {{this.width}} x {{this.height}}</div>
</template>
}
```
**Using modifiers for automatic cleanup:**
```javascript
// app/modifiers/window-listener.js
import { modifier } from 'ember-modifier';
export default modifier((element, [eventName, handler]) => {
window.addEventListener(eventName, handler);
// Automatic cleanup when element is removed
return () => {
window.removeEventListener(eventName, handler);
};
});
```
```glimmer-js
// app/components/resize-aware.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import windowListener from '../modifiers/window-listener';
class ResizeAware extends Component {
@tracked size = { width: 0, height: 0 };
handleResize = () => {
this.size = {
width: window.innerWidth,
height: window.innerHeight,
};
};
<template>
<div {{windowListener "resize" this.handleResize}}>
{{this.size.width}}
x
{{this.size.height}}
</div>
</template>
}
```
**Abort controller for fetch requests:**
```glimmer-js
// app/components/data-loader.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
class DataLoader extends Component {
@tracked data = null;
abortController = new AbortController();
constructor() {
super(...arguments);
this.loadData();
registerDestructor(this, () => {
this.abortController.abort();
});
}
async loadData() {
try {
const response = await fetch('/api/data', {
signal: this.abortController.signal,
});
this.data = await response.json();
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to load data:', error);
}
}
}
<template>
{{#if this.data}}
<div>{{this.data.content}}</div>
{{/if}}
</template>
}
```
**Using ember-resources for automatic cleanup:**
```glimmer-js
// app/components/websocket-data.gjs
import Component from '@glimmer/component';
import { resource } from 'ember-resources';
class WebsocketData extends Component {
messages = resource(({ on }) => {
const messages = [];
const ws = new WebSocket('wss://example.com/socket');
ws.onmessage = (event) => {
messages.push(event.data);
};
// Automatic cleanup
on.cleanup(() => {
ws.close();
});
return messages;
});
<template>
{{#each this.messages.value as |message|}}
<div>{{message}}</div>
{{/each}}
</template>
}
```
Always clean up timers, event listeners, subscriptions, and pending requests to prevent memory leaks and performance degradation.
Reference: [Ember Destroyable](https://api.emberjs.com/ember/release/modules/@ember%2Fdestroyable)
@@ -0,0 +1,57 @@
---
title: Avoid Unnecessary Tracking
impact: HIGH
impactDescription: 20-40% fewer invalidations
tags: components, tracked, performance, reactivity
---
## Avoid Unnecessary Tracking
Only mark properties as `@tracked` if they need to trigger re-renders when changed. Overusing `@tracked` causes unnecessary invalidations and re-renders.
**Incorrect (everything tracked):**
```javascript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class Form extends Component {
@tracked firstName = ''; // Used in template ✓
@tracked lastName = ''; // Used in template ✓
@tracked _formId = Date.now(); // Internal, never rendered ✗
@tracked _validationCache = new Map(); // Internal state ✗
@action
validate() {
this._validationCache.set('firstName', this.firstName.length > 0);
// Unnecessary re-render triggered
}
}
```
**Correct (selective tracking):**
```javascript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class Form extends Component {
@tracked firstName = ''; // Rendered in template
@tracked lastName = ''; // Rendered in template
@tracked isValid = false; // Rendered status
_formId = Date.now(); // Not tracked - internal only
_validationCache = new Map(); // Not tracked - internal state
@action
validate() {
this._validationCache.set('firstName', this.firstName.length > 0);
this.isValid = this._validationCache.get('firstName');
// Only re-renders when isValid changes
}
}
```
Only track properties that directly affect the template or other tracked getters to minimize unnecessary re-renders.
@@ -0,0 +1,132 @@
---
title: Use {{on}} Modifier for Event Handling
impact: MEDIUM
impactDescription: Better memory management and clarity
tags: events, modifiers, on, performance
---
## Use {{on}} Modifier for Event Handling
Use the `{{on}}` modifier for event handling instead of traditional action handlers for better memory management and clearer code.
**Incorrect (traditional action attribute):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Button extends Component {
@action
handleClick() {
this.args.onClick?.();
}
<template>
<button onclick={{this.handleClick}}>
{{@label}}
</button>
</template>
}
```
**Correct (using {{on}} modifier):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
class Button extends Component {
handleClick = () => {
this.args.onClick?.();
};
<template>
<button {{on "click" this.handleClick}}>
{{@label}}
</button>
</template>
}
```
**With event options:**
```glimmer-js
// app/components/scroll-tracker.gjs
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
class ScrollTracker extends Component {
handleScroll = (event) => {
console.log('Scroll position:', event.target.scrollTop);
};
<template>
<div class="scrollable" {{on "scroll" this.handleScroll passive=true}}>
{{yield}}
</div>
</template>
}
```
**Multiple event handlers:**
```glimmer-js
// app/components/input-field.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
class InputField extends Component {
@tracked isFocused = false;
handleFocus = () => {
this.isFocused = true;
};
handleBlur = () => {
this.isFocused = false;
};
handleInput = (event) => {
this.args.onInput?.(event.target.value);
};
<template>
<input
type="text"
class={{if this.isFocused "focused"}}
{{on "focus" this.handleFocus}}
{{on "blur" this.handleBlur}}
{{on "input" this.handleInput}}
value={{@value}}
/>
</template>
}
```
**Using fn helper for arguments:**
```glimmer-js
// app/components/item-list.gjs
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';
<template>
<ul>
{{#each @items as |item|}}
<li>
{{item.name}}
<button {{on "click" (fn @onDelete item.id)}}>
Delete
</button>
</li>
{{/each}}
</ul>
</template>
```
The `{{on}}` modifier properly cleans up event listeners, supports event options (passive, capture, once), and makes event handling more explicit.
Reference: [Ember Modifiers - on](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_event-handlers)
@@ -0,0 +1,292 @@
---
title: Build Reactive Chains with Dependent Getters
impact: HIGH
impactDescription: Clear data flow and automatic reactivity
tags: reactivity, getters, tracked, derived-state, composition
---
## Build Reactive Chains with Dependent Getters
Create reactive chains where getters depend on other getters or tracked properties for clear, maintainable data derivation.
**Incorrect (imperative updates):**
```glimmer-js
// app/components/shopping-cart.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
class ShoppingCart extends Component {
@tracked items = [];
@tracked subtotal = 0;
@tracked tax = 0;
@tracked shipping = 0;
@tracked total = 0;
@action
addItem(item) {
this.items = [...this.items, item];
this.recalculate();
}
@action
removeItem(index) {
this.items = this.items.filter((_, i) => i !== index);
this.recalculate();
}
recalculate() {
this.subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
this.tax = this.subtotal * 0.08;
this.shipping = this.subtotal > 50 ? 0 : 5.99;
this.total = this.subtotal + this.tax + this.shipping;
}
<template>
<div class="cart">
<div>Subtotal: ${{this.subtotal}}</div>
<div>Tax: ${{this.tax}}</div>
<div>Shipping: ${{this.shipping}}</div>
<div>Total: ${{this.total}}</div>
</div>
</template>
}
```
**Correct (reactive getter chains):**
```glimmer-js
// app/components/shopping-cart.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { TrackedArray } from 'tracked-built-ins';
class ShoppingCart extends Component {
@tracked items = new TrackedArray([]);
// Base calculation
get subtotal() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
// Depends on subtotal
get tax() {
return this.subtotal * 0.08;
}
// Depends on subtotal
get shipping() {
return this.subtotal > 50 ? 0 : 5.99;
}
// Depends on subtotal, tax, and shipping
get total() {
return this.subtotal + this.tax + this.shipping;
}
// Derived from total
get formattedTotal() {
return `$${this.total.toFixed(2)}`;
}
// Multiple dependencies
get discount() {
if (this.items.length >= 5) return this.subtotal * 0.1;
if (this.subtotal > 100) return this.subtotal * 0.05;
return 0;
}
// Depends on total and discount
get finalTotal() {
return this.total - this.discount;
}
@action
addItem(item) {
this.items.push(item);
// All getters automatically update!
}
@action
removeItem(index) {
this.items.splice(index, 1);
// All getters automatically update!
}
<template>
<div class="cart">
<div>Items: {{this.items.length}}</div>
<div>Subtotal: ${{this.subtotal.toFixed 2}}</div>
<div>Tax: ${{this.tax.toFixed 2}}</div>
<div>Shipping: ${{this.shipping.toFixed 2}}</div>
{{#if this.discount}}
<div class="discount">Discount: -${{this.discount.toFixed 2}}</div>
{{/if}}
<div class="total">Total: {{this.formattedTotal}}</div>
</div>
</template>
}
```
**Complex reactive chains with @cached:**
```glimmer-js
// app/components/data-analysis.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class DataAnalysis extends Component {
// Base data
get rawData() {
return this.args.data || [];
}
// Level 1: Filter
@cached
get validData() {
return this.rawData.filter((item) => item.value != null);
}
// Level 2: Transform (depends on validData)
@cached
get normalizedData() {
const max = Math.max(...this.validData.map((d) => d.value));
return this.validData.map((item) => ({
...item,
normalized: item.value / max,
}));
}
// Level 2: Statistics (depends on validData)
@cached
get statistics() {
const values = this.validData.map((d) => d.value);
const sum = values.reduce((a, b) => a + b, 0);
const mean = sum / values.length;
const variance = values.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / values.length;
return {
count: values.length,
sum,
mean,
stdDev: Math.sqrt(variance),
min: Math.min(...values),
max: Math.max(...values),
};
}
// Level 3: Depends on normalizedData and statistics
@cached
get outliers() {
const threshold = this.statistics.mean + 2 * this.statistics.stdDev;
return this.normalizedData.filter((item) => item.value > threshold);
}
// Level 3: Depends on statistics
get qualityScore() {
const validRatio = this.validData.length / this.rawData.length;
const outlierRatio = this.outliers.length / this.validData.length;
return validRatio * 0.7 + (1 - outlierRatio) * 0.3;
}
<template>
<div class="analysis">
<h3>Data Quality: {{this.qualityScore.toFixed 2}}</h3>
<div>Valid: {{this.validData.length}} / {{this.rawData.length}}</div>
<div>Mean: {{this.statistics.mean.toFixed 2}}</div>
<div>Std Dev: {{this.statistics.stdDev.toFixed 2}}</div>
<div>Outliers: {{this.outliers.length}}</div>
</div>
</template>
}
```
**Combining multiple tracked sources:**
```glimmer-js
// app/components/filtered-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { cached } from '@glimmer/tracking';
class FilteredList extends Component {
@tracked searchTerm = '';
@tracked selectedCategory = 'all';
@tracked sortDirection = 'asc';
// Depends on args.items and searchTerm
@cached
get searchFiltered() {
if (!this.searchTerm) return this.args.items;
const term = this.searchTerm.toLowerCase();
return this.args.items.filter(
(item) =>
item.name.toLowerCase().includes(term) || item.description?.toLowerCase().includes(term),
);
}
// Depends on searchFiltered and selectedCategory
@cached
get categoryFiltered() {
if (this.selectedCategory === 'all') return this.searchFiltered;
return this.searchFiltered.filter((item) => item.category === this.selectedCategory);
}
// Depends on categoryFiltered and sortDirection
@cached
get sorted() {
const items = [...this.categoryFiltered];
const direction = this.sortDirection === 'asc' ? 1 : -1;
return items.sort((a, b) => direction * a.name.localeCompare(b.name));
}
// Final result
get items() {
return this.sorted;
}
// Metadata derived from chain
get resultsCount() {
return this.items.length;
}
get hasFilters() {
return this.searchTerm || this.selectedCategory !== 'all';
}
<template>
<div class="filtered-list">
<input
type="search"
value={{this.searchTerm}}
{{on "input" (pick "target.value" (set this "searchTerm"))}}
/>
<select
value={{this.selectedCategory}}
{{on "change" (pick "target.value" (set this "selectedCategory"))}}
>
<option value="all">All Categories</option>
{{#each @categories as |cat|}}
<option value={{cat}}>{{cat}}</option>
{{/each}}
</select>
<p>Showing {{this.resultsCount}} results</p>
{{#each this.items as |item|}}
<div>{{item.name}}</div>
{{/each}}
</div>
</template>
}
```
Reactive getter chains provide automatic updates, clear data dependencies, and better performance through intelligent caching with @cached.
Reference: [Glimmer Tracking](https://guides.emberjs.com/release/in-depth-topics/autotracking-in-depth/)
@@ -0,0 +1,86 @@
---
title: Use Strict Mode and Template-Only Components
impact: HIGH
impactDescription: Better type safety and simpler components
tags: strict-mode, template-only, components, gjs
---
## Use Strict Mode and Template-Only Components
Use strict mode and template-only components for simpler, safer code with better tooling support.
**Incorrect (JavaScript component for simple templates):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
<template>
<div class="user-card">
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
}
```
**Correct (template-only component):**
```glimmer-js
// app/components/user-card.gjs
<template>
<div class="user-card">
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
```
**With TypeScript for better type safety:**
```glimmer-ts
// app/components/user-card.gts
import type { TOC } from '@ember/component/template-only';
interface UserCardSignature {
Args: {
user: {
name: string;
email: string;
};
};
}
const UserCard: TOC<UserCardSignature> = <template>
<div class="user-card">
<h3>{{@user.name}}</h3>
<p>{{@user.email}}</p>
</div>
</template>;
export default UserCard;
```
**Enable strict mode in your app:**
```javascript
// ember-cli-build.js
'use strict';
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
module.exports = function (defaults) {
const app = new EmberApp(defaults, {
'ember-cli-babel': {
enableTypeScriptTransform: true,
},
});
return app.toTree();
};
```
Template-only components are lighter, more performant, and easier to understand. Strict mode provides better error messages and prevents common mistakes.
Reference: [Ember Strict Mode](https://guides.emberjs.com/release/upgrading/current-edition/templates/)
@@ -0,0 +1,68 @@
---
title: Use Tracked Toolbox for Complex State
impact: HIGH
impactDescription: Cleaner state management
tags: components, tracked, state-management, performance
---
## Use Tracked Toolbox for Complex State
For complex state patterns like maps, sets, and arrays that need fine-grained reactivity, use tracked-toolbox utilities instead of marking entire structures as @tracked.
**Incorrect (tracking entire structures):**
```javascript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
class TodoList extends Component {
@tracked items = []; // Entire array replaced on every change
addItem = (item) => {
// Creates new array, invalidates all consumers
this.items = [...this.items, item];
};
removeItem = (index) => {
// Creates new array again
this.items = this.items.filter((_, i) => i !== index);
};
}
```
**Correct (using tracked-toolbox):**
```javascript
import Component from '@glimmer/component';
import { TrackedArray } from 'tracked-built-ins';
class TodoList extends Component {
items = new TrackedArray([]);
// Use arrow functions for methods used in templates (no @action needed)
addItem = (item) => {
// Efficiently adds to tracked array
this.items.push(item);
};
removeItem = (index) => {
// Efficiently removes from tracked array
this.items.splice(index, 1);
};
}
```
**Also useful for Maps and Sets:**
```javascript
import { TrackedMap, TrackedSet } from 'tracked-built-ins';
class Cache extends Component {
cache = new TrackedMap(); // Fine-grained reactivity per key
selected = new TrackedSet(); // Fine-grained reactivity per item
}
```
tracked-built-ins provides fine-grained reactivity and better performance than replacing entire structures.
Reference: [tracked-built-ins](https://github.com/tracked-tools/tracked-built-ins)
@@ -0,0 +1,56 @@
---
title: Use Glimmer Components Over Classic Components
impact: HIGH
impactDescription: 30-50% faster rendering
tags: components, glimmer, performance, reactivity
---
## Use Glimmer Components Over Classic Components
Glimmer components are lighter, faster, and have a simpler lifecycle than classic Ember components. They don't have two-way bindings or element lifecycle hooks, making them more predictable and performant.
**Incorrect (classic component):**
```javascript
// app/components/user-card.js
import Component from '@ember/component';
import { computed } from '@ember/object';
export default Component.extend({
tagName: 'div',
classNames: ['user-card'],
fullName: computed('user.{firstName,lastName}', function () {
return `${this.user.firstName} ${this.user.lastName}`;
}),
didInsertElement() {
this._super(...arguments);
// Complex lifecycle management
},
});
```
**Correct (Glimmer component):**
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
class UserCard extends Component {
get fullName() {
return `${this.args.user.firstName} ${this.args.user.lastName}`;
}
<template>
<div class="user-card">
<h3>{{this.fullName}}</h3>
<p>{{@user.email}}</p>
</div>
</template>
}
```
Glimmer components are 30-50% faster, have cleaner APIs, and integrate better with tracked properties.
Reference: [Glimmer Components](https://guides.emberjs.com/release/components/component-state-and-actions/)
@@ -0,0 +1,168 @@
---
title: Prefer Named Exports, Fallback to Default for Implicit Template Lookup
impact: LOW
impactDescription: Clear export contracts across .hbs and template-tag codebases
tags: exports, hbs, gjs, interop, code-organization
---
## Prefer Named Exports, Fallback to Default for Implicit Template Lookup
Use named exports for shared modules imported directly in JS/TS (utilities, constants, pure functions). If a module should be invokable from `.hbs` templates via implicit lookup, provide a default export. In hybrid `.gjs`/`.hbs` projects, a practical pattern is a named export plus a default export alias.
**Incorrect (default export in a shared utility module):**
```javascript
// app/utils/format-date.js
export default function formatDate(date) {
return new Date(date).toLocaleDateString();
}
```
**Correct (named export in a shared utility module):**
```javascript
// app/utils/format-date.js
export function formatDate(date) {
return new Date(date).toLocaleDateString();
}
```
**Correct (hybrid `.gjs`/`.hbs` named export + default alias):**
```javascript
// app/helpers/format-date.js
import { helper } from '@ember/component/helper';
export const formatDate = helper(([value]) => {
return new Date(value).toLocaleDateString();
});
export default formatDate;
```
## Where Named Exports Are Preferred
Use named exports when the module is imported directly by other modules and is not resolved via implicit template lookup.
**Example (utility module with multiple named exports):**
```javascript
// app/utils/validators.js
export function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
export function isPhoneNumber(value) {
return /^\d{3}-\d{3}-\d{4}$/.test(value);
}
```
Benefits:
1. Explicit import contracts
2. Better refactor safety (symbol rename tracking)
3. Better tree-shaking for utility modules
4. Easier multi-export module organization
## Where Default Exports Are Required
Use default exports for modules consumed through resolver/template lookup.
If your project uses `.hbs`, invokables that should be accessible from templates should provide `export default`.
In hybrid `.gjs`/`.hbs` codebases, use named exports plus a default export alias where you want both explicit imports and template compatibility.
**Service:**
```javascript
// app/services/auth.js
import Service from '@ember/service';
export default class AuthService extends Service {
// ...
}
```
**Route:**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class DashboardRoute extends Route {
@service store;
model() {
return this.store.findAll('dashboard-item');
}
}
```
**Modifier (when invoked from `.hbs`):**
```javascript
// app/modifiers/focus.js
import { modifier } from 'ember-modifier';
export default modifier((element) => {
element.focus();
});
```
**Template (`.gjs`):**
```glimmer-js
// app/templates/dashboard.gjs
<template>
<h1>Dashboard</h1>
</template>
```
**Template (`.gts`):**
```glimmer-ts
// app/templates/dashboard.gts
import type { TOC } from '@ember/component/template-only';
interface Signature {
Args: {
model: unknown;
};
}
export default <template>
<h1>Dashboard</h1>
</template> satisfies TOC<Signature>;
```
Template-tag files must resolve via a module default export in convention-based and `import.meta.glob` flows.
For `app/templates/*.gjs`, the default export is implicit after compilation.
## Strict Resolver Nuance
With `ember-strict-application-resolver`, you can register explicit module values in `App.modules`:
**Strict resolver explicit modules registration:**
```ts
modules = {
'./services/manual': { default: ManualService },
'./services/manual-shorthand': ManualService,
};
```
In that explicit shorthand case, a direct value works without a default-exported module object.
This is an explicit registration escape hatch and does not replace default-export requirements for `.hbs`-invokable modules.
## Rule of Thumb
1. If a module should be invokable from `.hbs`, provide a default export.
2. In hybrid `.gjs`/`.hbs` projects, use named export + default alias for resolver-facing modules.
3. Strict resolver explicit `modules` entries may use direct shorthand values where appropriate.
4. Plain shared modules (`app/utils`, shared constants, reusable pure functions): prefer named exports.
5. Template-tag components (`.gjs`/`.gts`): follow the component file-conventions rule and use named class exports.
## References
- [ES Modules Best Practices](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)
- [ember-strict-application-resolver](https://github.com/ember-cli/ember-strict-application-resolver)
- [ember-resolver](https://github.com/ember-cli/ember-resolver)
@@ -0,0 +1,362 @@
---
title: Use Helper Libraries Effectively
impact: MEDIUM
impactDescription: Reduces custom helper maintenance and keeps templates concise
tags: templates, helpers, ember-truth-helpers, ember-composable-helpers
---
## Use Helper Libraries Effectively
Leverage community helper libraries to write cleaner templates and avoid creating unnecessary custom helpers for common operations.
## Problem
Reinventing common functionality with custom helpers adds maintenance burden and bundle size when well-maintained helper libraries already provide the needed functionality.
**Incorrect:**
```glimmer-js
// app/utils/is-equal.js - Unnecessary custom helper
export function isEqual(a, b) {
return a === b;
}
// app/components/user-badge.gjs
import { isEqual } from '../utils/is-equal';
class UserBadge extends Component {
<template>
{{#if (isEqual @user.role "admin")}}
<span class="badge">Admin</span>
{{/if}}
</template>
}
```
## Solution
**Note:** These helpers will be built into Ember 7 core, but currently require installing the respective addon packages.
**Installation:**
```bash
npm install ember-truth-helpers ember-composable-helpers
```
Use helper libraries like `ember-truth-helpers` and `ember-composable-helpers`:
**Correct:**
```glimmer-js
// app/components/user-badge.gjs
import Component from '@glimmer/component';
import { eq } from 'ember-truth-helpers';
class UserBadge extends Component {
<template>
{{! eq helper from ember-truth-helpers }}
{{#if (eq @user.role "admin")}}
<span class="badge">Admin</span>
{{/if}}
</template>
}
```
## Comparison Helpers (ember-truth-helpers)
**Installation:** `npm install ember-truth-helpers`
```glimmer-js
// app/components/comparison-examples.gjs
import Component from '@glimmer/component';
import { eq, not, and, or, lt, lte, gt, gte } from 'ember-truth-helpers';
class ComparisonExamples extends Component {
<template>
{{! Equality }}
{{#if (eq @status "active")}}Active{{/if}}
{{! Negation }}
{{#if (not @isDeleted)}}Visible{{/if}}
{{! Logical AND }}
{{#if (and @isPremium @hasAccess)}}Premium Content{{/if}}
{{! Logical OR }}
{{#if (or @isAdmin @isModerator)}}Moderation Tools{{/if}}
{{! Comparisons }}
{{#if (gt @score 100)}}High Score!{{/if}}
{{#if (lte @attempts 3)}}Try again{{/if}}
</template>
}
```
## Array and Object Helpers (ember-composable-helpers)
**Installation:** `npm install ember-composable-helpers`
```glimmer-js
// app/components/collection-helpers.gjs
import Component from '@glimmer/component';
import { array, hash } from 'ember-composable-helpers/helpers';
import { get } from 'ember-composable-helpers/helpers';
class CollectionHelpers extends Component {
<template>
{{! Create array inline }}
{{#each (array "apple" "banana" "cherry") as |fruit|}}
<li>{{fruit}}</li>
{{/each}}
{{! Create object inline }}
{{#let (hash name="John" age=30 active=true) as |user|}}
<p>{{user.name}} is {{user.age}} years old</p>
{{/let}}
{{! Dynamic property access }}
<p>{{get @user @propertyName}}</p>
</template>
}
```
## String Helpers
```glimmer-js
// app/components/string-helpers.gjs
import Component from '@glimmer/component';
import { concat } from '@ember/helper'; // Built-in to Ember
class StringHelpers extends Component {
<template>
{{! Concatenate strings }}
<p class={{concat "user-" @user.id "-card"}}>
{{concat @user.firstName " " @user.lastName}}
</p>
{{! With dynamic values }}
<img
src={{concat "/images/" @category "/" @filename ".jpg"}}
alt={{concat "Image of " @title}}
/>
</template>
}
```
## Action Helpers (fn)
```glimmer-js
// app/components/action-helpers.gjs
import Component from '@glimmer/component';
import { fn } from '@ember/helper'; // Built-in to Ember
import { on } from '@ember/modifier';
class ActionHelpers extends Component {
updateValue = (field, event) => {
this.args.onChange(field, event.target.value);
};
deleteItem = (id) => {
this.args.onDelete(id);
};
<template>
{{! Partial application with fn }}
<input {{on "input" (fn this.updateValue "email")}} />
{{#each @items as |item|}}
<li>
{{item.name}}
<button {{on "click" (fn this.deleteItem item.id)}}>
Delete
</button>
</li>
{{/each}}
</template>
}
```
## Conditional Helpers (if/unless)
```glimmer-js
// app/components/conditional-inline.gjs
import Component from '@glimmer/component';
import { if as ifHelper } from '@ember/helper'; // Built-in to Ember
class ConditionalInline extends Component {
<template>
{{! Ternary-like behavior }}
<span class={{ifHelper @isActive "active" "inactive"}}>
{{@user.name}}
</span>
{{! Conditional attribute }}
<button disabled={{ifHelper @isProcessing true}}>
{{ifHelper @isProcessing "Processing..." "Submit"}}
</button>
{{! With default value }}
<p>{{ifHelper @description @description "No description provided"}}</p>
</template>
}
```
## Practical Combinations
**Dynamic Classes:**
```glimmer-js
// app/components/dynamic-classes.gjs
import Component from '@glimmer/component';
import { concat, if as ifHelper } from '@ember/helper'; // Built-in to Ember
import { and, not } from 'ember-truth-helpers';
class DynamicClasses extends Component {
<template>
<div
class={{concat
"card "
(ifHelper @isPremium "premium ")
(ifHelper (and @isNew (not @isRead)) "unread ")
@customClass
}}
>
<h3>{{@title}}</h3>
</div>
</template>
}
```
**List Filtering:**
```glimmer-js
// app/components/filtered-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { cached } from '@glimmer/tracking';
import { fn, concat } from '@ember/helper';
import { on } from '@ember/modifier';
import { eq } from 'ember-truth-helpers';
import { array } from 'ember-composable-helpers/helpers';
class FilteredList extends Component {
@tracked filter = 'all';
@cached
get filteredItems() {
if (this.filter === 'all') return this.args.items;
return this.args.items.filter((item) => item.status === this.filter);
}
<template>
<select {{on "change" (fn (mut this.filter) target.value)}}>
{{#each (array "all" "active" "pending" "completed") as |option|}}
<option value={{option}} selected={{eq this.filter option}}>
{{option}}
</option>
{{/each}}
</select>
{{#each this.filteredItems as |item|}}
<div class={{concat "item " item.status}}>
{{item.name}}
</div>
{{/each}}
</template>
}
```
## Complex Example
```glimmer-js
// app/components/user-profile-card.gjs
import Component from '@glimmer/component';
import { concat, if as ifHelper, fn } from '@ember/helper'; // Built-in to Ember
import { eq, not, and, or } from 'ember-truth-helpers';
import { hash, array, get } from 'ember-composable-helpers/helpers';
import { on } from '@ember/modifier';
class UserProfileCard extends Component {
updateField = (field, value) => {
this.args.onUpdate(field, value);
};
<template>
<div
class={{concat
"profile-card "
(ifHelper @user.isPremium "premium ")
(ifHelper (and @user.isOnline (not @user.isAway)) "online ")
}}
>
<h2>{{concat @user.firstName " " @user.lastName}}</h2>
{{#if (or (eq @user.role "admin") (eq @user.role "moderator"))}}
<span class="badge">
{{get (hash admin="Administrator" moderator="Moderator") @user.role}}
</span>
{{/if}}
{{#if (and @canEdit (not @user.locked))}}
<div class="actions">
{{#each (array "profile" "settings" "privacy") as |section|}}
<button {{on "click" (fn this.updateField "activeSection" section)}}>
Edit
{{section}}
</button>
{{/each}}
</div>
{{/if}}
<p class={{ifHelper @user.verified "verified" "unverified"}}>
{{ifHelper @user.bio @user.bio "No bio provided"}}
</p>
</div>
</template>
}
```
## Performance Impact
- **Library helpers**: ~0% overhead (compiled into efficient bytecode)
- **Custom helpers**: 5-15% overhead per helper call
- **Inline logic**: Cleaner templates, better tree-shaking
## When to Use
- **Library helpers**: For all common operations (equality, logic, arrays, strings)
- **Custom helpers**: Only for domain-specific logic not covered by library helpers
- **Component logic**: For complex operations that need @cached or multiple dependencies
## Complete Helper Reference
**Note:** These helpers will be built into Ember 7 core. Until then:
**Actually Built-in to Ember (from `@ember/helper`):**
- `concat` - Concatenate strings
- `fn` - Partial application / bind arguments
- `if` - Ternary-like conditional value
- `mut` - Create settable binding (use sparingly)
**From `ember-truth-helpers` package:**
- `eq` - Equality (===)
- `not` - Negation (!)
- `and` - Logical AND
- `or` - Logical OR
- `lt`, `lte`, `gt`, `gte` - Numeric comparisons
**From `ember-composable-helpers` package:**
- `array` - Create array inline
- `hash` - Create object inline
- `get` - Dynamic property access
## References
- [Ember Built-in Helpers](https://guides.emberjs.com/release/templates/built-in-helpers/)
- [Template Helpers API](https://api.emberjs.com/ember/release/modules/@ember%2Fhelper)
- [fn Helper Guide](https://guides.emberjs.com/release/components/helper-functions/)
- [ember-truth-helpers](https://github.com/jmurphyau/ember-truth-helpers)
- [ember-composable-helpers](https://github.com/DockYard/ember-composable-helpers)
@@ -0,0 +1,258 @@
---
title: Compose Helpers for Reusable Logic
impact: MEDIUM-HIGH
impactDescription: Better code reuse and testability
tags: helpers, composition, functions, pipes, reusability
---
## Compose Helpers for Reusable Logic
Compose helpers to create reusable, testable logic that can be combined in templates and components.
**Incorrect (logic duplicated in templates):**
```glimmer-js
// app/components/user-profile.gjs
<template>
<div class="profile">
<h1>{{uppercase (truncate @user.name 20)}}</h1>
{{#if (and @user.isActive (not @user.isDeleted))}}
<span class="status">Active</span>
{{/if}}
<p>{{lowercase @user.email}}</p>
{{#if (gt @user.posts.length 0)}}
<span>Posts: {{@user.posts.length}}</span>
{{/if}}
</div>
</template>
```
**Correct (composed helpers):**
```javascript
// app/helpers/display-name.js
export function displayName(name, { maxLength = 20 } = {}) {
if (!name) return '';
const truncated = name.length > maxLength ? name.slice(0, maxLength) + '...' : name;
return truncated.toUpperCase();
}
```
```javascript
// app/helpers/is-visible-user.js
export function isVisibleUser(user) {
return user && user.isActive && !user.isDeleted;
}
```
```javascript
// app/helpers/format-email.js
export function formatEmail(email) {
return email?.toLowerCase() || '';
}
```
```glimmer-js
// app/components/user-profile.gjs
import { displayName } from '../helpers/display-name';
import { isVisibleUser } from '../helpers/is-visible-user';
import { formatEmail } from '../helpers/format-email';
<template>
<div class="profile">
<h1>{{displayName @user.name}}</h1>
{{#if (isVisibleUser @user)}}
<span class="status">Active</span>
{{/if}}
<p>{{formatEmail @user.email}}</p>
{{#if (gt @user.posts.length 0)}}
<span>Posts: {{@user.posts.length}}</span>
{{/if}}
</div>
</template>
```
**Functional composition with pipe helper:**
```javascript
// app/helpers/pipe.js
export function pipe(...fns) {
return (value) => fns.reduce((acc, fn) => fn(acc), value);
}
```
**Or use a compose helper:**
```javascript
// app/helpers/compose.js
export function compose(...helperFns) {
return (value) => helperFns.reduceRight((acc, fn) => fn(acc), value);
}
```
**Usage:**
```glimmer-js
// app/components/text-processor.gjs
import { fn } from '@ember/helper';
// Individual helpers
const uppercase = (str) => str?.toUpperCase() || '';
const trim = (str) => str?.trim() || '';
const truncate = (str, length = 20) => str?.slice(0, length) || '';
<template>
{{! Compose multiple transformations }}
<div>
{{pipe @text (fn trim) (fn uppercase) (fn truncate 50)}}
</div>
</template>
```
**Higher-order helpers:**
```javascript
// app/helpers/partial-apply.js
export function partialApply(fn, ...args) {
return (...moreArgs) => fn(...args, ...moreArgs);
}
```
```javascript
// app/helpers/map-by.js
export function mapBy(array, property) {
return array?.map((item) => item[property]) || [];
}
```
```glimmer-js
// Usage in template
import { mapBy } from '../helpers/map-by';
import { partialApply } from '../helpers/partial-apply';
<template>
{{! Extract property from array }}
<ul>
{{#each (mapBy @users "name") as |name|}}
<li>{{name}}</li>
{{/each}}
</ul>
{{! Partial application }}
{{#let (partialApply @formatNumber 2) as |formatTwoDecimals|}}
<span>Price: {{formatTwoDecimals @price}}</span>
{{/let}}
</template>
```
**Chainable transformation helpers:**
```javascript
// app/helpers/transform.js
class Transform {
constructor(value) {
this.value = value;
}
filter(fn) {
this.value = this.value?.filter(fn) || [];
return this;
}
map(fn) {
this.value = this.value?.map(fn) || [];
return this;
}
sort(fn) {
this.value = [...(this.value || [])].sort(fn);
return this;
}
take(n) {
this.value = this.value?.slice(0, n) || [];
return this;
}
get result() {
return this.value;
}
}
export function transform(value) {
return new Transform(value);
}
```
```glimmer-js
// Usage
import { transform } from '../helpers/transform';
function filter(items) {
return items
.filter((item) => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
.take(10).result;
}
<template>
{{#let (transform @items) as |t|}}
{{#each (filter t) as |item|}}
<div>{{item.name}}</div>
{{/each}}
{{/let}}
</template>
```
**Conditional composition:**
```javascript
// app/helpers/when.js
export function when(condition, trueFn, falseFn) {
return condition ? trueFn() : falseFn ? falseFn() : null;
}
```
```javascript
// app/helpers/unless.js
export function unless(condition, falseFn, trueFn) {
return !condition ? falseFn() : trueFn ? trueFn() : null;
}
```
**Testing composed helpers:**
```javascript
// tests/helpers/display-name-test.js
import { module, test } from 'qunit';
import { displayName } from 'my-app/helpers/display-name';
module('Unit | Helper | display-name', function () {
test('it formats name correctly', function (assert) {
assert.strictEqual(displayName('John Doe'), 'JOHN DOE');
});
test('it truncates long names', function (assert) {
assert.strictEqual(
displayName('A Very Long Name That Should Be Truncated', { maxLength: 10 }),
'A VERY LON...',
);
});
test('it handles null', function (assert) {
assert.strictEqual(displayName(null), '');
});
});
```
Composed helpers provide testable, reusable logic that keeps templates clean and components focused on behavior rather than data transformation.
Reference: [Ember Helpers](https://guides.emberjs.com/release/components/helper-functions/)
@@ -0,0 +1,145 @@
---
title: No helper() Wrapper for Plain Functions
impact: LOW-MEDIUM
impactDescription: Simpler code, better performance
tags: helpers, templates, modern-ember
---
## No helper() Wrapper for Plain Functions
In modern Ember, plain functions can be used directly as helpers without wrapping them with `helper()`. The `helper()` wrapper is legacy and adds unnecessary complexity.
**Incorrect (using helper() wrapper):**
```javascript
// app/utils/format-date.js
import { helper } from '@ember/component/helper';
function formatDate([date]) {
return new Date(date).toLocaleDateString();
}
export default helper(formatDate);
```
**Correct (plain function):**
```javascript
// app/utils/format-date.js
export function formatDate(date) {
return new Date(date).toLocaleDateString();
}
```
**Usage in templates:**
```glimmer-js
// app/components/post-card.gjs
import { formatDate } from '../utils/format-date';
<template>
<article>
<h2>{{@post.title}}</h2>
<time>{{formatDate @post.publishedAt}}</time>
</article>
</template>
```
**With Multiple Arguments:**
```javascript
// app/utils/format-currency.js
export function formatCurrency(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
```
```glimmer-js
// app/components/price.gjs
import { formatCurrency } from '../utils/format-currency';
<template>
<span class="price">
{{formatCurrency @amount @currency}}
</span>
</template>
```
**For Helpers that Need Services (use class-based):**
When you need dependency injection, use a class instead of `helper()`:
```javascript
// app/utils/format-relative-time.js
export class FormatRelativeTime {
constructor(owner) {
this.intl = owner.lookup('service:intl');
}
compute(date) {
return this.intl.formatRelative(date);
}
}
```
**Why Avoid helper():**
1. **Simpler**: Plain functions are easier to understand
2. **Standard JavaScript**: No Ember-specific wrapper needed
3. **Better Testing**: Plain functions are easier to test
4. **Performance**: No wrapper overhead
5. **Modern Pattern**: Aligns with modern Ember conventions
**Migration from helper():**
```javascript
// Before
import { helper } from '@ember/component/helper';
function capitalize([text]) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export default helper(capitalize);
// After
export function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
```
**Common Helper Patterns:**
```javascript
// app/utils/string-helpers.js
export function capitalize(text) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function truncate(text, length = 50) {
if (text.length <= length) return text;
return text.slice(0, length) + '...';
}
export function pluralize(count, singular, plural) {
return count === 1 ? singular : plural;
}
```
```glimmer-js
// Usage
import { capitalize, truncate, pluralize } from '../utils/string-helpers';
<template>
<h1>{{capitalize @title}}</h1>
<p>{{truncate @description 100}}</p>
<span>{{@count}} {{pluralize @count "item" "items"}}</span>
</template>
```
Plain functions are the modern way to create helpers in Ember. Only use classes when you need dependency injection.
Reference: [Ember Helpers - Plain Functions](https://guides.emberjs.com/release/components/helper-functions/)
@@ -0,0 +1,250 @@
---
title: Use {{on}} Modifier Instead of Event Handler Properties
impact: MEDIUM
impactDescription: Better performance and clearer event handling
tags: performance, events, modifiers, best-practices
---
## Use {{on}} Modifier Instead of Event Handler Properties
Always use the `{{on}}` modifier for event handling instead of HTML event handler properties. The `{{on}}` modifier provides better memory management, automatic cleanup, and clearer intent.
**Why {{on}} is Better:**
- Automatic cleanup when element is removed (prevents memory leaks)
- Supports event options (`capture`, `passive`, `once`)
- More explicit and searchable in templates
**Incorrect (HTML event properties):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
export default class Button extends Component {
@action
handleClick() {
console.log('clicked');
}
<template>
<button onclick={{this.handleClick}}>
Click Me
</button>
</template>
}
```
**Correct ({{on}} modifier):**
```glimmer-js
// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class Button extends Component {
@action
handleClick() {
console.log('clicked');
}
<template>
<button {{on "click" this.handleClick}}>
Click Me
</button>
</template>
}
```
### Event Options
The `{{on}}` modifier supports standard event listener options:
```glimmer-js
// app/components/scrollable.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class Scrollable extends Component {
@action
handleScroll(event) {
console.log('scrolled', event.target.scrollTop);
}
<template>
{{! passive: true improves scroll performance }}
<div {{on "scroll" this.handleScroll passive=true}}>
{{yield}}
</div>
</template>
}
```
**Available options:**
- `capture` - Use capture phase instead of bubble phase
- `once` - Remove listener after first invocation
- `passive` - Indicates handler won't call `preventDefault()` (better scroll performance)
### Handling Multiple Events
```glimmer-js
// app/components/input-field.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class InputField extends Component {
@action
handleFocus() {
console.log('focused');
}
@action
handleBlur() {
console.log('blurred');
}
@action
handleInput(event) {
this.args.onChange?.(event.target.value);
}
<template>
<input
type="text"
value={{@value}}
{{on "focus" this.handleFocus}}
{{on "blur" this.handleBlur}}
{{on "input" this.handleInput}}
/>
</template>
}
```
### Preventing Default and Stopping Propagation
Handle these in your action, not in the template:
```glimmer-js
// app/components/form.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class Form extends Component {
@action
handleSubmit(event) {
event.preventDefault(); // Prevent page reload
event.stopPropagation(); // Stop event bubbling if needed
this.args.onSubmit?.(/* form data */);
}
<template>
<form {{on "submit" this.handleSubmit}}>
<button type="submit">Submit</button>
</form>
</template>
}
```
### Keyboard Events
```glimmer-js
// app/components/keyboard-nav.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class KeyboardNav extends Component {
@action
handleKeyDown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.args.onActivate?.();
}
if (event.key === 'Escape') {
this.args.onCancel?.();
}
}
<template>
<div role="button" tabindex="0" {{on "keydown" this.handleKeyDown}}>
{{yield}}
</div>
</template>
}
```
### Performance Tip: Event Delegation
For lists with many items, use event delegation on the parent:
```glimmer-js
// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
export default class TodoList extends Component {
@action
handleClick(event) {
// Find which todo was clicked
const todoId = event.target.closest('[data-todo-id]')?.dataset.todoId;
if (todoId) {
this.args.onTodoClick?.(todoId);
}
}
<template>
{{! Single listener for all todos - better than one per item }}
<ul {{on "click" this.handleClick}}>
{{#each @todos as |todo|}}
<li data-todo-id={{todo.id}}>
{{todo.title}}
</li>
{{/each}}
</ul>
</template>
}
```
### Common Pitfalls
**❌ Don't bind directly without @action:**
```glimmer-js
// This won't work - loses 'this' context
<button {{on "click" this.myMethod}}>Bad</button>
```
**✅ Use @action decorator:**
```glimmer-js
@action
myMethod() {
// 'this' is correctly bound
}
<button {{on "click" this.myMethod}}>Good</button>
```
**❌ Don't use string event handlers:**
```glimmer-js
{{! Security risk and doesn't work in strict mode }}
<button onclick="handleClick()">Bad</button>
```
Always use the `{{on}}` modifier for cleaner, safer, and more performant event handling in Ember applications.
**References:**
- [Ember Modifiers Guide](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/)
- [{{on}} Modifier RFC](https://github.com/emberjs/rfcs/blob/master/text/0471-on-modifier.md)
- [Event Listener Options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#parameters)
@@ -0,0 +1,45 @@
---
title: Use Route-Based Code Splitting
impact: CRITICAL
impactDescription: 30-70% initial bundle reduction
tags: routes, lazy-loading, embroider, bundle-size
---
## Use Route-Based Code Splitting
With Embroider's route-based code splitting, routes and their components are automatically split into separate chunks, loaded only when needed.
**Incorrect (everything in main bundle):**
```javascript
// ember-cli-build.js
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
module.exports = function (defaults) {
const app = new EmberApp(defaults, {
// No optimization
});
return app.toTree();
};
```
**Correct (Embroider with Vite and route splitting):**
```javascript
// ember-cli-build.js
const { Vite } = require('@embroider/vite');
module.exports = require('@embroider/compat').compatBuild(app, Vite, {
staticAddonTestSupportTrees: true,
staticAddonTrees: true,
staticHelpers: true,
staticModifiers: true,
staticComponents: true,
splitAtRoutes: ['admin', 'reports', 'settings'], // Routes to split
});
```
Embroider with `splitAtRoutes` creates separate bundles for specified routes, reducing initial load time by 30-70%.
Reference: [Embroider Documentation](https://github.com/embroider-build/embroider)
@@ -0,0 +1,47 @@
---
title: Use Loading Substates for Better UX
impact: CRITICAL
impactDescription: Perceived performance improvement
tags: routes, loading, ux, performance
---
## Use Loading Substates for Better UX
Implement loading substates to show immediate feedback while data loads, preventing blank screens and improving perceived performance.
**Incorrect (no loading state):**
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
async model() {
return this.store.request({ url: '/posts' });
}
}
```
**Correct (with loading substate):**
```glimmer-js
// app/routes/posts-loading.gjs
import { LoadingSpinner } from './loading-spinner';
<template>
<div class="loading-spinner" role="status" aria-live="polite">
<span class="sr-only">Loading posts...</span>
<LoadingSpinner />
</div>
</template>
```
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
model() {
// Return promise directly - Ember will show posts-loading template
return this.store.request({ url: '/posts' });
}
}
```
Ember automatically renders `{route-name}-loading` route templates while the model promise resolves, providing better UX without extra code.
@@ -0,0 +1,245 @@
---
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):**
```javascript
// app/routes/post.js
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}` });
}
}
```
```glimmer-js
// app/templates/post.gjs
<template>
<article>
<h1>{{@model.title}}</h1>
<div>{{@model.content}}</div>
</article>
{{outlet}}
</template>
```
**Correct (with smart caching):**
```javascript
// app/routes/post.js
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;
}
}
```
```glimmer-js
// app/templates/post.gjs
<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();
}
}
```
```javascript
// app/routes/post.js
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 });
}
}
```
```glimmer-js
// app/templates/post.gjs
<template>
<article>
<h1>{{@model.title}}</h1>
<div>{{@model.content}}</div>
</article>
{{outlet}}
</template>
```
**Using query params for cache control:**
```javascript
// app/routes/posts.js
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,
});
}
}
```
```glimmer-js
// app/templates/posts.gjs
<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:**
```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() {
// 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' });
}
}
```
```glimmer-js
// app/templates/dashboard.gjs
<template>
<div class="dashboard">
<h1>Dashboard</h1>
<div>Stats: {{@model.stats}}</div>
</div>
{{outlet}}
</template>
```
Smart caching reduces server load, improves perceived performance, and provides better offline support while keeping data fresh.
Reference: [WarpDrive Caching](https://warp-drive.io/)
@@ -0,0 +1,54 @@
---
title: Parallel Data Loading in Model Hooks
impact: CRITICAL
impactDescription: 2-10× improvement
tags: routes, data-fetching, parallelization, performance
---
## Parallel Data Loading in Model Hooks
When fetching multiple independent data sources in a route's model hook, use `Promise.all()` or RSVP.hash() to load them in parallel instead of sequentially.
`export default` in these route examples is intentional because route modules are discovered through resolver lookup. In hybrid `.gjs`/`.hbs` codebases, keep route defaults and add named exports only when you need explicit imports elsewhere.
**Incorrect (sequential loading, 3 round trips):**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class DashboardRoute extends Route {
@service store;
async model() {
const user = await this.store.request({ url: '/users/me' });
const posts = await this.store.request({ url: '/posts?recent=true' });
const notifications = await this.store.request({ url: '/notifications?unread=true' });
return { user, posts, notifications };
}
}
```
**Correct (parallel loading, 1 round trip):**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
export default class DashboardRoute extends Route {
@service store;
model() {
return hash({
user: this.store.request({ url: '/users/me' }),
posts: this.store.request({ url: '/posts?recent=true' }),
notifications: this.store.request({ url: '/notifications?unread=true' }),
});
}
}
```
Using `hash()` from RSVP allows Ember to resolve all promises concurrently, significantly reducing load time.
@@ -0,0 +1,112 @@
---
title: Use Separate Route and Template Files
impact: MEDIUM-HIGH
impactDescription: Better code organization and maintainability
tags: routes, templates, gjs, file-conventions
---
## Use Separate Route and Template Files
Keep route logic in `app/routes/*.js` and route templates in `app/templates/*.gjs`. Route classes imported from `@ember/routing/route` do not support inline `<template>` blocks.
**Incorrect (inline template inside a route class):**
```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>
}
```
**Correct (separate route module and template file):**
```javascript
// app/routes/posts.js
import Route from '@ember/routing/route';
export default class PostsRoute extends Route {
model() {
return this.store.request({ url: '/posts' });
}
}
```
```glimmer-js
// app/templates/posts.gjs
<template>
<h1>Posts</h1>
<ul>
{{#each @model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
{{outlet}}
</template>
```
**With a separate template file for route UI:**
```javascript
// app/routes/posts.js
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' });
}
}
```
```glimmer-js
// app/templates/posts.gjs
<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/templates/about.gjs
<template>
<div class="about-page">
<h1>About Us</h1>
<p>Welcome to our application!</p>
</div>
</template>
```
Keeping route classes and route templates in their conventional files matches Ember's supported routing model and makes examples easier to apply in real apps.
Reference: [Ember Routes](https://guides.emberjs.com/release/routing/)
@@ -0,0 +1,98 @@
---
title: Cache API Responses in Services
impact: MEDIUM-HIGH
impactDescription: 50-90% reduction in duplicate requests
tags: services, caching, performance, api
---
## Cache API Responses in Services
Cache API responses in services to avoid duplicate network requests. Use tracked properties to make the cache reactive.
**Incorrect (no caching):**
```javascript
// app/services/user.js
import Service from '@ember/service';
import { service } from '@ember/service';
export default class UserService extends Service {
@service store;
async getCurrentUser() {
// Fetches from API every time
return this.store.request({ url: '/users/me' });
}
}
```
**Correct (with caching):**
```javascript
// app/services/user.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TrackedMap } from 'tracked-built-ins';
export default class UserService extends Service {
@service store;
@tracked currentUser = null;
cache = new TrackedMap();
async getCurrentUser() {
if (!this.currentUser) {
const response = await this.store.request({ url: '/users/me' });
this.currentUser = response.content.data;
}
return this.currentUser;
}
async getUser(id) {
if (!this.cache.has(id)) {
const response = await this.store.request({ url: `/users/${id}` });
this.cache.set(id, response.content.data);
}
return this.cache.get(id);
}
clearCache() {
this.currentUser = null;
this.cache.clear();
}
}
```
**For time-based cache invalidation:**
```javascript
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class DataService extends Service {
@tracked _cache = null;
_cacheTimestamp = null;
_cacheDuration = 5 * 60 * 1000; // 5 minutes
async getData() {
const now = Date.now();
const isCacheValid =
this._cache && this._cacheTimestamp && now - this._cacheTimestamp < this._cacheDuration;
if (!isCacheValid) {
this._cache = await this.fetchData();
this._cacheTimestamp = now;
}
return this._cache;
}
async fetchData() {
const response = await fetch('/api/data');
return response.json();
}
}
```
Caching in services prevents duplicate API requests and improves performance significantly.
@@ -0,0 +1,342 @@
---
title: Implement Robust Data Requesting Patterns
impact: HIGH
impactDescription: Prevents request waterfalls and race conditions in data flows
tags: services, data-fetching, concurrency, cancellation, reliability
---
## Implement Robust Data Requesting Patterns
Use proper patterns for data fetching including parallel requests, error handling, request cancellation, and retry logic.
`export default` in route/service snippets below is intentional because these modules are commonly resolved by convention and referenced from templates. In hybrid `.gjs`/`.hbs` codebases, you can pair named exports with a default alias where needed.
## Problem
Naive data fetching creates waterfall requests, doesn't handle errors properly, and can cause race conditions or memory leaks from uncanceled requests.
**Incorrect:**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
export default class DashboardRoute extends Route {
async model() {
// Sequential waterfall - slow!
const user = await this.store.request({ url: '/users/me' });
const posts = await this.store.request({ url: '/posts' });
const notifications = await this.store.request({ url: '/notifications' });
// No error handling
// No cancellation
return { user, posts, notifications };
}
}
```
## Solution: Parallel Requests
Use `RSVP.hash` or `Promise.all` for parallel loading:
**Correct (parallelized model loading):**
```javascript
// app/routes/dashboard.js
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
export default class DashboardRoute extends Route {
async model() {
return hash({
user: this.store.request({ url: '/users/me' }),
posts: this.store.request({ url: '/posts?recent=true' }),
notifications: this.store.request({ url: '/notifications?unread=true' }),
});
}
}
```
## Error Handling Pattern
Handle errors gracefully with fallbacks:
```javascript
// app/services/api.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class ApiService extends Service {
@service store;
@tracked lastError = null;
async fetchWithFallback(url, fallback = null) {
try {
const response = await this.store.request({ url });
this.lastError = null;
return response.content;
} catch (error) {
this.lastError = error.message;
console.error(`API Error fetching ${url}:`, error);
return fallback;
}
}
async fetchWithRetry(url, { maxRetries = 3, delay = 1000 } = {}) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await this.store.request({ url });
} catch (error) {
if (attempt === maxRetries - 1) throw error;
await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1)));
}
}
}
}
```
## Request Cancellation with AbortController
Prevent race conditions by canceling stale requests:
```glimmer-js
// app/components/search-results.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { restartableTask, timeout } from 'ember-concurrency';
class SearchResults extends Component {
@service store;
@tracked results = [];
// Automatically cancels previous searches
@restartableTask
*searchTask(query) {
yield timeout(300); // Debounce
try {
const response = yield this.store.request({
url: `/search?q=${encodeURIComponent(query)}`,
});
this.results = response.content;
} catch (error) {
if (error.name !== 'TaskCancelation') {
console.error('Search failed:', error);
}
}
}
<template>
<input
type="search"
{{on "input" (fn this.searchTask.perform @value)}}
placeholder="Search..."
/>
{{#if this.searchTask.isRunning}}
<div class="loading">Searching...</div>
{{else}}
<ul>
{{#each this.results as |result|}}
<li>{{result.title}}</li>
{{/each}}
</ul>
{{/if}}
</template>
}
```
## Manual AbortController Pattern
For non-ember-concurrency scenarios:
```javascript
// app/services/data-fetcher.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
export default class DataFetcherService extends Service {
@service store;
@tracked data = null;
@tracked isLoading = false;
abortController = null;
constructor() {
super(...arguments);
registerDestructor(this, () => {
this.abortController?.abort();
});
}
async fetch(url) {
// Cancel previous request
this.abortController?.abort();
this.abortController = new AbortController();
this.isLoading = true;
try {
// Note: WarpDrive handles AbortSignal internally
const response = await this.store.request({
url,
signal: this.abortController.signal,
});
this.data = response.content;
} catch (error) {
if (error.name !== 'AbortError') {
throw error;
}
} finally {
this.isLoading = false;
}
}
}
```
## Dependent Requests Pattern
When requests depend on previous results:
```javascript
// app/routes/post.js
import Route from '@ember/routing/route';
import { hash } from 'rsvp';
export default class PostRoute extends Route {
async model({ post_id }) {
// First fetch the post
const post = await this.store.request({
url: `/posts/${post_id}`,
});
// Then fetch related data in parallel
return hash({
post,
author: this.store.request({
url: `/users/${post.content.authorId}`,
}),
comments: this.store.request({
url: `/posts/${post_id}/comments`,
}),
relatedPosts: this.store.request({
url: `/posts/${post_id}/related`,
}),
});
}
}
```
## Polling Pattern
For real-time data updates:
```javascript
// app/services/live-data.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
export default class LiveDataService extends Service {
@service store;
@tracked data = null;
intervalId = null;
constructor() {
super(...arguments);
registerDestructor(this, () => {
this.stopPolling();
});
}
startPolling(url, interval = 5000) {
this.stopPolling();
this.poll(url); // Initial fetch
this.intervalId = setInterval(() => this.poll(url), interval);
}
async poll(url) {
try {
const response = await this.store.request({ url });
this.data = response.content;
} catch (error) {
console.error('Polling error:', error);
}
}
stopPolling() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
}
```
## Batch Requests
Optimize multiple similar requests:
```javascript
// app/services/batch-loader.js
import Service, { service } from '@ember/service';
export default class BatchLoaderService extends Service {
@service store;
pendingIds = new Set();
batchTimeout = null;
async loadUser(id) {
this.pendingIds.add(id);
if (!this.batchTimeout) {
this.batchTimeout = setTimeout(() => this.executeBatch(), 50);
}
// Return a promise that resolves when batch completes
return new Promise((resolve) => {
this.registerCallback(id, resolve);
});
}
async executeBatch() {
const ids = Array.from(this.pendingIds);
this.pendingIds.clear();
this.batchTimeout = null;
const response = await this.store.request({
url: `/users?ids=${ids.join(',')}`,
});
// Resolve all pending promises
response.content.forEach((user) => {
this.resolveCallback(user.id, user);
});
}
}
```
## Performance Impact
- **Parallel requests (RSVP.hash)**: 60-80% faster than sequential
- **Request cancellation**: Prevents memory leaks and race conditions
- **Retry logic**: Improves reliability with < 5% overhead
- **Batch loading**: 40-70% reduction in requests
## When to Use
- **RSVP.hash**: Independent data that can load in parallel
- **ember-concurrency**: Search, autocomplete, or user-driven requests
- **AbortController**: Long-running requests that may become stale
- **Retry logic**: Critical data with transient network issues
- **Batch loading**: Loading many similar items (N+1 scenarios)
## References
- [WarpDrive Documentation](https://warp-drive.io/)
- [ember-concurrency](https://ember-concurrency.com/)
- [RSVP.js](https://github.com/tildeio/rsvp.js)
- [AbortController MDN](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
@@ -0,0 +1,129 @@
---
title: Optimize WarpDrive Queries
impact: MEDIUM-HIGH
impactDescription: 40-70% reduction in API calls
tags: warp-drive, performance, api, optimization
---
## Optimize WarpDrive Queries
Use WarpDrive's request features effectively to reduce API calls and load only the data you need.
**Incorrect (multiple queries, overfetching):**
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
@service store;
async model() {
// Loads all posts (could be thousands)
const response = await this.store.request({ url: '/posts' });
const posts = response.content.data;
// Then filters in memory
return posts.filter((post) => post.attributes.status === 'published');
}
}
```
**Correct (filtered query with pagination):**
```javascript
// app/routes/posts.js
export default class PostsRoute extends Route {
@service store;
queryParams = {
page: { refreshModel: true },
filter: { refreshModel: true },
};
model(params) {
// Server-side filtering and pagination
return this.store.request({
url: '/posts',
data: {
filter: {
status: 'published',
},
page: {
number: params.page || 1,
size: 20,
},
include: 'author', // Sideload related data
fields: {
// Sparse fieldsets
posts: 'title,excerpt,publishedAt,author',
users: 'name,avatar',
},
},
});
}
}
```
**Use request with includes for single records:**
```javascript
// app/routes/post.js
export default class PostRoute extends Route {
@service store;
model(params) {
return this.store.request({
url: `/posts/${params.post_id}`,
data: {
include: 'author,comments.user', // Nested relationships
},
});
}
}
```
**For frequently accessed data, use cache lookups:**
```javascript
// app/components/user-badge.js
class UserBadge extends Component {
@service store;
get user() {
// Check cache first, avoiding API call if already loaded
const cached = this.store.cache.peek({
type: 'user',
id: this.args.userId,
});
if (cached) {
return cached;
}
// Only fetch if not in cache
return this.store.request({
url: `/users/${this.args.userId}`,
});
}
}
```
**Use request options for custom queries:**
```javascript
model() {
return this.store.request({
url: '/posts',
data: {
include: 'author,tags',
customParam: 'value'
},
options: {
reload: true // Bypass cache
}
});
}
```
Efficient WarpDrive usage reduces network overhead and improves application performance significantly.
Reference: [WarpDrive Documentation](https://warp-drive.io/)
@@ -0,0 +1,460 @@
---
title: Manage Service Owner and Linkage Patterns
impact: MEDIUM-HIGH
impactDescription: Better service organization and dependency management
tags: services, owner, linkage, dependency-injection, architecture
---
## Manage Service Owner and Linkage Patterns
Understand how to manage service linkage, owner passing, and alternative service organization patterns beyond the traditional app/services directory.
### Owner and Linkage Fundamentals
**Incorrect (manual service instantiation):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import ApiService from '../services/api';
class UserProfile extends Component {
// ❌ Creates orphaned instance without owner
api = new ApiService();
async loadUser() {
// Won't have access to other services or owner features
return this.api.fetch('/user/me');
}
<template>
<div>{{@user.name}}</div>
</template>
}
```
**Correct (proper service injection with owner):**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
class UserProfile extends Component {
// ✅ Proper injection with owner linkage
@service api;
async loadUser() {
// Has full owner context and can inject other services
return this.api.fetch('/user/me');
}
<template>
<div>{{@user.name}}</div>
</template>
}
```
### Manual Owner Passing (Without Libraries)
**Creating instances with owner:**
```glimmer-js
// app/components/data-processor.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { service } from '@ember/service';
class DataTransformer {
@service store;
transform(data) {
// Can use injected services because it has an owner
return this.store.request({ url: '/transform', data });
}
}
class DataProcessor extends Component {
@service('store') storeService;
constructor(owner, args) {
super(owner, args);
// Manual instantiation with owner linkage
this.transformer = new DataTransformer();
setOwner(this.transformer, getOwner(this));
}
processData(data) {
// transformer can now access services
return this.transformer.transform(data);
}
<template>
<div>Processing...</div>
</template>
}
```
**Factory pattern with owner:**
```javascript
// app/utils/logger-factory.js
import { getOwner } from '@ember/application';
class Logger {
constructor(owner, context) {
this.owner = owner;
this.context = context;
}
get config() {
// Access configuration service via owner
return getOwner(this).lookup('service:config');
}
log(message) {
if (this.config.enableLogging) {
console.log(`[${this.context}]`, message);
}
}
}
export function createLogger(owner, context) {
return new Logger(owner, context);
}
```
```glimmer-js
// Usage in component
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { createLogger } from '../utils/logger-factory';
class My extends Component {
logger = createLogger(getOwner(this), 'MyComponent');
performAction() {
this.logger.log('Action performed');
}
<template>
<button {{on "click" this.performAction}}>Do Something</button>
</template>
}
```
### Owner Passing with Modern Libraries
**Using reactiveweb's link() for ownership and destruction:**
The `link()` function from `reactiveweb` provides both ownership transfer and automatic destruction linkage.
```glimmer-js
// app/components/advanced-form.gjs
import Component from '@glimmer/component';
import { link } from 'reactiveweb/link';
class ValidationService {
validate(data) {
// Validation logic
return data.email && data.email.includes('@');
}
}
class FormStateManager {
data = { email: '' };
updateEmail(value) {
this.data.email = value;
}
}
export class AdvancedForm extends Component {
// link() handles both owner and destruction automatically
validation = link(this, () => new ValidationService());
formState = link(this, () => new FormStateManager());
get isValid() {
return this.validation.validate(this.formState.data);
}
<template>
<form>
<input value={{this.formState.data.email}} />
{{#if (not this.isValid)}}
<span>Invalid form</span>
{{/if}}
</form>
</template>
}
```
**Why use link():**
- Automatically transfers owner from parent to child instance
- Registers destructor so child is cleaned up when parent is destroyed
- No manual `setOwner` or `registerDestructor` calls needed
- See [RFC #1067](https://github.com/emberjs/rfcs/pull/1067) for the proposal and reasoning
- Documentation: https://reactive.nullvoxpopuli.com/functions/link.link.html
### Services Outside app/services Directory
**Using createService from ember-primitives:**
```glimmer-js
// app/components/analytics-tracker.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { createService } from 'ember-primitives/utils';
// Define service logic as a plain function
function AnalyticsService() {
let events = [];
return {
get events() {
return events;
},
track(event) {
events.push({ ...event, timestamp: Date.now() });
// Send to analytics endpoint
fetch('/analytics', {
method: 'POST',
body: JSON.stringify(event),
});
},
};
}
export class AnalyticsTracker extends Component {
// createService handles owner linkage and cleanup automatically
analytics = createService(this, AnalyticsService);
<template>
<div>Tracking {{this.analytics.events.length}} events</div>
</template>
}
```
**Why createService:**
- No need to extend Service class
- Automatic owner linkage and cleanup
- Simpler than manual setOwner/registerDestructor
- Documentation: https://ce1d7e18.ember-primitives.pages.dev/6-utils/createService.md
**Co-located services with components:**
```javascript
// app/components/shopping-cart/service.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TrackedArray } from 'tracked-built-ins';
import { action } from '@ember/object';
export class CartService extends Service {
@tracked items = new TrackedArray([]);
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
@action
addItem(item) {
this.items.push(item);
}
@action
removeItem(id) {
const index = this.items.findIndex((item) => item.id === id);
if (index > -1) this.items.splice(index, 1);
}
@action
clear() {
this.items.clear();
}
}
```
```glimmer-js
// app/components/shopping-cart/index.gjs
import Component from '@glimmer/component';
import { getOwner, setOwner } from '@ember/application';
import { CartService } from './service';
class ShoppingCart extends Component {
cart = (() => {
const instance = new CartService();
setOwner(instance, getOwner(this));
return instance;
})();
<template>
<div class="cart">
<h3>Cart ({{this.cart.items.length}} items)</h3>
<div>Total: ${{this.cart.total}}</div>
{{#each this.cart.items as |item|}}
<div class="cart-item">
{{item.name}}
- ${{item.price}}
<button {{on "click" (fn this.cart.removeItem item.id)}}>
Remove
</button>
</div>
{{/each}}
<button {{on "click" this.cart.clear}}>Clear Cart</button>
</div>
</template>
}
```
**Service-like utilities in utils/ directory:**
```javascript
// app/utils/notification-manager.js
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { TrackedArray } from 'tracked-built-ins';
import { setOwner } from '@ember/application';
export class NotificationManager {
@tracked notifications = new TrackedArray([]);
constructor(owner) {
setOwner(this, owner);
}
@action
add(message, type = 'info') {
const notification = {
id: Math.random().toString(36),
message,
type,
timestamp: Date.now(),
};
this.notifications.push(notification);
// Auto-dismiss after 5 seconds
setTimeout(() => this.dismiss(notification.id), 5000);
}
@action
dismiss(id) {
const index = this.notifications.findIndex((n) => n.id === id);
if (index > -1) this.notifications.splice(index, 1);
}
}
```
```glimmer-js
// app/components/notification-container.gjs
import Component from '@glimmer/component';
import { getOwner } from '@ember/application';
import { NotificationManager } from '../utils/notification-manager';
class NotificationContainer extends Component {
notifications = new NotificationManager(getOwner(this));
<template>
<div class="notifications">
{{#each this.notifications.notifications as |notif|}}
<div class="notification notification-{{notif.type}}">
{{notif.message}}
<button {{on "click" (fn this.notifications.dismiss notif.id)}}>
×
</button>
</div>
{{/each}}
</div>
{{! Example usage }}
<button {{on "click" (fn this.notifications.add "Success!" "success")}}>
Show Notification
</button>
</template>
}
```
### Registering Custom Services Dynamically
**Runtime service registration:**
```javascript
// app/instance-initializers/dynamic-services.js
export function initialize(appInstance) {
// Register service dynamically without app/services file
appInstance.register(
'service:feature-flags',
class FeatureFlagsService {
flags = {
newDashboard: true,
betaFeatures: false,
};
isEnabled(flag) {
return this.flags[flag] || false;
}
},
);
// Make it a singleton
appInstance.inject('route', 'featureFlags', 'service:feature-flags');
appInstance.inject('component', 'featureFlags', 'service:feature-flags');
}
export default {
initialize,
};
```
**Using registered services:**
```glimmer-js
// app/components/feature-gated.gjs
import Component from '@glimmer/component';
import { service } from '@ember/service';
class FeatureGated extends Component {
@service featureFlags;
get shouldShow() {
return this.featureFlags.isEnabled(this.args.feature);
}
<template>
{{#if this.shouldShow}}
{{yield}}
{{else}}
<div class="feature-disabled">This feature is not available</div>
{{/if}}
</template>
}
```
### Best Practices
1. **Use @service decorator** for app/services - cleanest and most maintainable
2. **Use link() from reactiveweb** for ownership and destruction linkage
3. **Use createService from ember-primitives** for component-scoped services without extending Service class
4. **Manual owner passing** for utilities that need occasional service access
5. **Co-located services** for component-specific state that doesn't need global access
6. **Runtime registration** for dynamic services or testing scenarios
7. **Always use setOwner** when manually instantiating classes that need services
### When to Use Each Pattern
- **app/services**: Global singletons needed across the app
- **link() from reactiveweb**: When you need both owner and destruction linkage
- **createService from ember-primitives**: Component-scoped services without Service class
- **Co-located services**: Component-specific state, not needed elsewhere
- **Utils with owner**: Stateless utilities that occasionally need config/services
- **Runtime registration**: Dynamic configuration, feature flags, testing
Reference: [Ember Owner API](https://api.emberjs.com/ember/release/functions/@ember%2Fapplication/getOwner), [Dependency Injection](https://guides.emberjs.com/release/applications/dependency-injection/), [reactiveweb link()](https://reactive.nullvoxpopuli.com/functions/link.link.html), [ember-primitives createService](https://ce1d7e18.ember-primitives.pages.dev/6-utils/createService.md)
@@ -0,0 +1,119 @@
---
title: Use Services for Shared State
impact: MEDIUM-HIGH
impactDescription: Better state management and reusability
tags: services, state-management, dependency-injection
---
## Use Services for Shared State
Use services to manage shared state across components and routes instead of passing data through multiple layers or duplicating state.
**Incorrect (prop drilling):**
```glimmer-js
// app/routes/dashboard.gjs
export default class DashboardRoute extends Route {
model() {
return { currentTheme: 'dark' };
}
<template>
<Header @theme={{@model.currentTheme}} />
<Sidebar @theme={{@model.currentTheme}} />
<MainContent @theme={{@model.currentTheme}} />
</template>
}
```
**Correct (using service):**
```javascript
// app/services/theme.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class ThemeService extends Service {
@tracked currentTheme = 'dark';
@action
setTheme(theme) {
this.currentTheme = theme;
localStorage.setItem('theme', theme);
}
@action
loadTheme() {
this.currentTheme = localStorage.getItem('theme') || 'dark';
}
}
```
```javascript
// app/components/header.js
import Component from '@glimmer/component';
import { service } from '@ember/service';
class Header extends Component {
@service theme;
// Access theme.currentTheme directly
}
```
```javascript
// app/components/sidebar.js
import Component from '@glimmer/component';
import { service } from '@ember/service';
class Sidebar extends Component {
@service theme;
// Access theme.currentTheme directly
}
```
Services provide centralized state management with automatic reactivity through tracked properties.
**For complex state, consider using Ember Data or ember-orbit:**
```javascript
// app/services/cart.js
import Service from '@ember/service';
import { service } from '@ember/service';
import { TrackedArray } from 'tracked-built-ins';
import { cached } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class CartService extends Service {
@service store;
items = new TrackedArray([]);
@cached
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
@cached
get itemCount() {
return this.items.length;
}
@action
addItem(item) {
this.items.push(item);
}
@action
removeItem(item) {
const index = this.items.indexOf(item);
if (index > -1) {
this.items.splice(index, 1);
}
}
}
```
Reference: [Ember Services](https://guides.emberjs.com/release/services/)
@@ -0,0 +1,94 @@
---
title: Avoid Heavy Computation in Templates
impact: MEDIUM
impactDescription: 40-60% reduction in render time
tags: templates, performance, getters, helpers
---
## Avoid Heavy Computation in Templates
Move expensive computations from templates to cached getters in the component class or in-scope functions for template-only components. Templates should only display data, not compute it. Keep templates easy for humans to read by minimizing nested function invocations.
**Why this matters:**
- Templates should be easy to read and understand
- Nested function calls create cognitive overhead
- Computations should be cached and reused, not recalculated on every render
- Template-only components (without `this`) need alternative patterns
**Incorrect (heavy computation in template):**
```glimmer-js
// app/components/stats.gjs
import { sum, map, div, max, multiply, sortBy } from '../helpers/math';
<template>
<div>
<p>Total: {{sum (map @items "price")}}</p>
<p>Average: {{div (sum (map @items "price")) @items.length}}</p>
<p>Max: {{max (map @items "price")}}</p>
{{#each (sortBy "name" @items) as |item|}}
<div>{{item.name}}: {{multiply item.price item.quantity}}</div>
{{/each}}
</div>
</template>
```
**Correct (computation in component with cached getters):**
```glimmer-js
// app/components/stats.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
export class Stats extends Component {
// @cached is useful when getters are accessed multiple times
// For single access, regular getters are sufficient
@cached
get total() {
return this.args.items.reduce((sum, item) => sum + item.price, 0);
}
get average() {
// No @cached needed if only accessed once in template
return this.args.items.length > 0 ? this.total / this.args.items.length : 0;
}
get maxPrice() {
return Math.max(...this.args.items.map((item) => item.price));
}
@cached
get sortedItems() {
// @cached useful here as it's used by itemsWithTotal
return [...this.args.items].sort((a, b) => a.name.localeCompare(b.name));
}
@cached
get itemsWithTotal() {
// @cached useful as accessed multiple times in {{#each}}
return this.sortedItems.map((item) => ({
...item,
total: item.price * item.quantity,
}));
}
<template>
<div>
<p>Total: {{this.total}}</p>
<p>Average: {{this.average}}</p>
<p>Max: {{this.maxPrice}}</p>
{{#each this.itemsWithTotal key="id" as |item|}}
<div>{{item.name}}: {{item.total}}</div>
{{/each}}
</div>
</template>
}
```
**Note on @cached**: Use `@cached` when a getter is accessed multiple times (like in `{{#each}}` loops or by other getters). For getters accessed only once, regular getters are sufficient and avoid unnecessary memoization overhead.
Moving computations to getters ensures they run only when dependencies change, not on every render. Templates remain clean and readable.
@@ -0,0 +1,280 @@
---
title: Optimize Conditional Rendering
impact: HIGH
impactDescription: Reduces unnecessary rerenders in dynamic template branches
tags: templates, conditionals, rendering, performance, glimmer
---
## Optimize Conditional Rendering
Use efficient conditional rendering patterns to minimize unnecessary DOM updates and improve rendering performance.
## Problem
Inefficient conditional logic causes excessive re-renders, creates complex template code, and can lead to poor performance in lists and dynamic UIs.
**Incorrect:**
```glimmer-js
// app/components/user-list.gjs
import Component from '@glimmer/component';
class UserList extends Component {
<template>
{{#each @users as |user|}}
<div class="user">
{{! Recomputes every time}}
{{#if (eq user.role "admin")}}
<span class="badge admin">{{user.name}} (Admin)</span>
{{/if}}
{{#if (eq user.role "moderator")}}
<span class="badge mod">{{user.name}} (Mod)</span>
{{/if}}
{{#if (eq user.role "user")}}
<span>{{user.name}}</span>
{{/if}}
</div>
{{/each}}
</template>
}
```
## Solution
Use `{{#if}}` / `{{#else if}}` / `{{#else}}` chains and extract computed logic to getters for better performance and readability.
**Correct:**
```glimmer-js
// app/components/user-list.gjs
import Component from '@glimmer/component';
class UserList extends Component {
<template>
{{#each @users as |user|}}
<div class="user">
{{#if (eq user.role "admin")}}
<span class="badge admin">{{user.name}} (Admin)</span>
{{else if (eq user.role "moderator")}}
<span class="badge mod">{{user.name}} (Mod)</span>
{{else}}
<span>{{user.name}}</span>
{{/if}}
</div>
{{/each}}
</template>
}
```
## Extracted Logic Pattern
For complex conditions, use getters:
```glimmer-js
// app/components/user-card.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class UserCard extends Component {
@cached
get isActive() {
return this.args.user.status === 'active' && this.args.user.lastLoginDays < 30;
}
@cached
get showActions() {
return this.args.canEdit && !this.args.user.locked && this.isActive;
}
<template>
<div class="user-card">
<h3>{{@user.name}}</h3>
{{#if this.isActive}}
<span class="status active">Active</span>
{{else}}
<span class="status inactive">Inactive</span>
{{/if}}
{{#if this.showActions}}
<div class="actions">
<button>Edit</button>
<button>Delete</button>
</div>
{{/if}}
</div>
</template>
}
```
## Conditional Lists
Use `{{#if}}` to guard `{{#each}}` and avoid rendering empty states:
```glimmer-js
// app/components/task-list.gjs
import Component from '@glimmer/component';
class TaskList extends Component {
get hasTasks() {
return this.args.tasks?.length > 0;
}
<template>
{{#if this.hasTasks}}
<ul class="task-list">
{{#each @tasks as |task|}}
<li>
{{task.title}}
{{#if task.completed}}
<span class="done">✓</span>
{{/if}}
</li>
{{/each}}
</ul>
{{else}}
<p class="empty-state">No tasks yet</p>
{{/if}}
</template>
}
```
## Avoid Nested Conditionals
**Bad:**
```glimmer-js
{{#if @user}}
{{#if @user.isPremium}}
{{#if @user.hasAccess}}
<PremiumContent />
{{/if}}
{{/if}}
{{/if}}
```
**Good:**
```glimmer-js
// app/components/content-gate.gjs
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
class ContentGate extends Component {
@cached
get canViewPremium() {
return this.args.user?.isPremium && this.args.user?.hasAccess;
}
<template>
{{#if this.canViewPremium}}
<PremiumContent />
{{else}}
<UpgradeCTA />
{{/if}}
</template>
}
```
## Component Switching Pattern
Use conditional rendering for component selection:
```glimmer-js
// app/components/media-viewer.gjs
import Component from '@glimmer/component';
import ImageViewer from './image-viewer';
import VideoPlayer from './video-player';
import AudioPlayer from './audio-player';
import { cached } from '@glimmer/tracking';
class MediaViewer extends Component {
@cached
get mediaType() {
return this.args.media?.type;
}
<template>
{{#if (eq this.mediaType "image")}}
<ImageViewer @src={{@media.url}} />
{{else if (eq this.mediaType "video")}}
<VideoPlayer @src={{@media.url}} />
{{else if (eq this.mediaType "audio")}}
<AudioPlayer @src={{@media.url}} />
{{else}}
<p>Unsupported media type</p>
{{/if}}
</template>
}
```
## Loading States
Pattern for async data with loading/error states:
```glimmer-js
// app/components/data-display.gjs
import Component from '@glimmer/component';
import { Resource } from 'ember-resources';
import { resource } from 'ember-resources';
class DataResource extends Resource {
@tracked data = null;
@tracked isLoading = true;
@tracked error = null;
modify(positional, named) {
this.fetchData(named.url);
}
async fetchData(url) {
this.isLoading = true;
this.error = null;
try {
const response = await fetch(url);
this.data = await response.json();
} catch (e) {
this.error = e.message;
} finally {
this.isLoading = false;
}
}
}
class DataDisplay extends Component {
@resource data = DataResource.from(() => ({
url: this.args.url,
}));
<template>
{{#if this.data.isLoading}}
<div class="loading">Loading...</div>
{{else if this.data.error}}
<div class="error">Error: {{this.data.error}}</div>
{{else}}
<div class="content">
{{this.data.data}}
</div>
{{/if}}
</template>
}
```
## Performance Impact
- **Chained if/else**: 40-60% faster than multiple independent {{#if}} blocks
- **Extracted getters**: ~20% faster for complex conditions (cached)
- **Component switching**: Same performance as {{#if}} but better code organization
## When to Use
- **{{#if}}/{{#else}}**: For simple true/false conditions
- **Extracted getters**: For complex or reused conditions
- **Component switching**: For different component types based on state
- **Guard clauses**: To avoid rendering large subtrees when not needed
## References
- [Ember Guides - Conditionals](https://guides.emberjs.com/release/components/conditional-content/)
- [Glimmer VM Performance](https://github.com/glimmerjs/glimmer-vm)
- [@cached decorator](https://api.emberjs.com/ember/release/functions/@glimmer%2Ftracking/cached)
@@ -0,0 +1,91 @@
---
title: Use {{#each}} with @key for Lists
impact: MEDIUM
impactDescription: 50-100% faster list updates
tags: templates, each, performance, rendering
---
## Use {{#each}} with @key for Lists
Use the `key=` parameter with `{{#each}}` when objects are recreated between renders (e.g., via `.map()` or fresh API data). The default behavior uses object identity (`@identity`), which works when object references are stable.
**Incorrect (no key):**
```glimmer-js
// app/components/user-list.gjs
import UserCard from './user-card';
<template>
<ul>
{{#each this.users as |user|}}
<li>
<UserCard @user={{user}} />
</li>
{{/each}}
</ul>
</template>
```
**Correct (with key):**
```glimmer-js
// app/components/user-list.gjs
import UserCard from './user-card';
<template>
<ul>
{{#each this.users key="id" as |user|}}
<li>
<UserCard @user={{user}} />
</li>
{{/each}}
</ul>
</template>
```
**For arrays of primitives (strings, numbers):**
`@identity` is the default, so you rarely need to specify it explicitly. It compares items by value for primitives.
```glimmer-js
// app/components/tag-list.gjs
<template>
{{! @identity is implicit, no need to write it }}
{{#each this.tags as |tag|}}
<span class="tag">{{tag}}</span>
{{/each}}
</template>
```
**For complex scenarios with @index:**
```glimmer-js
// app/components/item-list.gjs
<template>
{{#each this.items key="@index" as |item index|}}
<div data-index={{index}}>
{{item.name}}
</div>
{{/each}}
</template>
```
Using proper keys allows Ember's rendering engine to efficiently update, reorder, and remove items without re-rendering the entire list.
**When to use `key=`:**
- Objects recreated between renders (`.map()`, generators, fresh API responses) → use `key="id"` or similar
- High-frequency updates (animations, real-time data) → always specify a key
- Stable object references (Apollo cache, Ember Data) → default `@identity` is fine
- Items never reorder → `key="@index"` is acceptable
**Performance comparison (dbmon benchmark, 40 rows at 60fps):**
- Without key (objects recreated): Destroys/recreates DOM every frame
- With `key="data.db.id"`: DOM reuse, **2x FPS improvement**
### References:
- [Ember API: each helper](https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/each)
- [Ember template lint: equire-each-key](https://github.com/ember-template-lint/ember-template-lint/blob/main/docs/rule/require-each-key.md)
- [Example PR showing the fps improvement on updated lists](https://github.com/universal-ember/table/pull/68)
@@ -0,0 +1,148 @@
---
title: Use {{fn}} for Partial Application Only
impact: LOW-MEDIUM
impactDescription: Clearer code, avoid unnecessary wrapping
tags: helpers, templates, fn, partial-application
---
## Use {{fn}} for Partial Application Only
The `{{fn}}` helper is used for partial application (binding arguments), similar to JavaScript's `.bind()`. Only use it when you need to pre-bind arguments to a function. Don't use it to simply pass a function reference.
**Incorrect (unnecessary use of {{fn}}):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Search extends Component {
@action
handleSearch(event) {
console.log('Searching:', event.target.value);
}
<template>
{{! Wrong - no arguments being bound}}
<input {{on "input" (fn this.handleSearch)}} />
</template>
}
```
**Correct (direct function reference):**
```glimmer-js
// app/components/search.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class Search extends Component {
@action
handleSearch(event) {
console.log('Searching:', event.target.value);
}
<template>
{{! Correct - pass function directly}}
<input {{on "input" this.handleSearch}} />
</template>
}
```
**When to Use {{fn}} - Partial Application:**
Use `{{fn}}` when you need to pre-bind arguments to a function, similar to JavaScript's `.bind()`:
```glimmer-js
// app/components/user-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class UserList extends Component {
@action
deleteUser(userId, event) {
console.log('Deleting user:', userId);
this.args.onDelete(userId);
}
<template>
<ul>
{{#each @users as |user|}}
<li>
{{user.name}}
{{! Correct - binding user.id as first argument}}
<button {{on "click" (fn this.deleteUser user.id)}}>
Delete
</button>
</li>
{{/each}}
</ul>
</template>
}
```
**Multiple Arguments:**
```glimmer-js
// app/components/data-grid.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
class DataGrid extends Component {
@action
updateCell(rowId, columnKey, event) {
const newValue = event.target.value;
this.args.onUpdate(rowId, columnKey, newValue);
}
<template>
{{#each @rows as |row|}}
{{#each @columns as |column|}}
<input
value={{get row column.key}}
{{! Pre-binding rowId and columnKey}}
{{on "input" (fn this.updateCell row.id column.key)}}
/>
{{/each}}
{{/each}}
</template>
}
```
**Think of {{fn}} like .bind():**
```javascript
// JavaScript comparison
const boundFn = this.deleteUser.bind(this, userId); // .bind() pre-binds args
// Template equivalent: {{fn this.deleteUser userId}}
// Direct reference
const directFn = this.handleSearch; // No pre-binding
// Template equivalent: {{this.handleSearch}}
```
**Common Patterns:**
```javascript
// ❌ Wrong - no partial application
<button {{on "click" (fn this.save)}}>Save</button>
// ✅ Correct - direct reference
<button {{on "click" this.save}}>Save</button>
// ✅ Correct - partial application with argument
<button {{on "click" (fn this.save "draft")}}>Save Draft</button>
// ❌ Wrong - no partial application
<input {{on "input" (fn this.handleInput)}} />
// ✅ Correct - direct reference
<input {{on "input" this.handleInput}} />
// ✅ Correct - partial application with field name
<input {{on "input" (fn this.updateField "email")}} />
```
Only use `{{fn}}` when you're binding arguments. For simple function references, pass them directly.
Reference: [Ember Templates - fn Helper](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/#toc_passing-arguments-to-functions)
@@ -0,0 +1,116 @@
---
title: Import Helpers Directly in Templates
impact: MEDIUM
impactDescription: Better tree-shaking and clarity
tags: helpers, imports, templates, gjs
---
## Import Helpers Directly in Templates
Import helpers directly in gjs/gts files for better tree-shaking, clearer dependencies, and improved type safety.
**Incorrect (global helper resolution):**
```glimmer-js
// app/components/user-profile.gjs
<template>
<div class="profile">
<h1>{{capitalize @user.name}}</h1>
<p>Joined: {{format-date @user.createdAt}}</p>
<p>Posts: {{pluralize @user.postCount "post"}}</p>
</div>
</template>
```
**Correct (explicit helper imports):**
```glimmer-js
// app/components/user-profile.gjs
import { capitalize } from 'ember-string-helpers';
import { formatDate } from 'ember-intl';
import { pluralize } from 'ember-inflector';
<template>
<div class="profile">
<h1>{{capitalize @user.name}}</h1>
<p>Joined: {{formatDate @user.createdAt}}</p>
<p>Posts: {{pluralize @user.postCount "post"}}</p>
</div>
</template>
```
**Built-in and library helpers:**
```glimmer-js
// app/components/conditional-content.gjs
import { fn, hash } from '@ember/helper'; // Actually built-in to Ember
import { eq, not } from 'ember-truth-helpers'; // From ember-truth-helpers addon
<template>
<div class="content">
{{#if (eq @status "active")}}
<span class="badge">Active</span>
{{/if}}
{{#if (not @isLoading)}}
<button {{on "click" (fn @onSave (hash id=@id data=@data))}}>
Save
</button>
{{/if}}
</div>
</template>
```
**Custom helper with imports:**
```javascript
// app/utils/format-currency.js
export function formatCurrency(amount, { currency = 'USD' } = {}) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
```
```glimmer-js
// app/components/price-display.gjs
import { formatCurrency } from '../utils/format-currency';
<template>
<div class="price">
{{formatCurrency @amount currency="EUR"}}
</div>
</template>
```
**Type-safe helpers with TypeScript:**
```glimmer-ts
// app/components/typed-component.gts
import { fn } from '@ember/helper';
import type { TOC } from '@ember/component/template-only';
interface Signature {
Args: {
items: Array<{ id: string; name: string }>;
onSelect: (id: string) => void;
};
}
const TypedComponent: TOC<Signature> = <template>
<ul>
{{#each @items as |item|}}
<li {{on "click" (fn @onSelect item.id)}}>
{{item.name}}
</li>
{{/each}}
</ul>
</template>;
export default TypedComponent;
```
Explicit helper imports enable better tree-shaking, make dependencies clear, and improve IDE support with proper type checking.
Reference: [Template Imports](https://github.com/ember-template-imports/ember-template-imports)
@@ -0,0 +1,79 @@
---
title: Use {{#let}} to Avoid Recomputation
impact: MEDIUM
impactDescription: 30-50% reduction in duplicate work
tags: templates, helpers, performance, optimization
---
## Use {{#let}} to Avoid Recomputation
Use `{{#let}}` to compute expensive values once and reuse them in the template instead of calling getters or helpers multiple times.
**Incorrect (recomputes on every reference):**
```glimmer-js
// app/components/user-card.gjs
<template>
<div class="user-card">
{{#if (and this.user.isActive (not this.user.isDeleted))}}
<h3>{{this.user.fullName}}</h3>
<p>Status: Active</p>
{{/if}}
{{#if (and this.user.isActive (not this.user.isDeleted))}}
<button {{on "click" this.editUser}}>Edit</button>
{{/if}}
{{#if (and this.user.isActive (not this.user.isDeleted))}}
<button {{on "click" this.deleteUser}}>Delete</button>
{{/if}}
</div>
</template>
```
**Correct (compute once, reuse):**
```glimmer-js
// app/components/user-card.gjs
<template>
{{#let (and this.user.isActive (not this.user.isDeleted)) as |isEditable|}}
<div class="user-card">
{{#if isEditable}}
<h3>{{this.user.fullName}}</h3>
<p>Status: Active</p>
{{/if}}
{{#if isEditable}}
<button {{on "click" this.editUser}}>Edit</button>
{{/if}}
{{#if isEditable}}
<button {{on "click" this.deleteUser}}>Delete</button>
{{/if}}
</div>
{{/let}}
</template>
```
**Multiple values:**
```glimmer-js
// app/components/checkout.gjs
<template>
{{#let
(this.calculateTotal this.items) (this.formatCurrency this.total) (this.hasDiscount this.user)
as |total formattedTotal showDiscount|
}}
<div class="checkout">
<p>Total: {{formattedTotal}}</p>
{{#if showDiscount}}
<p>Original: {{total}}</p>
<p>Discount Applied!</p>
{{/if}}
</div>
{{/let}}
</template>
```
`{{#let}}` computes values once and caches them for the block scope, reducing redundant calculations.
@@ -0,0 +1,220 @@
---
title: Template-Only Components with In-Scope Functions
impact: MEDIUM
impactDescription: Clean, performant patterns for template-only components
tags: templates, components, functions, performance
---
## Template-Only Components with In-Scope Functions
For template-only components (components without a class and `this`), use in-scope functions to keep logic close to the template while avoiding unnecessary caching overhead.
**Incorrect (using class-based component for simple logic):**
```glimmer-js
// app/components/product-card.gjs
import Component from '@glimmer/component';
export class ProductCard extends Component {
// Unnecessary class and overhead for simple formatting
formatPrice(price) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
}
<template>
<div class="product-card">
<h3>{{@product.name}}</h3>
<div class="price">{{this.formatPrice @product.price}}</div>
</div>
</template>
}
```
**Correct (template-only component with in-scope functions):**
```glimmer-js
// app/components/product-card.gjs
function formatPrice(price) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
}
function calculateDiscount(price, discountPercent) {
return price * (1 - discountPercent / 100);
}
function isOnSale(product) {
return product.discountPercent > 0;
}
<template>
<div class="product-card">
<h3>{{@product.name}}</h3>
{{#if (isOnSale @product)}}
<div class="price">
<span class="original">{{formatPrice @product.price}}</span>
<span class="sale">
{{formatPrice (calculateDiscount @product.price @product.discountPercent)}}
</span>
</div>
{{else}}
<div class="price">{{formatPrice @product.price}}</div>
{{/if}}
<p>{{@product.description}}</p>
</div>
</template>
```
**When to use class-based vs template-only:**
```glimmer-js
// Use class-based when:
// - You need @cached for expensive computations accessed multiple times
// - You have tracked state
// - You need lifecycle hooks or services
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
export class ProductList extends Component {
@cached
get sortedProducts() {
// Expensive sort, accessed in template multiple times
return [...this.args.products].sort((a, b) => a.name.localeCompare(b.name));
}
@cached
get filteredProducts() {
// Depends on sortedProducts - benefits from caching
return this.sortedProducts.filter((p) => p.category === this.args.selectedCategory);
}
<template>
{{#each this.filteredProducts as |product|}}
<div>{{product.name}}</div>
{{/each}}
</template>
}
```
```glimmer-js
// Use template-only when:
// - Simple transformations
// - Functions accessed once
// - No state or services needed
function formatDate(date) {
return new Date(date).toLocaleDateString();
}
<template>
<div class="timestamp">
Last updated:
{{formatDate @lastUpdate}}
</div>
</template>
```
**Combining in-scope functions for readability:**
```glimmer-js
// app/components/user-badge.gjs
function getInitials(name) {
return name
.split(' ')
.map((part) => part[0])
.join('')
.toUpperCase();
}
function getBadgeColor(status) {
const colors = {
active: 'green',
pending: 'yellow',
inactive: 'gray',
};
return colors[status] || 'gray';
}
<template>
<div class="user-badge" style="background-color: {{getBadgeColor @user.status}}">
<span class="initials">{{getInitials @user.name}}</span>
<span class="name">{{@user.name}}</span>
</div>
</template>
```
**Anti-pattern - Complex nested calls:**
```glimmer-js
// ❌ Hard to read, lots of nesting
<template>
<div>
{{formatCurrency (multiply (add @basePrice @taxAmount) @quantity)}}
</div>
</template>
// ✅ Better - use intermediate function
function calculateTotal(basePrice, taxAmount, quantity) {
return (basePrice + taxAmount) * quantity;
}
function formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}
<template>
<div>
{{formatCurrency (calculateTotal @basePrice @taxAmount @quantity)}}
</div>
</template>
```
**Key differences from class-based components:**
| Aspect | Template-Only | Class-Based |
| ---------------- | ------------------------ | ------------------------ |
| `this` context | ❌ No `this` | ✅ Has `this` |
| Function caching | ❌ Recreated each render | ✅ `@cached` available |
| Services | ❌ Cannot inject | ✅ `@service` decorator |
| Tracked state | ❌ No instance state | ✅ `@tracked` properties |
| Best for | Simple, stateless | Complex, stateful |
**Best practices:**
1. **Keep functions simple** - If computation is complex, consider a class with `@cached`
2. **One responsibility per function** - Makes them reusable and testable
3. **Minimize nesting** - Use intermediate functions for readability
4. **No side effects** - Functions should be pure transformations
5. **Export for testing** - Export functions so they can be tested independently
```glimmer-js
// app/components/stats-display.gjs
export function average(numbers) {
if (numbers.length === 0) return 0;
return numbers.reduce((sum, n) => sum + n, 0) / numbers.length;
}
export function round(number, decimals = 2) {
return Math.round(number * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
<template>
<div class="stats">
Average:
{{round (average @scores)}}
</div>
</template>
```
Reference: [Template-only Components](https://guides.emberjs.com/release/components/component-types/), [Component Authoring Best Practices](https://guides.emberjs.com/release/components/conditional-content/)
@@ -0,0 +1,329 @@
---
title: Provide DOM-Abstracted Test Utilities for Library Components
impact: MEDIUM
impactDescription: Stabilizes consumer tests against internal DOM refactors
tags: testing, test-support, libraries, dom-abstraction, maintainability
---
## Provide DOM-Abstracted Test Utilities for Library Components
**Impact: Medium** - Critical for library maintainability and consumer testing experience, especially important for team-based projects
## Problem
When building reusable components or libraries, consumers should not need to know implementation details or interact directly with the component's DOM. DOM structure should be considered **private** unless the author of the tests is the **owner** of the code being tested.
Without abstracted test utilities:
- Component refactoring breaks consumer tests
- Tests are tightly coupled to implementation details
- Teams waste time updating tests when internals change
- Testing becomes fragile and maintenance-heavy
## Solution
**Library authors should provide test utilities that fully abstract the DOM.** These utilities expose a public API for testing that remains stable even when internal implementation changes.
**Incorrect (exposing DOM to consumers):**
```glimmer-js
// my-library/src/components/data-grid.gjs
export class DataGrid extends Component {
<template>
<div class="data-grid">
<div class="data-grid__header">
<button class="sort-button" data-column="name">Name</button>
</div>
<div class="data-grid__body">
{{#each @rows as |row|}}
<div class="data-grid__row">{{row.name}}</div>
{{/each}}
</div>
</div>
</template>
}
```
```glimmer-js
// Consumer's test - tightly coupled to DOM
import { render, click } from '@ember/test-helpers';
import { DataGrid } from 'my-library';
test('sorting works', async function (assert) {
await render(<template><DataGrid @rows={{this.rows}} /></template>);
// Fragile: breaks if class names or structure change
await click('.data-grid__header .sort-button[data-column="name"]');
assert.dom('.data-grid__row:first-child').hasText('Alice');
});
```
**Problems:**
- Consumer knows about `.data-grid__header`, `.sort-button`, `[data-column]`
- Refactoring component structure breaks consumer tests
- No clear public API for testing
**Correct (providing DOM-abstracted test utilities):**
```glimmer-js
// my-library/src/test-support/data-grid.js
import { click, findAll } from '@ember/test-helpers';
/**
* Test utility for DataGrid component
* Provides stable API regardless of internal DOM structure
*/
export class DataGridTestHelper {
constructor(containerElement) {
this.container = containerElement;
}
/**
* Sort by column name
* @param {string} columnName - Column to sort by
*/
async sortBy(columnName) {
// Implementation detail hidden from consumer
const button = this.container.querySelector(`[data-test-sort="${columnName}"]`);
if (!button) {
throw new Error(`Column "${columnName}" not found`);
}
await click(button);
}
/**
* Get all row data
* @returns {Array<string>} Row text content
*/
getRows() {
return findAll('[data-test-row]', this.container).map((el) => el.textContent.trim());
}
/**
* Get row by index
* @param {number} index - Zero-based row index
* @returns {string} Row text content
*/
getRow(index) {
const rows = this.getRows();
return rows[index];
}
}
// Factory function for easier usage
export function getDataGrid(container = document) {
const gridElement = container.querySelector('[data-test-data-grid]');
if (!gridElement) {
throw new Error('DataGrid component not found');
}
return new DataGridTestHelper(gridElement);
}
```
```glimmer-js
// my-library/src/components/data-grid.gjs
// Component updated with test hooks (data-test-*)
export class DataGrid extends Component {
<template>
<div data-test-data-grid class="data-grid">
<div class="data-grid__header">
{{#each @columns as |column|}}
<button data-test-sort={{column.name}}>
{{column.label}}
</button>
{{/each}}
</div>
<div class="data-grid__body">
{{#each @rows as |row|}}
<div data-test-row class="data-grid__row">{{row.name}}</div>
{{/each}}
</div>
</div>
</template>
}
```
```glimmer-js
// Consumer's test - abstracted from DOM
import { render } from '@ember/test-helpers';
import { DataGrid } from 'my-library';
import { getDataGrid } from 'my-library/test-support';
test('sorting works', async function (assert) {
await render(<template><DataGrid @rows={{this.rows}} @columns={{this.columns}} /></template>);
const grid = getDataGrid();
// Clean API: no DOM knowledge required
await grid.sortBy('name');
assert.strictEqual(grid.getRow(0), 'Alice');
assert.deepEqual(grid.getRows(), ['Alice', 'Bob', 'Charlie']);
});
```
**Benefits:**
- Component internals can change without breaking consumer tests
- Clear, documented testing API
- Consumer tests are declarative and readable
- Library maintains API stability contract
## When This Matters Most
### Team-Based Projects (Critical)
On projects with teams, DOM abstraction prevents:
- Merge conflicts from test changes
- Cross-team coordination overhead
- Broken tests from uncoordinated refactoring
- Knowledge silos about component internals
### Solo Projects (Less Critical)
For solo projects, the benefit is smaller but still valuable:
- Easier refactoring without test maintenance
- Better separation of concerns
- Professional API design practice
## Best Practices
### 1. Use `data-test-*` Attributes
```glimmer-js
// Stable test hooks that won't conflict with styling
<button data-test-submit>Submit</button>
<div data-test-error-message>{{@errorMessage}}</div>
```
### 2. Document the Test API
```javascript
/**
* @class FormTestHelper
* @description Test utility for Form component
*
* @example
* const form = getForm();
* await form.fillIn('email', 'user@example.com');
* await form.submit();
* assert.strictEqual(form.getError(), 'Invalid email');
*/
```
### 3. Provide Semantic Methods
```javascript
// ✅ Semantic and declarative
await modal.close();
await form.fillIn('email', 'test@example.com');
assert.true(dropdown.isOpen());
// ❌ Exposes implementation
await click('.modal-close-button');
await fillIn('.form-field[name="email"]', 'test@example.com');
assert.dom('.dropdown.is-open').exists();
```
### 4. Handle Edge Cases
```javascript
export class FormTestHelper {
async fillIn(fieldName, value) {
const field = this.container.querySelector(`[data-test-field="${fieldName}"]`);
if (!field) {
throw new Error(
`Field "${fieldName}" not found. Available fields: ${this.getFieldNames().join(', ')}`,
);
}
await fillIn(field, value);
}
getFieldNames() {
return Array.from(this.container.querySelectorAll('[data-test-field]')).map(
(el) => el.dataset.testField,
);
}
}
```
## Example: Complete Test Utility
```javascript
// addon/test-support/modal.js
import { click, find, waitUntil } from '@ember/test-helpers';
export class ModalTestHelper {
constructor(container = document) {
this.container = container;
}
get element() {
return find('[data-test-modal]', this.container);
}
isOpen() {
return this.element !== null;
}
async waitForOpen() {
await waitUntil(() => this.isOpen(), { timeout: 1000 });
}
async waitForClose() {
await waitUntil(() => !this.isOpen(), { timeout: 1000 });
}
getTitle() {
const titleEl = find('[data-test-modal-title]', this.element);
return titleEl ? titleEl.textContent.trim() : null;
}
getBody() {
const bodyEl = find('[data-test-modal-body]', this.element);
return bodyEl ? bodyEl.textContent.trim() : null;
}
async close() {
if (!this.isOpen()) {
throw new Error('Cannot close modal: modal is not open');
}
await click('[data-test-modal-close]', this.element);
}
async clickButton(buttonText) {
const buttons = findAll('[data-test-modal-button]', this.element);
const button = buttons.find((btn) => btn.textContent.trim() === buttonText);
if (!button) {
const available = buttons.map((b) => b.textContent.trim()).join(', ');
throw new Error(`Button "${buttonText}" not found. Available: ${available}`);
}
await click(button);
}
}
export function getModal(container) {
return new ModalTestHelper(container);
}
```
## Performance Impact
**Before:** ~30-50% of test maintenance time spent updating selectors
**After:** Minimal test maintenance when refactoring components
## Related Patterns
- **component-avoid-classes-in-examples.md** - Avoid exposing implementation details
- **testing-modern-patterns.md** - Modern testing approaches
- **testing-render-patterns.md** - Component testing patterns
## References
- [Testing Best Practices - ember-learn](https://guides.emberjs.com/release/testing/)
- [ember-test-selectors](https://github.com/mainmatter/ember-test-selectors) - Addon for stripping test selectors from production
- [Page Objects Pattern](https://martinfowler.com/bliki/PageObject.html) - Related testing abstraction pattern
@@ -0,0 +1,340 @@
---
title: Use Modern Testing Patterns
impact: HIGH
impactDescription: Better test coverage and maintainability
tags: testing, qunit, test-helpers, integration-tests
---
## Use Modern Testing Patterns
Use modern Ember testing patterns with `@ember/test-helpers` and `qunit-dom` for better test coverage and maintainability.
**Incorrect (old testing patterns):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, find, click } from '@ember/test-helpers';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(<template><UserCard /></template>);
// Using find() instead of qunit-dom
assert.ok(find('.user-card'));
});
});
```
**Correct (modern testing patterns):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import { setupIntl } from 'ember-intl/test-support';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks);
test('it renders user information', async function (assert) {
const user = {
name: 'John Doe',
email: 'john@example.com',
avatarUrl: '/avatar.jpg',
};
await render(<template><UserCard @user={{user}} /></template>);
// qunit-dom assertions
assert.dom('[data-test-user-name]').hasText('John Doe');
assert.dom('[data-test-user-email]').hasText('john@example.com');
assert
.dom('[data-test-user-avatar]')
.hasAttribute('src', '/avatar.jpg')
.hasAttribute('alt', 'John Doe');
});
test('it handles edit action', async function (assert) {
assert.expect(1);
const user = { name: 'John Doe', email: 'john@example.com' };
const handleEdit = (editedUser) => {
assert.deepEqual(editedUser, user, 'Edit handler called with user');
};
await render(<template><UserCard @user={{user}} @onEdit={{handleEdit}} /></template>);
await click('[data-test-edit-button]');
});
});
```
**Component testing with reactive state:**
```glimmer-ts
// tests/integration/components/search-box-test.ts
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, fillIn } from '@ember/test-helpers';
import { trackedObject } from '@ember/reactive/collections';
import SearchBox from 'my-app/components/search-box';
module('Integration | Component | search-box', function (hooks) {
setupRenderingTest(hooks);
test('it performs search', async function (assert) {
// Use trackedObject for reactive state in tests
const state = trackedObject({
results: [] as string[],
});
const handleSearch = (query: string) => {
state.results = [`Result for ${query}`];
};
await render(
<template>
<SearchBox @onSearch={{handleSearch}} />
<ul data-test-results>
{{#each state.results as |result|}}
<li>{{result}}</li>
{{/each}}
</ul>
</template>,
);
await fillIn('[data-test-search-input]', 'ember');
// State updates reactively - no waitFor needed when using test-waiters
assert.dom('[data-test-results] li').hasText('Result for ember');
});
});
```
**Testing with ember-concurrency tasks:**
```glimmer-js
// app/components/async-button.js
import Component from '@glimmer/component';
import { task } from 'ember-concurrency';
export default class AsyncButtonComponent extends Component {
@task
*saveTask() {
yield this.args.onSave();
}
<template>
<button
type="button"
disabled={{this.saveTask.isRunning}}
{{on "click" (perform this.saveTask)}}
data-test-button
>
{{#if this.saveTask.isRunning}}
<span data-test-loading-spinner>Saving...</span>
{{else}}
{{yield}}
{{/if}}
</button>
</template>
}
```
```glimmer-js
// tests/integration/components/async-button-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import AsyncButton from 'my-app/components/async-button';
module('Integration | Component | async-button', function (hooks) {
setupRenderingTest(hooks);
test('it shows loading state during task execution', async function (assert) {
let resolveTask;
const onSave = () => {
return new Promise((resolve) => {
resolveTask = resolve;
});
};
await render(
<template>
<AsyncButton @onSave={{onSave}}>
Save
</AsyncButton>
</template>,
);
// Trigger the task
await click('[data-test-button]');
// ember-concurrency automatically registers test waiters
// The button will be disabled while the task runs
assert.dom('[data-test-button]').hasAttribute('disabled');
assert.dom('[data-test-loading-spinner]').hasText('Saving...');
// Resolve the task
resolveTask();
// No need to call settled() - ember-concurrency's test waiters handle this
assert.dom('[data-test-button]').doesNotHaveAttribute('disabled');
assert.dom('[data-test-loading-spinner]').doesNotExist();
assert.dom('[data-test-button]').hasText('Save');
});
});
```
**When to use test-waiters with ember-concurrency:**
- **ember-concurrency auto-registers test waiters** - You don't need to manually register test waiters for ember-concurrency tasks. The library automatically waits for tasks to complete before test helpers like `click()`, `fillIn()`, etc. resolve.
- **You still need test-waiters when:**
- Using raw Promises outside of ember-concurrency tasks
- Working with third-party async operations that don't integrate with Ember's test waiter system
- Creating custom async behavior that needs to pause test execution
- **You DON'T need additional test-waiters when:**
- Using ember-concurrency tasks (already handled)
- Using Ember Data operations (already handled)
- Using `settled()` from `@ember/test-helpers` (already coordinates with test waiters)
- **Note**: `waitFor()` and `waitUntil()` from `@ember/test-helpers` are code smells - if you need them, it indicates missing test-waiters in your code. Instrument your async operations with test-waiters instead.
**Route testing with MSW (Mock Service Worker):**
```javascript
// tests/acceptance/posts-test.js
import { module, test } from 'qunit';
import { visit, currentURL, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { http, HttpResponse } from 'msw';
import { setupMSW } from 'my-app/tests/helpers/msw';
module('Acceptance | posts', function (hooks) {
setupApplicationTest(hooks);
const { server } = setupMSW(hooks);
test('visiting /posts', async function (assert) {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({
data: [
{ id: '1', type: 'post', attributes: { title: 'Post 1' } },
{ id: '2', type: 'post', attributes: { title: 'Post 2' } },
{ id: '3', type: 'post', attributes: { title: 'Post 3' } },
],
});
}),
);
await visit('/posts');
assert.strictEqual(currentURL(), '/posts');
assert.dom('[data-test-post-item]').exists({ count: 3 });
});
test('clicking a post navigates to detail', async function (assert) {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({
data: [{ id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } }],
});
}),
http.get('/api/posts/test-post', () => {
return HttpResponse.json({
data: { id: '1', type: 'post', attributes: { title: 'Test Post', slug: 'test-post' } },
});
}),
);
await visit('/posts');
await click('[data-test-post-item]:first-child');
assert.strictEqual(currentURL(), '/posts/test-post');
assert.dom('[data-test-post-title]').hasText('Test Post');
});
});
```
**Note:** Use MSW (Mock Service Worker) for API mocking instead of Mirage. MSW provides better conventions and doesn't lead developers astray. See `testing-msw-setup.md` for detailed setup instructions.
**Accessibility testing:**
```glimmer-js
// tests/integration/components/modal-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import a11yAudit from 'ember-a11y-testing/test-support/audit';
import Modal from 'my-app/components/modal';
module('Integration | Component | modal', function (hooks) {
setupRenderingTest(hooks);
test('it passes accessibility audit', async function (assert) {
await render(
<template>
<Modal @isOpen={{true}} @title="Test Modal">
<p>Modal content</p>
</Modal>
</template>,
);
await a11yAudit();
assert.ok(true, 'no a11y violations');
});
test('it traps focus', async function (assert) {
await render(
<template>
<Modal @isOpen={{true}}>
<button data-test-first>First</button>
<button data-test-last>Last</button>
</Modal>
</template>,
);
assert.dom('[data-test-first]').isFocused();
// Tab should stay within modal
await click('[data-test-last]');
assert.dom('[data-test-last]').isFocused();
});
});
```
**Testing with data-test attributes:**
```glimmer-js
// app/components/user-profile.gjs
import Component from '@glimmer/component';
class UserProfile extends Component {
<template>
<div class="user-profile" data-test-user-profile>
<img src={{@user.avatar}} alt={{@user.name}} data-test-avatar />
<h2 data-test-name>{{@user.name}}</h2>
<p data-test-email>{{@user.email}}</p>
{{#if @onEdit}}
<button {{on "click" (fn @onEdit @user)}} data-test-edit-button>
Edit
</button>
{{/if}}
</div>
</template>
}
```
Modern testing patterns with `@ember/test-helpers`, `qunit-dom`, and data-test attributes provide better test reliability, readability, and maintainability.
Reference: [Ember Testing](https://guides.emberjs.com/release/testing/)
@@ -0,0 +1,541 @@
---
title: MSW (Mock Service Worker) Setup for Testing
impact: HIGH
impactDescription: Proper API mocking without ORM complexity
tags: testing, msw, api-mocking, mock-service-worker
---
## MSW (Mock Service Worker) Setup for Testing
Use MSW (Mock Service Worker) for API mocking in tests. MSW provides a cleaner approach than Mirage by intercepting requests at the network level without introducing unnecessary ORM patterns or abstractions.
**Incorrect (using Mirage with ORM complexity):**
```javascript
// mirage/config.js
export default function () {
this.namespace = '/api';
// Complex schema and factories
this.get('/users', (schema) => {
return schema.users.all();
});
// Need to maintain schema, factories, serializers
this.post('/users', (schema, request) => {
let attrs = JSON.parse(request.requestBody);
return schema.users.create(attrs);
});
}
```
**Correct (using MSW with simple network mocking):**
```javascript
// tests/helpers/msw.js
import { http, HttpResponse } from 'msw';
// Simple request/response mocking
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
}),
http.post('/api/users', async ({ request }) => {
const user = await request.json();
return HttpResponse.json({ id: 3, ...user }, { status: 201 });
}),
];
```
**Why MSW over Mirage:**
- **Better conventions** - Mock at the network level, not with an ORM
- **Simpler mental model** - Define request handlers, return responses
- **Doesn't lead developers astray** - No schema migrations or factories to maintain
- **Works everywhere** - Same mocks work in tests, Storybook, and development
- **More realistic** - Actually intercepts fetch/XMLHttpRequest
Reference: [Ember.js Community Discussion on MSW](https://discuss.emberjs.com/t/my-cookbook-for-various-emberjs-things/19679)
### Installation
```bash
npm install --save-dev msw
```
### Setup Test Helper
Create a test helper to set up MSW in your tests:
```javascript
// tests/helpers/msw.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
// Define default handlers that apply to all tests
const defaultHandlers = [
// Add default handlers here if needed
];
export function setupMSW(hooks, handlers = []) {
const server = setupServer(...defaultHandlers, ...handlers);
hooks.beforeEach(function () {
server.listen({ onUnhandledRequest: 'warn' });
});
hooks.afterEach(function () {
server.resetHandlers();
});
hooks.after(function () {
server.close();
});
return { server };
}
// Re-export for convenience
export { http, HttpResponse };
```
### Basic Usage in Tests
```javascript
// tests/acceptance/users-test.js
import { module, test } from 'qunit';
import { visit, currentURL, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMSW, http, HttpResponse } from 'my-app/tests/helpers/msw';
module('Acceptance | users', function (hooks) {
setupApplicationTest(hooks);
const { server } = setupMSW(hooks);
test('displays list of users', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({
data: [
{
id: '1',
type: 'user',
attributes: { name: 'Alice', email: 'alice@example.com' },
},
{
id: '2',
type: 'user',
attributes: { name: 'Bob', email: 'bob@example.com' },
},
],
});
}),
);
await visit('/users');
assert.strictEqual(currentURL(), '/users');
assert.dom('[data-test-user-item]').exists({ count: 2 });
assert.dom('[data-test-user-name]').hasText('Alice');
});
test('handles server errors gracefully', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({ errors: [{ title: 'Server Error' }] }, { status: 500 });
}),
);
await visit('/users');
assert.dom('[data-test-error-message]').exists();
assert.dom('[data-test-error-message]').containsText('Server Error');
});
});
```
### Mocking POST/PUT/DELETE Requests
```javascript
import { visit, click, fillIn } from '@ember/test-helpers';
test('creates a new user', async function (assert) {
let capturedRequest = null;
server.use(
http.post('/api/users', async ({ request }) => {
capturedRequest = await request.json();
return HttpResponse.json(
{
data: {
id: '3',
type: 'user',
attributes: capturedRequest.data.attributes,
},
},
{ status: 201 },
);
}),
);
await visit('/users/new');
await fillIn('[data-test-name-input]', 'Charlie');
await fillIn('[data-test-email-input]', 'charlie@example.com');
await click('[data-test-submit-button]');
assert.strictEqual(currentURL(), '/users/3');
assert.deepEqual(capturedRequest.data.attributes, {
name: 'Charlie',
email: 'charlie@example.com',
});
});
test('updates an existing user', async function (assert) {
server.use(
http.get('/api/users/1', () => {
return HttpResponse.json({
data: {
id: '1',
type: 'user',
attributes: { name: 'Alice', email: 'alice@example.com' },
},
});
}),
http.patch('/api/users/1', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
data: {
id: '1',
type: 'user',
attributes: body.data.attributes,
},
});
}),
);
await visit('/users/1/edit');
await fillIn('[data-test-name-input]', 'Alice Updated');
await click('[data-test-submit-button]');
assert.dom('[data-test-user-name]').hasText('Alice Updated');
});
test('deletes a user', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
});
}),
http.delete('/api/users/1', () => {
return new HttpResponse(null, { status: 204 });
}),
);
await visit('/users');
await click('[data-test-delete-button]');
assert.dom('[data-test-user-item]').doesNotExist();
});
```
### Query Parameters and Dynamic Routes
```javascript
test('filters users by query parameter', async function (assert) {
server.use(
http.get('/api/users', ({ request }) => {
const url = new URL(request.url);
const searchQuery = url.searchParams.get('filter[name]');
const users = [
{ id: '1', type: 'user', attributes: { name: 'Alice' } },
{ id: '2', type: 'user', attributes: { name: 'Bob' } },
];
const filtered = searchQuery
? users.filter((u) => u.attributes.name.includes(searchQuery))
: users;
return HttpResponse.json({ data: filtered });
}),
);
await visit('/users?filter[name]=Alice');
assert.dom('[data-test-user-item]').exists({ count: 1 });
assert.dom('[data-test-user-name]').hasText('Alice');
});
test('handles dynamic route segments', async function (assert) {
server.use(
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
data: {
id: params.id,
type: 'user',
attributes: { name: `User ${params.id}` },
},
});
}),
);
await visit('/users/42');
assert.dom('[data-test-user-name]').hasText('User 42');
});
```
### Network Delays and Race Conditions
```javascript
test('handles slow network responses', async function (assert) {
server.use(
http.get('/api/users', async () => {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 100));
return HttpResponse.json({
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
});
}),
);
const visitPromise = visit('/users');
// Loading state should be visible
assert.dom('[data-test-loading-spinner]').exists();
await visitPromise;
assert.dom('[data-test-loading-spinner]').doesNotExist();
assert.dom('[data-test-user-item]').exists();
});
```
### Shared Handlers with Reusable Fixtures
```javascript
// tests/helpers/msw-handlers.js
import { http, HttpResponse } from 'msw';
export const userHandlers = {
list: (users = []) => {
return http.get('/api/users', () => {
return HttpResponse.json({ data: users });
});
},
get: (user) => {
return http.get(`/api/users/${user.id}`, () => {
return HttpResponse.json({ data: user });
});
},
create: (attributes) => {
return http.post('/api/users', () => {
return HttpResponse.json(
{
data: {
id: String(Math.random()),
type: 'user',
attributes,
},
},
{ status: 201 },
);
});
},
};
// Common fixtures
export const fixtures = {
users: {
alice: {
id: '1',
type: 'user',
attributes: { name: 'Alice', email: 'alice@example.com' },
},
bob: {
id: '2',
type: 'user',
attributes: { name: 'Bob', email: 'bob@example.com' },
},
},
};
```
```javascript
// tests/acceptance/users-test.js
import { userHandlers, fixtures } from 'my-app/tests/helpers/msw-handlers';
test('displays list of users', async function (assert) {
server.use(userHandlers.list([fixtures.users.alice, fixtures.users.bob]));
await visit('/users');
assert.dom('[data-test-user-item]').exists({ count: 2 });
});
```
### Integration Test Setup
MSW works in integration tests too:
```javascript
// tests/integration/components/user-list-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, waitFor } from '@ember/test-helpers';
import { setupMSW, http, HttpResponse } from 'my-app/tests/helpers/msw';
import UserList from 'my-app/components/user-list';
module('Integration | Component | user-list', function (hooks) {
setupRenderingTest(hooks);
const { server } = setupMSW(hooks);
test('fetches and displays users', async function (assert) {
server.use(
http.get('/api/users', () => {
return HttpResponse.json({
data: [{ id: '1', type: 'user', attributes: { name: 'Alice' } }],
});
}),
);
await render(
<template>
<UserList />
</template>,
);
// Wait for async data to load
await waitFor('[data-test-user-item]');
assert.dom('[data-test-user-item]').exists();
assert.dom('[data-test-user-name]').hasText('Alice');
});
});
```
### Best Practices
1. **Define handlers per test** - Use `server.use()` in individual tests rather than global handlers
2. **Reset between tests** - The helper automatically resets handlers after each test
3. **Use JSON:API format** - Keep responses consistent with your API format
4. **Test error states** - Mock various HTTP error codes (400, 401, 403, 404, 500)
5. **Capture requests** - Use the request object to verify what your app sent
6. **Use fixtures** - Create reusable test data to keep tests DRY
7. **Simulate delays** - Test loading states with artificial delays
8. **Type-safe responses** - In TypeScript, type your response payloads
### Common Patterns
**Default handlers for all tests:**
```javascript
// tests/helpers/msw.js
const defaultHandlers = [
// Always return current user
http.get('/api/current-user', () => {
return HttpResponse.json({
data: {
id: '1',
type: 'user',
attributes: { name: 'Test User', role: 'admin' },
},
});
}),
];
```
**One-time handlers (don't persist):**
```javascript
// MSW handlers persist until resetHandlers() is called
// The test helper automatically resets after each test
// For a one-time handler within a test, manually reset:
test('one-time response', async function (assert) {
server.use(
http.get('/api/special', () => {
return HttpResponse.json({ data: 'special' });
}),
);
// First request gets mocked response
await visit('/special');
assert.dom('[data-test-data]').hasText('special');
// Reset to remove this handler
server.resetHandlers();
// Subsequent requests will use default handlers or be unhandled
});
```
**Conditional responses:**
```javascript
http.post('/api/login', async ({ request }) => {
const { email, password } = await request.json();
if (email === 'test@example.com' && password === 'password') {
return HttpResponse.json({
data: { token: 'abc123' },
});
}
return HttpResponse.json({ errors: [{ title: 'Invalid credentials' }] }, { status: 401 });
});
```
### Migration from Mirage
If migrating from Mirage:
1. Remove `ember-cli-mirage` dependency
2. Delete `mirage/` directory (models, factories, scenarios)
3. Install MSW: `npm install --save-dev msw`
4. Create the MSW test helper (see above)
5. Replace `setupMirage(hooks)` with `setupMSW(hooks)`
6. Convert Mirage handlers:
- `this.server.get()``http.get()`
- `this.server.create()` → Return inline JSON
- `this.server.createList()` → Return array of JSON objects
**Before (Mirage):**
```javascript
test('lists posts', async function (assert) {
this.server.createList('post', 3);
await visit('/posts');
assert.dom('[data-test-post]').exists({ count: 3 });
});
```
**After (MSW):**
```javascript
test('lists posts', async function (assert) {
server.use(
http.get('/api/posts', () => {
return HttpResponse.json({
data: [
{ id: '1', type: 'post', attributes: { title: 'Post 1' } },
{ id: '2', type: 'post', attributes: { title: 'Post 2' } },
{ id: '3', type: 'post', attributes: { title: 'Post 3' } },
],
});
}),
);
await visit('/posts');
assert.dom('[data-test-post]').exists({ count: 3 });
});
```
Reference: [MSW Documentation](https://mswjs.io/docs/)
@@ -0,0 +1,323 @@
---
title: Use qunit-dom for Better Test Assertions
impact: MEDIUM
impactDescription: More readable and maintainable tests
tags: testing, qunit-dom, assertions, best-practices
---
## Use qunit-dom for Better Test Assertions
Use `qunit-dom` for DOM assertions in tests. It provides expressive, chainable assertions that make tests more readable and provide better error messages than raw QUnit assertions.
**Why qunit-dom:**
- More expressive and readable test assertions
- Better error messages when tests fail
- Type-safe with TypeScript
- Reduces boilerplate in DOM testing
### Basic DOM Assertions
**Incorrect (verbose QUnit assertions):**
```javascript
// tests/integration/components/greeting-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
module('Integration | Component | greeting', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(<template><Greeting @name="World" /></template>);
const element = this.element.querySelector('.greeting');
assert.ok(element, 'greeting element exists');
assert.equal(element.textContent.trim(), 'Hello, World!', 'shows greeting');
assert.ok(element.classList.contains('greeting'), 'has greeting class');
});
});
```
**Correct (expressive qunit-dom):**
```javascript
// tests/integration/components/greeting-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
module('Integration | Component | greeting', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(<template><Greeting @name="World" /></template>);
assert.dom('.greeting').exists('greeting element exists');
assert.dom('.greeting').hasText('Hello, World!', 'shows greeting');
});
});
```
### Common Assertions
**Existence and Visibility:**
```javascript
test('element visibility', async function (assert) {
await render(
<template>
<MyComponent />
</template>,
);
// Element exists in DOM
assert.dom('[data-test-output]').exists();
// Element doesn't exist
assert.dom('[data-test-deleted]').doesNotExist();
// Element is visible (not display: none or visibility: hidden)
assert.dom('[data-test-visible]').isVisible();
// Element is not visible
assert.dom('[data-test-hidden]').isNotVisible();
// Count elements
assert.dom('[data-test-item]').exists({ count: 3 });
});
```
**Text Content:**
```javascript
test('text assertions', async function (assert) {
await render(<template><Article @title="Hello World" /></template>);
// Exact text match
assert.dom('h1').hasText('Hello World');
// Contains text (partial match)
assert.dom('p').containsText('Hello');
// Any text exists
assert.dom('h1').hasAnyText();
// No text
assert.dom('.empty').hasNoText();
});
```
**Attributes:**
```javascript
test('attribute assertions', async function (assert) {
await render(<template><Button @disabled={{true}} /></template>);
// Has attribute (any value)
assert.dom('button').hasAttribute('disabled');
// Has specific attribute value
assert.dom('button').hasAttribute('type', 'submit');
// Attribute value matches regex
assert.dom('a').hasAttribute('href', /^https:\/\//);
// Doesn't have attribute
assert.dom('button').doesNotHaveAttribute('aria-hidden');
// Has ARIA attributes
assert.dom('[role="button"]').hasAttribute('aria-label', 'Close dialog');
});
```
**Classes:**
```javascript
test('class assertions', async function (assert) {
await render(<template><Card @status="active" /></template>);
// Has single class
assert.dom('.card').hasClass('active');
// Doesn't have class
assert.dom('.card').doesNotHaveClass('disabled');
// Has no classes at all
assert.dom('.plain').hasNoClass();
});
```
**Form Elements:**
```javascript
test('form assertions', async function (assert) {
await render(
<template>
<form>
<input type="text" value="hello" />
<input type="checkbox" checked />
<input type="radio" disabled />
<select>
<option selected>Option 1</option>
</select>
</form>
</template>,
);
// Input value
assert.dom('input[type="text"]').hasValue('hello');
// Checkbox/radio state
assert.dom('input[type="checkbox"]').isChecked();
assert.dom('input[type="checkbox"]').isNotChecked();
// Disabled state
assert.dom('input[type="radio"]').isDisabled();
assert.dom('input[type="text"]').isNotDisabled();
// Required state
assert.dom('input').isRequired();
assert.dom('input').isNotRequired();
// Focus state
assert.dom('input').isFocused();
assert.dom('input').isNotFocused();
});
```
### Chaining Assertions
You can chain multiple assertions on the same element:
```javascript
test('chained assertions', async function (assert) {
await render(<template><Button @variant="primary" @disabled={{false}} /></template>);
assert.dom('button')
.exists()
.hasClass('btn-primary')
.hasAttribute('type', 'button')
.isNotDisabled()
.hasText('Submit')
.isVisible();
});
```
### Custom Error Messages
Add custom messages to make failures clearer:
```javascript
test('custom messages', async function (assert) {
await render(<template><UserProfile @user={{this.user}} /></template>);
assert.dom('[data-test-username]')
.hasText(this.user.name, 'username is displayed correctly');
assert.dom('[data-test-avatar]')
.exists('user avatar should be visible');
});
```
### Testing Counts
```javascript
test('list items', async function (assert) {
await render(<template>
<TodoList @todos={{this.todos}} />
</template>);
// Exact count
assert.dom('[data-test-todo]').exists({ count: 5 });
// At least one
assert.dom('[data-test-todo]').exists({ count: 1 });
// None
assert.dom('[data-test-todo]').doesNotExist();
});
```
### Accessibility Testing
Use qunit-dom for basic accessibility checks:
```javascript
test('accessibility', async function (assert) {
await render(<template><Modal @onClose={{this.close}} /></template>);
// ARIA roles
assert.dom('[role="dialog"]').exists();
assert.dom('[role="dialog"]').hasAttribute('aria-modal', 'true');
// Labels
assert.dom('[aria-label="Close modal"]').exists();
// Focus management
assert.dom('[data-test-close-button]').isFocused();
// Required fields
assert.dom('input[name="email"]').hasAttribute('aria-required', 'true');
});
```
### Best Practices
1. **Use data-test attributes** for test selectors instead of classes:
```javascript
// Good
assert.dom('[data-test-submit-button]').exists();
// Avoid - classes can change
assert.dom('.btn.btn-primary').exists();
```
2. **Make assertions specific**:
```javascript
// Better - exact match
assert.dom('h1').hasText('Welcome');
// Less specific - could miss issues
assert.dom('h1').containsText('Welc');
```
3. **Use meaningful custom messages**:
```javascript
assert.dom('[data-test-error]').hasText('Invalid email', 'shows correct validation error');
```
4. **Combine with @ember/test-helpers**:
```javascript
import { click, fillIn } from '@ember/test-helpers';
await fillIn('[data-test-email]', 'user@example.com');
await click('[data-test-submit]');
assert.dom('[data-test-success]').exists();
```
5. **Test user-visible behavior**, not implementation:
```javascript
// Good - tests what user sees
assert.dom('[data-test-greeting]').hasText('Hello, Alice');
// Avoid - tests implementation details
assert.ok(this.component.internalState === 'ready');
```
qunit-dom makes your tests more maintainable and easier to understand. It comes pre-installed with `ember-qunit`, so you can start using it immediately.
**References:**
- [qunit-dom Documentation](https://github.com/mainmatter/qunit-dom)
- [qunit-dom API](https://github.com/mainmatter/qunit-dom/blob/master/API.md)
- [Ember Testing Guide](https://guides.emberjs.com/release/testing/)
@@ -0,0 +1,257 @@
---
title: Use Appropriate Render Patterns in Tests
impact: MEDIUM
impactDescription: Simpler test code and better readability
tags: testing, render, component-testing, test-helpers
---
## Use Appropriate Render Patterns in Tests
Choose the right rendering pattern based on whether your component needs arguments, blocks, or attributes in the test.
**Incorrect (using template tag unnecessarily):**
```javascript
// tests/integration/components/loading-spinner-test.js
import { render } from '@ember/test-helpers';
import LoadingSpinner from 'my-app/components/loading-spinner';
test('it renders', async function (assert) {
// ❌ Unnecessary template wrapper for component with no args
await render(
<template>
<LoadingSpinner />
</template>,
);
assert.dom('[data-test-spinner]').exists();
});
```
**Correct (direct component render when no args needed):**
```javascript
// tests/integration/components/loading-spinner-test.js
import { render } from '@ember/test-helpers';
import LoadingSpinner from 'my-app/components/loading-spinner';
test('it renders', async function (assert) {
// ✅ Simple: pass component directly when no args needed
await render(LoadingSpinner);
assert.dom('[data-test-spinner]').exists();
});
```
**Pattern 1: Direct component render (no args/blocks/attributes):**
```javascript
// tests/integration/components/loading-spinner-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import LoadingSpinner from 'my-app/components/loading-spinner';
module('Integration | Component | loading-spinner', function (hooks) {
setupRenderingTest(hooks);
test('it renders without arguments', async function (assert) {
// ✅ Simple: pass component directly when no args needed
await render(LoadingSpinner);
assert.dom('[data-test-spinner]').exists();
assert.dom('[data-test-spinner]').hasClass('loading');
});
});
```
**Pattern 2: Template tag render (with args/blocks/attributes):**
```glimmer-js
// tests/integration/components/user-card-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import UserCard from 'my-app/components/user-card';
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
test('it renders with arguments', async function (assert) {
const user = { name: 'John Doe', email: 'john@example.com' };
// ✅ Use template tag when passing arguments
await render(<template><UserCard @user={{user}} /></template>);
assert.dom('[data-test-user-name]').hasText('John Doe');
});
test('it renders with block content', async function (assert) {
// ✅ Use template tag when providing blocks
await render(
<template>
<UserCard>
<:header>Custom Header</:header>
<:body>Custom Content</:body>
</UserCard>
</template>,
);
assert.dom('[data-test-header]').hasText('Custom Header');
assert.dom('[data-test-body]').hasText('Custom Content');
});
test('it renders with HTML attributes', async function (assert) {
// ✅ Use template tag when passing HTML attributes
await render(<template><UserCard class="featured" data-test-featured /></template>);
assert.dom('[data-test-featured]').exists();
assert.dom('[data-test-featured]').hasClass('featured');
});
});
```
**Complete example showing both patterns:**
```glimmer-js
// tests/integration/components/button-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import Button from 'my-app/components/button';
module('Integration | Component | button', function (hooks) {
setupRenderingTest(hooks);
test('it renders default button', async function (assert) {
// ✅ No args needed - use direct render
await render(Button);
assert.dom('button').exists();
assert.dom('button').hasText('Click me');
});
test('it renders with custom text', async function (assert) {
// ✅ Needs block content - use template tag
await render(
<template>
<Button>Submit Form</Button>
</template>,
);
assert.dom('button').hasText('Submit Form');
});
test('it handles click action', async function (assert) {
assert.expect(1);
const handleClick = () => {
assert.ok(true, 'Click handler called');
};
// ✅ Needs argument - use template tag
await render(
<template>
<Button @onClick={{handleClick}}>Click me</Button>
</template>,
);
await click('button');
});
test('it applies variant styling', async function (assert) {
// ✅ Needs argument - use template tag
await render(
<template>
<Button @variant="primary">Primary Button</Button>
</template>,
);
assert.dom('button').hasClass('btn-primary');
});
});
```
**Testing template-only components:**
```glimmer-js
// tests/integration/components/icon-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import Icon from 'my-app/components/icon';
module('Integration | Component | icon', function (hooks) {
setupRenderingTest(hooks);
test('it renders default icon', async function (assert) {
// ✅ Template-only component with no args - use direct render
await render(Icon);
assert.dom('[data-test-icon]').exists();
});
test('it renders specific icon', async function (assert) {
// ✅ Needs @name argument - use template tag
await render(<template><Icon @name="check" @size="large" /></template>);
assert.dom('[data-test-icon]').hasAttribute('data-icon', 'check');
assert.dom('[data-test-icon]').hasClass('icon-large');
});
});
```
**Decision guide:**
| Scenario | Pattern | Example |
| ----------------------------------- | ---------------------------------- | ----------------------------------------------------------- |
| No arguments, blocks, or attributes | `render(Component)` | `render(LoadingSpinner)` |
| Component needs arguments | `render(<template>...</template>)` | `render(<template><Card @title="Hello" /></template>)` |
| Component receives block content | `render(<template>...</template>)` | `render(<template><Card>Content</Card></template>)` |
| Component needs HTML attributes | `render(<template>...</template>)` | `render(<template><Card class="featured" /></template>)` |
| Multiple test context properties | `render(<template>...</template>)` | `render(<template><Card @data={{this.data}} /></template>)` |
**Why this matters:**
- **Simplicity**: Direct render reduces boilerplate for simple cases
- **Clarity**: Template syntax makes data flow explicit when needed
- **Consistency**: Clear pattern helps teams write maintainable tests
- **Type Safety**: Both patterns work with TypeScript for component types
**Common patterns:**
```glimmer-js
// ✅ Simple component, no setup needed
await render(LoadingSpinner);
await render(Divider);
await render(Logo);
// ✅ Component with arguments from test context
await render(
<template><UserList @users={{this.users}} @onSelect={{this.handleSelect}} /></template>,
);
// ✅ Component with named blocks
await render(
<template>
<Modal>
<:header>Title</:header>
<:body>Content</:body>
<:footer><button>Close</button></:footer>
</Modal>
</template>,
);
// ✅ Component with splattributes
await render(
<template>
<Card class="highlighted" data-test-card role="article">
Card content
</Card>
</template>,
);
```
Using the appropriate render pattern keeps tests clean and expressive.
Reference: [Ember Testing Guide](https://guides.emberjs.com/release/testing/)
@@ -0,0 +1,309 @@
---
title: Use Test Waiters for Async Operations
impact: HIGH
impactDescription: Reliable tests that don't depend on implementation details
tags: testing, async, test-waiters, waitFor, settled
---
## Use Test Waiters for Async Operations
Instrument async code with test waiters instead of using `waitFor()` or `waitUntil()` in tests. Test waiters abstract async implementation details so tests focus on user behavior rather than timing.
**Why Test Waiters Matter:**
Test waiters allow `settled()` and other test helpers to automatically wait for your async operations. This means:
- Tests don't need to know about implementation details (timeouts, polling intervals, etc.)
- Tests are written from a user's perspective ("click button, see result")
- Code refactoring doesn't break tests
- Tests are more reliable and less flaky
**Incorrect (testing implementation details):**
```glimmer-js
// tests/integration/components/data-loader-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, waitFor } from '@ember/test-helpers';
import DataLoader from 'my-app/components/data-loader';
module('Integration | Component | data-loader', function (hooks) {
setupRenderingTest(hooks);
test('it loads data', async function (assert) {
await render(<template><DataLoader /></template>);
await click('[data-test-load-button]');
// BAD: Test knows about implementation details
// If the component changes from polling every 100ms to 200ms, test breaks
await waitFor('[data-test-data]', { timeout: 5000 });
assert.dom('[data-test-data]').hasText('Loaded data');
});
});
```
**Correct (using test waiters):**
```glimmer-js
// app/components/data-loader.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('data-loader');
export class DataLoader extends Component {
@tracked data = null;
@tracked isLoading = false;
loadData = async () => {
// Register the async operation with test waiter
const token = waiter.beginAsync();
try {
this.isLoading = true;
// Simulate async data loading
const response = await fetch('/api/data');
this.data = await response.json();
} finally {
this.isLoading = false;
// Always end the async operation, even on error
waiter.endAsync(token);
}
};
<template>
<div>
<button {{on "click" this.loadData}} data-test-load-button>
Load Data
</button>
{{#if this.isLoading}}
<div data-test-loading>Loading...</div>
{{/if}}
{{#if this.data}}
<div data-test-data>{{this.data}}</div>
{{/if}}
</div>
</template>
}
```
```glimmer-js
// tests/integration/components/data-loader-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, settled } from '@ember/test-helpers';
import DataLoader from 'my-app/components/data-loader';
module('Integration | Component | data-loader', function (hooks) {
setupRenderingTest(hooks);
test('it loads data', async function (assert) {
await render(<template><DataLoader /></template>);
await click('[data-test-load-button]');
// GOOD: settled() automatically waits for test waiters
// No knowledge of timing needed - tests from user's perspective
await settled();
assert.dom('[data-test-data]').hasText('Loaded data');
});
});
```
**Test waiter with cleanup:**
```glimmer-js
// app/components/polling-widget.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { registerDestructor } from '@ember/destroyable';
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('polling-widget');
export class PollingWidget extends Component {
@tracked status = 'idle';
intervalId = null;
token = null;
constructor(owner, args) {
super(owner, args);
registerDestructor(this, () => {
this.stopPolling();
});
}
startPolling = () => {
// Register async operation
this.token = waiter.beginAsync();
this.intervalId = setInterval(() => {
this.checkStatus();
}, 1000);
};
stopPolling = () => {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
// End async operation on cleanup
if (this.token) {
waiter.endAsync(this.token);
this.token = null;
}
};
checkStatus = async () => {
const response = await fetch('/api/status');
this.status = await response.text();
if (this.status === 'complete') {
this.stopPolling();
}
};
<template>
<div>
<button {{on "click" this.startPolling}} data-test-start>
Start Polling
</button>
<div data-test-status>{{this.status}}</div>
</div>
</template>
}
```
**Test waiter with Services:**
```glimmer-js
// app/services/data-sync.js
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('data-sync-service');
export class DataSyncService extends Service {
@tracked isSyncing = false;
async sync() {
const token = waiter.beginAsync();
try {
this.isSyncing = true;
const response = await fetch('/api/sync', { method: 'POST' });
const result = await response.json();
return result;
} finally {
this.isSyncing = false;
waiter.endAsync(token);
}
}
}
```
```glimmer-js
// tests/unit/services/data-sync-test.js
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { settled } from '@ember/test-helpers';
module('Unit | Service | data-sync', function (hooks) {
setupTest(hooks);
test('it syncs data', async function (assert) {
const service = this.owner.lookup('service:data-sync');
// Start async operation
const syncPromise = service.sync();
// No need for manual waiting - settled() handles it
await settled();
const result = await syncPromise;
assert.ok(result, 'Sync completed successfully');
});
});
```
**Multiple concurrent operations:**
```glimmer-js
// app/components/parallel-loader.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('parallel-loader');
export class ParallelLoader extends Component {
@tracked results = [];
loadAll = async () => {
const urls = ['/api/data1', '/api/data2', '/api/data3'];
// Each request gets its own token
const requests = urls.map(async (url) => {
const token = waiter.beginAsync();
try {
const response = await fetch(url);
return await response.json();
} finally {
waiter.endAsync(token);
}
});
this.results = await Promise.all(requests);
};
<template>
<button {{on "click" this.loadAll}} data-test-load-all>
Load All
</button>
{{#each this.results as |result|}}
<div data-test-result>{{result}}</div>
{{/each}}
</template>
}
```
**Benefits:**
1. **User-focused tests**: Tests describe user actions, not implementation
2. **Resilient to refactoring**: Change timing/polling without breaking tests
3. **No arbitrary timeouts**: Tests complete as soon as operations finish
4. **Automatic waiting**: `settled()`, `click()`, etc. wait for all registered operations
5. **Better debugging**: Test waiters show pending operations when tests hang
**When to use test waiters:**
- Network requests (fetch, XHR)
- Timers and intervals (setTimeout, setInterval)
- Animations and transitions
- Polling operations
- Any async operation that affects rendered output
**When NOT needed:**
- ember-concurrency already registers test waiters automatically
- Promises that complete before render (data preparation in constructors)
- Operations that don't affect the DOM or component state
**Key principle:** If your code does something async that users care about, register it with a test waiter. Tests should never use `waitFor()` or `waitUntil()` - those are code smells indicating missing test waiters.
Reference: [@ember/test-waiters](https://github.com/emberjs/ember-test-waiters)
@@ -0,0 +1,210 @@
---
title: VSCode Extensions and MCP Configuration for Ember Projects
impact: HIGH
impactDescription: Improves editor consistency and AI-assisted debugging setup
tags: tooling, vscode, mcp, glint, developer-experience
---
## VSCode Extensions and MCP Configuration for Ember Projects
Set up recommended VSCode extensions and Model Context Protocol (MCP) servers for optimal Ember development experience.
**Incorrect (no extension recommendations):**
```json
{
"recommendations": []
}
```
**Correct (recommended extensions for Ember):**
```json
{
"recommendations": [
"emberjs.vscode-ember",
"vunguyentuan.vscode-glint",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}
```
## Recommended VSCode Extensions
Create a `.vscode/extensions.json` file in your project root to recommend extensions to all team members:
```json
{
"recommendations": [
"emberjs.vscode-ember",
"vunguyentuan.vscode-glint",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}
```
### Core Ember Extensions
**ember-extension-pack** (or individual extensions):
- `emberjs.vscode-ember` - Ember.js language support
- Syntax highlighting for `.hbs`, `.gjs`, `.gts` files
- IntelliSense for Ember-specific patterns
- Code snippets for common Ember patterns
**Glint 2 Extension** (for TypeScript projects):
- `vunguyentuan.vscode-glint` - Type checking for Glimmer templates
- Real-time type errors in `.gts`/`.gjs` files
- Template-aware autocomplete
- Hover information for template helpers and components
Install instructions:
```bash
# Via command palette
# Press Cmd+Shift+P (Mac) or Ctrl+Shift+P (Windows/Linux)
# Type: "Extensions: Install Extensions"
# Search for "Ember" or "Glint"
```
## MCP (Model Context Protocol) Server Configuration
Configure MCP servers in `.vscode/settings.json` to integrate AI coding assistants with Ember-specific context:
```json
{
"github.copilot.enable": {
"*": true,
"yaml": false,
"plaintext": false,
"markdown": false
},
"mcp.servers": {
"ember-mcp": {
"command": "npx",
"args": ["@ember/mcp-server"],
"description": "Ember.js MCP Server - Provides Ember-specific context"
},
"chrome-devtools": {
"command": "npx",
"args": ["@modelcontextprotocol/server-chrome-devtools"],
"description": "Chrome DevTools MCP Server - Browser debugging integration"
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp-server"],
"description": "Playwright MCP Server - Browser automation and testing"
}
}
}
```
### MCP Server Benefits
**Ember MCP Server** (`@ember/mcp-server`):
- Ember API documentation lookup
- Component and helper discovery
- Addon documentation integration
- Routing and data layer context
**Chrome DevTools MCP** (`@modelcontextprotocol/server-chrome-devtools`):
- Live browser inspection
- Console debugging assistance
- Network request analysis
- Performance profiling integration
**Playwright MCP** (optional, `@playwright/mcp-server`):
- Test generation assistance
- Browser automation context
- E2E testing patterns
- Debugging test failures
## Complete VSCode Settings Example
```json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"[glimmer-js]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[glimmer-ts]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"files.associations": {
"*.gjs": "glimmer-js",
"*.gts": "glimmer-ts"
},
"glint.enabled": true,
"glint.configPath": "./tsconfig.json",
"github.copilot.enable": {
"*": true
},
"mcp.servers": {
"ember-mcp": {
"command": "npx",
"args": ["@ember/mcp-server"],
"description": "Ember.js MCP Server"
},
"chrome-devtools": {
"command": "npx",
"args": ["@modelcontextprotocol/server-chrome-devtools"],
"description": "Chrome DevTools MCP Server"
}
}
}
```
## TypeScript Configuration (when using Glint)
Ensure your `tsconfig.json` has Glint configuration:
```json
{
"compilerOptions": {
// ... standard TS options
},
"glint": {
"environment": ["ember-loose", "ember-template-imports"]
}
}
```
## Installation Steps
1. **Install extensions** (prompted automatically when opening project with `.vscode/extensions.json`)
2. **Install Glint** (if using TypeScript):
```bash
npm install --save-dev @glint/core @glint/environment-ember-loose @glint/environment-ember-template-imports
```
3. **Configure MCP servers** in `.vscode/settings.json`
4. **Reload VSCode** to activate all extensions and MCP integrations
## Benefits
- **Consistent team setup**: All developers get same extensions
- **Type safety**: Glint provides template type checking
- **AI assistance**: MCP servers give AI tools Ember-specific context
- **Better DX**: Autocomplete, debugging, and testing integration
- **Reduced onboarding**: New team members get productive faster
## References
- [VSCode Ember Extension](https://marketplace.visualstudio.com/items?itemName=emberjs.vscode-ember)
- [Glint Documentation](https://typed-ember.gitbook.io/glint/)
- [MCP Protocol Specification](https://modelcontextprotocol.io/)
- [Ember Primitives VSCode Setup Example](https://github.com/universal-ember/ember-primitives/tree/main/.vscode)
-4
View File
@@ -1,4 +0,0 @@
{
"directory": "bower_components",
"analytics": false
}
-1
View File
@@ -4,7 +4,6 @@
root = true
[*]
end_of_line = lf
charset = utf-8
+1 -1
View File
@@ -5,5 +5,5 @@
Setting `disableAnalytics` to true will prevent any data from being sent.
*/
"disableAnalytics": false
"disableAnalytics": true
}
+21
View File
@@ -0,0 +1,21 @@
# unconventional js
/blueprints/*/files/
/vendor/
# compiled output
/dist/
/tmp/
/release/
# dependencies
/bower_components/
/node_modules/
# misc
/coverage/
!.*
# ember-try
/.node_modules.ember-try/
/bower.json.ember-try
/package.json.ember-try
+77
View File
@@ -0,0 +1,77 @@
'use strict';
module.exports = {
root: true,
parser: '@babel/eslint-parser',
parserOptions: {
requireConfigFile: false,
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
legacyDecorators: true
},
babelOptions: {
babelrc: false,
configFile: false,
plugins: [
['@babel/plugin-proposal-decorators', { legacy: true }],
],
},
},
globals: {
console: true
},
plugins: [
'ember'
],
extends: [
'eslint:recommended',
'plugin:ember/recommended'
],
env: {
browser: true
},
rules: {
'ember/avoid-leaking-state-in-ember-objects': 'warn',
'no-console': 'off',
'ember/no-jquery': 'error',
'ember/require-computed-property-dependencies': 'warn',
'ember/no-computed-properties-in-native-classes': 'warn',
'ember/no-observers': 'warn',
'ember/no-classic-classes': 'warn',
'ember/no-classic-components': 'warn',
'ember/no-controller-access-in-routes': 'warn',
'ember/no-actions-hash': 'warn',
'ember/require-tagless-components': 'warn'
},
overrides: [
// node files
{
files: [
'.eslintrc.js',
'.template-lintrc.js',
'ember-cli-build.js',
'testem.js',
'blueprints/*/index.js',
'config/**/*.js',
'lib/*/index.js',
'server/**/*.js'
],
parserOptions: {
sourceType: 'script'
},
env: {
browser: false,
node: true
},
plugins: ['node'],
rules: Object.assign({}, require('eslint-plugin-node').configs.recommended.rules, {
// add your custom rules and overrides for node files here
// this can be removed once the following is fixed
// https://github.com/mysticatea/eslint-plugin-node/issues/77
'node/no-unpublished-require': 'off'
})
}
]
};
+14
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
+51
View File
@@ -0,0 +1,51 @@
name: CI
on:
push:
branches:
- main
- master
pull_request: {}
concurrency:
group: ci-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: "Lint"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 16
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Lint
run: npm run lint
test:
name: "Test"
runs-on: ubuntu-latest
container:
image: cypress/browsers:node16.18.0-chrome107-ff106-edge
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Node
uses: actions/setup-node@v4
with:
node-version: 16
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Run Tests
run: npm test
+13
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 }}
+16 -7
View File
@@ -1,17 +1,26 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/dist/
/tmp/
# dependencies
/node_modules
/bower_components
/bower_components/
/node_modules/
# misc
/.env*
/.pnp*
/.sass-cache
/connect.lock
/coverage/*
/coverage/
/libpeerconnection.log
npm-debug.log*
testem.log
/npm-debug.log*
/testem.log
/yarn-error.log
.tm_properties
# ember-try
/.node_modules.ember-try/
/bower.json.ember-try
/package.json.ember-try
-32
View File
@@ -1,32 +0,0 @@
{
"predef": [
"document",
"window",
"-Promise"
],
"browser": true,
"boss": true,
"curly": true,
"debug": false,
"devel": true,
"eqeqeq": true,
"evil": true,
"forin": false,
"immed": false,
"laxbreak": false,
"newcap": true,
"noarg": true,
"noempty": false,
"nonew": false,
"nomen": false,
"onevar": false,
"plusplus": false,
"regexp": false,
"undef": true,
"sub": true,
"strict": false,
"white": false,
"eqnull": true,
"esversion": 6,
"unused": true
}
+1
View File
@@ -0,0 +1 @@
16
+100
View File
@@ -0,0 +1,100 @@
'use strict';
module.exports = {
extends: 'octane',
rules: {
'simple-unless': false,
'no-nested-interactive': false,
'no-html-comments': false
},
ignore: [
'kredits-web/templates/components/**',
'app/templates/components/**'
],
pending: [
{
"moduleId": "app/templates/dashboard",
"only": [
"no-action"
]
},
{
"moduleId": "app/components/add-contribution/template",
"only": [
"no-action",
"no-curly-component-invocation"
]
},
{
"moduleId": "app/components/add-contributor/template",
"only": [
"no-action",
"no-curly-component-invocation"
]
},
{
"moduleId": "app/components/contribution-list/template",
"only": [
"no-action",
"no-curly-component-invocation"
]
},
{
"moduleId": "app/components/contributor-list/template",
"only": [
"no-action"
]
},
{
"moduleId": "app/components/expense-list/template",
"only": [
"no-invalid-role"
]
},
{
"moduleId": "app/components/topbar-account-panel/template",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/contributions/new",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/contributions/resubmit",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/contributors/edit",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/contributors/new",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/signup/eth-account",
"only": [
"no-action"
]
},
{
"moduleId": "app/templates/signup/index",
"only": [
"no-action"
]
}
]
};
-25
View File
@@ -1,25 +0,0 @@
---
language: node_js
node_js:
- "6"
sudo: false
cache:
directories:
- $HOME/.npm
- $HOME/.cache # includes bowers cache
before_install:
- npm config set spin false
- npm install -g bower
- bower --version
- npm install phantomjs-prebuilt
- node_modules/phantomjs-prebuilt/bin/phantomjs --version
install:
- npm install
- bower install
script:
- npm test
+108
View File
@@ -0,0 +1,108 @@
# AGENTS.md
Reference for AI agents working in `kredits-web`.
## Overview
Unhosted Ember.js Web UI for [Kosmos Kredits](https://wiki.kosmos.org/Kredits) — a contribution-tracking and community-currency system. The app is serverless: it reads all data from the **Rootstock (RSK)** blockchain via `@kredits/contracts` + `ethers`, fetches contributor metadata from **IPFS**, and caches everything locally with `localforage`. No custom backend.
## Tech Stack
- **Ember.js 3.18** (Octane edition), **ember-cli** build pipeline (Broccoli)
- **Plain JavaScript** (no TypeScript; `jsconfig.json` only enables `experimentalDecorators`)
- **SCSS** for styling (no Tailwind/CSS modules)
- **ethers 5** + **@kredits/contracts** for on-chain reads/writes
- **ember-concurrency** for async sync tasks; **Ember Services** for state (no Redux, no Ember Data)
- **QUnit** + qunit-dom + fetch-mock for tests
- **Node 16** (`.nvmrc`); `>= 14` in engines
## Commands
| Task | Command |
|---|---|
| Dev server (RSK testnet) | `npm start` |
| Dev against local Hardhat devchain | `npm run start:local` |
| Full lint + test pipeline (CI) | `npm test` |
| Ember/QUnit suite only | `npm run test:ember` |
| Interactive test runner | `ember test --server` |
| All linters | `npm run lint` |
| Lint JS (autofix) | `npm run lint:js -- --fix` |
| Lint Handlebars templates | `npm run lint:hbs` |
| Production build into `release/` | `npm run build-prod` |
| Deploy to 5apps | `npm run deploy` (=`git push 5apps master`) |
No `format` or `typecheck` scripts (no TS, no Prettier).
## Project Structure
```
app/
├── components/ # PODS layout: <name>/component.js + template.hbs
├── controllers/ # Route controllers (dashboard, budget, signup, contributions/*, ...)
├── routes/ # Ember routes (mirror router.js hierarchy)
├── services/ # Singleton services — the core architecture
├── models/ # Plain EmberObject models (contributor, contribution, reimbursement)
├── helpers/ # Template helpers (fmt-*, etc.), dash-case files
├── utils/ # Plain JS utilities (dash-case)
├── styles/ # SCSS: app.scss + partials + components/_<name>.scss
└── templates/ # Route .hbs templates
config/ # environment.js, targets.js, optional-features.json
tests/ # unit/, integration/, fixtures/, helpers/
public/ # Static assets (img/, robots.txt)
release/ # Committed production build output (deployed via 5apps)
```
**Key files**:
- `app/services/kredits.js` (~784 lines) — central state hub; wraps `@kredits/contracts` SDK, holds `contributors`/`contributions`/`reimbursements`/`currentUser`, subscribes to contract events, runs ember-concurrency sync tasks.
- `app/services/browser-cache.js``localforage`-based offline persistence, namespaced per network.
- `app/router.js` — full route map (dashboard, contributions, contributors, signup, budget, reimbursements, about).
- `config/environment.js` — web3/IPFS/GitHub/API config + env-var overrides.
## Architecture
- **State**: The `kredits` service is the single source of truth. Components/controllers inject `@service kredits` and consume `contributors`, `contributions`, `reimbursements`, `currentUser`, and computed derivations (`contributionsConfirmed`, `kreditsByContributor`, …) via `alias`/`filterBy`/`sort` macros.
- **Identity/auth**: No login. The connected wallet address (`window.ethereum` → MetaMask, fallback read-only `JsonRpcProvider`) is mapped to a contributor via `Contributor.getContributorIdByAddress`. `currentUserIsContributor` / `currentUserIsCore` gate UI capabilities.
- **Live updates**: Contract event subscriptions (`ContributorAdded`, `ContributionAdded`, `ContributionVetoed`, `ReimbursementAdded`, `ReimbursementVetoed`, `Token.Transfer`) update local state. A `provider.on('block', …)` tick recomputes confirmed vs. unconfirmed items (confirmation is block-based).
- **Offline**: `browser-cache` loads from localforage on boot (`loadInitialData`), sets `*NeedSync` flags, then background sync tasks refill from chain. Enables fast unhosted operation.
- **Bootstrap**: `routes/application.js``beforeModel: kredits.setup()``model: loadInitialData() + addContractEventHandlers()``afterModel` schedules syncs.
## Conventions
- **Files**: dash-case (`contribution-list/component.js`, `fmt-crypto-currency.js`).
- **Components**: **PODS** layout — each component is a directory under `app/components/<name>/` with `component.js` + `template.hbs`.
- **Component style — prefer Octane for new code**: use `@glimmer/component` with `@tracked`, `@service`, `@action` decorators. The codebase is mid-migration from classic `Component.extend({...})` with `actions` hashes; don't force-rewrite existing classic components, but write new ones in Octane. ESLint and `template-lint` warn (not error) on classic patterns.
- **Styling**: plain SCSS — one partial per component at `app/styles/components/_<name>.scss`, imported by `app/styles/app.scss`. Semantic/BEM-ish class names.
- **Imports**: external libs → `@ember/*` → ember addons → app utils/models → `kredits-web/config/environment`. App imports use the `kredits-web/` absolute module prefix.
- **No jQuery** (`ember/no-jquery: error`). **No Ember Data** — models are plain `EmberObject.extend` with a `serialize()` returning `JSON.stringify`.
- **Indent**: 2 spaces, LF, UTF-8, trim trailing whitespace (`.editorconfig`).
## Config & Environment
Read in `config/environment.js` (lines ~102110). No `.env.example` (`.gitignore` ignores `/.env*`).
| Var | Default | Purpose |
|---|---|---|
| `WEB3_PROVIDER_URL` | `https://rsk-testnet.kosmos.org` | JSON-RPC endpoint |
| `WEB3_CHAIN_ID` | `31` (RSK Testnet) | Target chain ID (used for MetaMask switching) |
| `WEB3_NETWORK_NAME` | `RSK Testnet` | Human-readable name; namespaces the localforage cache |
| `CI` | — | Toggles `testem.js` `--no-sandbox` and IE11 browser target |
## Testing
- **Framework**: QUnit via `ember-qunit` + `qunit-dom`; `@ember/test-helpers` (`render`, `click`, `fillIn`).
- **HTTP mocking**: `fetch-mock``tests/test-helper.js` mocks Bitstamp ticker endpoints globally.
- **Runner**: headless Chrome (`testem.js`); CI uses Node 16 via `.github/workflows/ci.yml`.
- **File patterns**:
- `tests/unit/<type>/<name>-test.js` — unit tests for services, controllers, routes, models, helpers, utils.
- `tests/integration/components/<name>/component-test.js` — component rendering tests (PODS layout).
- `tests/integration/helpers/<name>-test.js` — helper tests.
- `tests/fixtures/<name>.js` — shared fixture data (contributors, contributions, …).
- `tests/helpers/create-component.js` — custom test helper.
## Gotchas
- **Mid-migration codebase**: classic and Octane component styles coexist. Linters warn (not error) on legacy patterns; `.template-lintrc.js` keeps a `pending` list of templates still using `{{action}}`.
- **`release/` is committed build output** — don't hand-edit; regenerated by `npm run build-prod` (auto-staged on `npm version`).
- **Deployment is `git push 5apps master`** (5apps static hosting) — pushing to `master` triggers a deploy. Be deliberate about commits/pushes.
- **No `.env.example`** — set `WEB3_*` vars inline (see `npm run start:local` for the pattern).
- **`gitno/`** contains auxiliary non-Ember scripts (bitstamp sync shell, `nostr.html`) — not part of the app.
+79 -33
View File
@@ -1,77 +1,123 @@
[![Build Status](https://travis-ci.org/67P/kredits-web.svg?branch=master)](https://travis-ci.org/67P/kredits-web)
[![Build Status](https://github.com/67P/kredits-web/actions/workflows/ci.yml/badge.svg)](https://github.com/67P/kredits-web/actions/workflows/ci.yml?query=branch%3Amaster)
# kredits-web
# Kredits Web
This README outlines the details of collaborating on this Ember application.
A short introduction of this app could easily go here.
This is the main Web UI for Kosmos [Kredits](https://wiki.kosmos.org/Kredits).
It provides an overview of contributors and contributions, as well as the
community's budget, expenses, and reimbursements.
## Prerequisites
It is an unhosted Web app, fetching all data from
[Rootstock](https://rootstock.io/) and [IPFS](https://ipfs.tech/), and caching
it in the browser's local database. As such, you can run the app directly from
the `/release` directory on any Web server that can serve static assets.
## Development
### Prerequisites
You will need the following things properly installed on your computer.
* [Git](https://git-scm.com/)
* [Node.js](https://nodejs.org/) (with NPM)
* [Bower](https://bower.io/)
* [Node.js](https://nodejs.org/) (with npm)
* [Ember CLI](https://ember-cli.com/)
* [PhantomJS](http://phantomjs.org/)
* [Google Chrome](https://google.com/chrome/) (only for running tests)
## Installation
### Installation
* `git clone <repository-url>` this repository
* `git clone git@github.com:67P/kredits-web.git` this repository
* `cd kredits-web`
* `npm install`
* `bower install`
## Running / Development
### Building/running for development
* `ember serve`
* Visit your app at [http://localhost:4200](http://localhost:4200).
* `npm start` - by default Kredits Web connects to the Rootstock testnet network
* Visit the app at [http://localhost:4200](http://localhost:4200).
* Visit the tests at [http://localhost:4200/tests](http://localhost:4200/tests).
### Code Generators
See [working with locally deployed contracts](https://github.com/67P/kredits-web#working-with-locally-deployed-contracts) for details on how to develop with locally deployed contracts.
### Code generators
Make use of the many generators for code, try `ember help generate` for more details
### Running Tests
### Running tests
* `ember test`
* `ember test --server`
### Linting
* `npm run lint:hbs`
* `npm run lint:js`
* `npm run lint:js -- --fix`
### Building
* `ember build` (development)
* `ember build --environment production` (production)
* `npm run build` (production)
### Deploying
Specify what it takes to deploy your app.
_(You need collaborator permissions on the 5apps Deploy project.)_
`npm run deploy`
## Working with locally deployed contracts
### Working with locally deployed contracts
For development you might want to run your own development chain with your own development contracts.
The kredits-contracts helper scripts are available in kredits-web. See [kredits-contracts](https://github.com/67P/kredits-contracts) for more information.
The smart contracts and their JavaScript wrapper library are developed in the
[kredits-contracts](https://github.com/67P/kredits-contracts) repo/package.
Start your parity node with a KreditsChain:
npm run kredits-parity
You can run `kredits-web` on your machine, against a local, simulated
blockchain. [kredits-contracts](https://github.com/67P/kredits-contracts)
contains all the tools to start and set up such a simulated network, as well as
to deploy the Kredits smart contracts to it.
Deploy the contracts to your local chain:
These are the basic steps to get up and running:
npm run kredits-deploy
#### 1. IPFS
Run kredits-web with your localy deployed contracts:
Run a local IPFS deamon.
KREDITS_CONTRACT_ADDR=`kredits-inspect -c Kredits -r -w address` \
TOKEN_CONTRACT_ADDR=`kredits-inspect -c Token -r -w address` \
npm start
* Make sure CORS headers are configured. See [IPFS](#ipfs) for more info.
* `ipfs daemon`
You need to have `./node_modules/.bin` in your path for this to work. Otherwise simply copy the contract addresses and set the environment variables manually.
#### 2. kredits-contracts
Run a local devchain with test data. (See [kredits-contracts
README](https://github.com/67P/kredits-contracts) for details.
* Clone [kredits-contracts](https://github.com/67P/kredits-contracts)
* `npm install`
* `npm run devchain` - runs a local development chain
* `npm run bootstrap` - deploys all contracts and seeds test data
* `npm link` - makes the `kredits-contracts` module linkable as `kredits-contracts` on your machine
#### 3. kredits-web
With IPFS and the local devchain running, you can now link the contracts and
start the Ember app:
* `npm link kredits-contracts` - links the local `kredits-contracts` package (has to be done again after every `npm install`)
* `npm run start:local` - runs the Ember app with WEB3_PROVIDER_URL=http://localhost:8545 set
#### 4. Metamask network
If you want to interact with the local contracts via a Web3 wallet, switch the
network to a "Custom RPC" one, with the RPC URL `http://localhost:8545`.
#### IPFS
If you haven't configured your IPFS node for CORS yet, you can do so by running
the following commands:
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["localhost:4200"]'
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["PUT", "GET", "POST"]'
ipfs config --json API.HTTPHeaders.Access-Control-Allow-Credentials '["true"]'
ipfs config Addresses.Gateway /ip4/127.0.0.1/tcp/8080
## Further Reading / Useful Links
* [ember.js](http://emberjs.com/)
* [ember.js](https://emberjs.com/)
* [ember-cli](https://ember-cli.com/)
* Development Browser Extensions
* [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)
+7 -13
View File
@@ -1,18 +1,12 @@
import Ember from 'ember';
import Resolver from './resolver';
import Application from '@ember/application';
import Resolver from 'ember-resolver';
import loadInitializers from 'ember-load-initializers';
import config from './config/environment';
let App;
Ember.MODEL_FACTORY_INJECTIONS = true;
App = Ember.Application.extend({
modulePrefix: config.modulePrefix,
podModulePrefix: config.podModulePrefix,
Resolver
});
export default class App extends Application {
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
Resolver = Resolver;
}
loadInitializers(App, config.modulePrefix);
export default App;
@@ -0,0 +1,88 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { and, notEmpty } from '@ember/object/computed';
import { assign } from '@ember/polyfills';
import moment from 'moment';
import { inject as service } from '@ember/service';
export default Component.extend({
kredits: service(),
attributes: null,
contributors: computed('kredits.contributorsSorted.[]', function() {
return this.kredits.contributorsSorted.map(c => {
return {
id: c.id.toString(),
name: c.name
}
})
}),
isValidContributor: notEmpty('contributorId'),
isValidKind: notEmpty('kind'),
isValidAmount: computed('amount', function() {
return parseInt(this.amount, 10) > 0;
}),
isValidDescription: notEmpty('description'),
isValidUrl: notEmpty('url'),
isValid: and('isValidContributor',
'isValidKind',
'isValidAmount',
'isValidDescription'),
init () {
this._super(...arguments);
this.set('defaultDate', moment().startOf('hour').toDate());
this.set('defaultAttr', {
contributorId: null,
kind: null,
date: this.defaultDate,
amount: null,
description: null,
url: null,
details: null
});
this.set('attributes', assign({}, this.defaultAttr, this.attributes));
this.reset();
},
reset () {
this.setProperties(this.attributes);
},
actions: {
submit (evt) {
evt.preventDefault();
if (!this.isValid) {
alert('Invalid data. Please review and try again.');
return;
}
const attributes = this.getProperties(Object.keys(this.attributes));
attributes.contributorId = parseInt(this.contributorId);
let dateInput = (attributes.date instanceof Array) ?
attributes.date[0] : attributes.date;
const [ date, time ] = dateInput.toISOString().split('T');
[ attributes.date, attributes.time ] = [ date, time ];
this.set('inProgress', true);
this.save(attributes)
.then((/*contribution*/) => {
this.reset();
}, err => {
console.warn(err);
window.alert('Something went wrong. Check the browser console for details.');
})
.finally(() => this.set('inProgress', false));
}
}
});
@@ -0,0 +1,86 @@
<form onsubmit={{action "submit"}}>
<label>
<p class="label">Contributor:</p>
<p>
<select required onchange={{action (mut this.contributorId) value="target.value"}}>
<option value="" selected disabled hidden></option>
{{#each this.contributors as |contributor|}}
<option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option>
{{/each}}
</select>
</p>
</label>
<label>
<p class="label">Kind:</p>
<p>
<select required onchange={{action (mut this.kind) value="target.value"}}>
<option value="" selected disabled hidden></option>
<option value="bureaucracy" selected={{eq this.kind "bureaucracy"}}>Bureaucracy</option>
<option value="community" selected={{eq this.kind "community"}}>Community</option>
<option value="design" selected={{eq this.kind "design"}}>Design</option>
<option value="dev" selected={{eq this.kind "dev"}}>Development</option>
<option value="docs" selected={{eq this.kind "docs"}}>Documentation</option>
<option value="ops" selected={{eq this.kind "ops"}}>IT Operations</option>
<option value="outreach" selected={{eq this.kind "outreach"}}>Outreach</option>
<option value="qa" selected={{eq this.kind "qa"}}>Quality Assurance</option>
<option value="special" selected={{eq this.kind "special"}}>Special</option>
</select>
</p>
</label>
<label>
<p class="label">Date:</p>
<p>
{{ember-flatpickr
date=this.date
defaultDate=this.defaultDate
maxDate=this.defaultDate
enableTime=true
time_24hr=true
onChange=(action (mut this.date))
}}
</p>
</label>
<label>
<p class="label">Amount:</p>
<p>
{{input type="text"
placeholder="500"
value=this.amount
class=(if this.isValidAmount "valid" "")}}
</p>
</label>
<label>
<p class="label">Description:</p>
<p>
{{input type="text"
value=this.description
class=(if this.isValidDescription "valid" "")}}
</p>
</label>
<label>
<p class="label">URL (optional):</p>
<p>
{{input type="text"
value=this.url
class=(if this.isValidUrl "valid" "")}}
</p>
</label>
{{#if this.details}}
<label>
<p class="label">Details:</p>
<p>
<pre>
{{this.details}}
</pre>
</p>
</label>
{{/if}}
<p class="actions">
{{input type="submit"
disabled=this.inProgress
value=(if this.inProgress "Processing" "Save")}}
</p>
</form>
+67 -55
View File
@@ -1,79 +1,91 @@
import Ember from 'ember';
import Contributor from 'kredits-web/models/contributor';
const {
Component,
isPresent,
inject: {
service
},
computed
} = Ember;
import Component from '@ember/component';
import { computed } from '@ember/object';
import { and, notEmpty } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { isPresent } from '@ember/utils';
import { isAddress } from 'web3-utils';
export default Component.extend({
router: service(),
kredits: service(),
newContributor: null,
inProgress: false,
attributes: null,
isValidAddress: function() {
return this.get('kredits.web3')
.isAddress(this.get('newContributor.address'));
}.property('kredits.web3', 'newContributor.address'),
isValidAccount: computed('account', function() {
return isAddress(this.account);
}),
isValidName: function() {
return isPresent(this.get('newContributor.name'));
}.property('newContributor.name'),
isValidName: notEmpty('name'),
isValidURL: notEmpty('url'),
isValidGithubUID: notEmpty('github_uid'),
isValidGithubUsername: notEmpty('github_username'),
isValidGiteaUsername: notEmpty('gitea_username'),
isValidWikiUsername: notEmpty('wiki_username'),
isValidZoomDisplayName: notEmpty('zoom_display_name'),
isValidURL: function() {
return isPresent(this.get('newContributor.url'));
}.property('newContributor.url'),
isValidGithubUID: function() {
return isPresent(this.get('newContributor.github_uid'));
}.property('newContributor.github_uid'),
isValidGithubUsername: function() {
return isPresent(this.get('newContributor.github_username'));
}.property('newContributor.github_username'),
isValidWikiUsername: function() {
return isPresent(this.get('newContributor.wiki_username'));
}.property('newContributor.wiki_username'),
isValid: computed.and(
'isValidAddress',
isValid: and(
'isValidAccount',
'isValidName',
'isValidGithubUID'
),
reset: function() {
this.setProperties({
newContributor: Contributor.create({ kind: 'person' }),
inProgress: false
inProgress: false,
init () {
this._super(...arguments);
this.setDefaultAttributes();
this.reset();
},
setDefaultAttributes () {
if (isPresent(this.attributes)) { return; }
this.set('attributes', {
account: null,
name: null,
kind: 'person',
url: null,
github_username: null,
github_uid: null,
gitea_username: null,
wiki_username: null,
zoom_display_name: null
});
},
reset: function() {
this.setProperties(this.attributes);
},
actions: {
save() {
if (!this.get('contractInteractionEnabled')) {
alert('Only core team members can add new contributors. Please ask someone to set you up.');
submit (evt) {
evt.preventDefault();
if (!this.kredits.currentUserIsCore) {
window.alert('Only core team members can edit profiles. Please ask someone to set you up.');
return;
}
if (!this.isValid) {
window.alert('Invalid data. Please review and try again.');
return;
}
if (this.get('isValid')) {
this.set('inProgress', true);
const attributes = Object.keys(this.attributes);
const contributor = this.getProperties(attributes);
this.get('kredits').addContributor(this.get('newContributor')).then(contributor => {
this.reset();
this.get('contributors').pushObject(contributor);
window.scroll(0,0);
});
} else {
alert('Invalid data. Please review and try again.');
}
this.set('inProgress', true);
this.save(contributor).then(() => {
this.reset();
}).catch(err => {
console.warn(err);
window.alert('Something went wrong. Please check the browser console.');
}).finally(() => {
this.set('inProgress', false);
this.router.transitionTo('dashboard');
});
}
}
+38 -41
View File
@@ -1,59 +1,56 @@
<form {{action "save" on="submit"}}>
<form onsubmit={{action "submit"}}>
<p>
{{input type="checkbox" name="is-core" id="is-core" checked=newContributor.isCore}}
<label for="is-core" class="checkbox">
Core team member (can add contributors)
</label>
<label for="c-account">Rootstock account</label>
<Input @type="text"
@value={{this.account}}
name="account" id="c-account"
placeholder="0xF18E631Ea191aE4ebE70046Fcb01a436554421BA4"
class={{if this.isValidAccount "valid" ""}} />
</p>
<p>
{{input name="address"
type="text"
placeholder="0xF18E631Ea191aE4ebE70046Fcb01a436554421BA4"
value=newContributor.address
class=(if isValidAddress 'valid' '')}}
</p>
<p>
<select required onchange={{action (mut newContributor.kind) value="target.value"}}>
<option value="person" selected={{eq newContributor.kind "person"}}>Person</option>
<option value="organization" selected={{eq newContributor.kind "organization"}}>Organization</option>
<label for="c-kind">Kind</label>
<select required onchange={{action (mut this.kind) value="target.value"}} id="c-kind">
<option value="person" selected={{eq this.kind "person"}}>Person</option>
<option value="organization" selected={{eq this.kind "organization"}}>Organization</option>
</select>
</p>
<p>
{{input name="name"
type="text"
placeholder="Name"
value=newContributor.name
class=(if isValidName 'valid' '')}}
<label for="c-name">Name</label>
{{input name="name" type="text" value=this.name placeholder="Zero Cool"
class=(if this.isValidName "valid" "") id="c-name"}}
</p>
<p>
{{input name="url"
type="text"
placeholder="URL"
value=newContributor.url
class=(if isValidURL 'valid' '')}}
<label for="c-url">URL</label>
{{input name="url" type="text" value=this.url placeholder="http://zerocool.bit"
class=(if this.isValidURL "valid" "") id="c-url"}}
</p>
<p>
{{input name="github_uid"
type="text"
placeholder="GitHub UID (123)"
value=newContributor.github_uid
class=(if isValidGithubUID 'valid' '')}}
<label for="c-github-uid">GitHub UID</label>
{{input name="github_uid" type="text" value=this.github_uid placeholder="2342"
class=(if this.isValidGithubUID "valid" "") id="c-github-uid"}}
</p>
<p>
{{input name="github_username"
type="text"
placeholder="GitHub username"
value=newContributor.github_username
class=(if isValidGithubUsername 'valid' '')}}
<label for="c-github-username">GitHub username</label>
{{input name="github_username" type="text" value=this.github_username placeholder="zerocool"
class=(if this.isValidGithubUsername "valid" "") id="c-github-username"}}
</p>
<p>
{{input name="wiki_username"
type="text"
placeholder="Wiki Username"
value=newContributor.wiki_username
class=(if isValidWikiUsername 'valid' '')}}
<label for="c-gitea-username">Gitea username</label>
{{input name="gitea_username" type="text" value=this.gitea_username placeholder="zerocool"
class=(if this.isValidGiteaUsername "valid" "") id="c-gitea-username"}}
</p>
<p>
<label for="c-wiki-username">Wiki username</label>
{{input name="wiki_username" type="text" value=this.wiki_username placeholder="ZeroCool"
class=(if this.isValidWikiUsername "valid" "") id="c-wiki-username"}}
</p>
<p>
<label for="c-zoom-display-name">Zoom display name</label>
{{input name="zoom_display_name" type="text" value=this.zoom_display_name placeholder="Zero Cool"
class=(if this.isValidZoomDisplayName "valid" "") id="c-zoom-display-name"}}
</p>
<p class="actions">
{{input type="submit" value=(if inProgress 'Processing' 'Save') disabled=inProgress}}
{{input type="submit" disabled=this.inProgress
value=(if this.inProgress "Processing" "Save")}}
</p>
</form>
@@ -0,0 +1,92 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import moment from 'moment';
import isValidAmount from 'kredits-web/utils/is-valid-amount';
import { isPresent } from '@ember/utils';
export default class AddExpenseItemComponent extends Component {
@tracked amount = '0';
@tracked currency = 'EUR';
@tracked date = moment().startOf('hour').toDate();
@tracked title = '';
@tracked description = '';
@tracked url = '';
@tracked tags = '';
defaultDate = moment().startOf('hour').toDate();
currencies = [
{ code: 'EUR' },
{ code: 'USD' },
{ code: 'BTC' }
];
get isValidAmount () {
return isValidAmount(this.amount);
}
get amountInputClass () {
return this.isValidTotal ? 'valid' : '';
}
validateForm () {
const formEl = document.querySelector('form#add-expense-item');
const inputFields = formEl.querySelectorAll('input');
inputFields.forEach(i => i.classList.remove('invalid'));
let validity = true;
if (!this.isValidAmount) {
document.querySelector('input[name=expense-amount]').classList.add('invalid');
validity = false;
}
if (!formEl.checkValidity()) {
inputFields.forEach(i => {
if (!i.validity.valid) {
i.classList.add('invalid');
validity = false;
}
})
}
return validity;
}
@action
updateCurrency(event) {
this.currency = event.target.value;
}
@action
submit (e) {
e.preventDefault();
let dateInput = (this.date instanceof Array) ?
this.date[0] : this.date;
const [ date ] = moment(dateInput).utcOffset(0, true)
.toISOString()
.split('T');
const isValid = this.validateForm();
if (!isValid) return false;
const expense = {
amount: parseFloat(this.amount),
currency: this.currency,
date: date,
title: this.title,
description: isPresent(this.description) ? this.description : undefined,
url: isPresent(this.url) ? this.url : undefined,
}
if (isPresent(this.tags)) {
expense.tags = this.tags.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
}
this.args.addExpenseItem(expense);
}
}
@@ -0,0 +1,69 @@
<form id="add-expense-item" {{on "submit" this.submit}} novalidate>
<fieldset class="horizontal">
<label>
<p class="label">Amount:</p>
<p>
<Input @name="expense-amount"
@type="text"
@placeholder="10"
@value={{this.amount}}
@required={{true}}
@pattern="([0-9]*[.])?[0-9]+"
@class={{this.amountInputClass}} />
</p>
</label>
<label>
<p class="label">Currency:</p>
<p>
<select required name="expense-currency" {{on "change" this.updateCurrency}}>
<option value="" selected disabled hidden></option>
{{#each this.currencies as |currency|}}
<option value={{currency.code}} selected={{eq this.currency currency.code}}>{{currency.code}}</option>
{{/each}}
</select>
</p>
</label>
</fieldset>
<label>
<p class="label">Date:</p>
<p>
<EmberFlatpickr @date={{this.date}}
@defaultDate={{this.defaultDate}}
@maxDate={{this.defaultDate}}
@enableTime={{false}}
@onChange={{fn (mut this.date)}} />
</p>
</label>
<label>
<p class="label">Title:</p>
<p>
<Input @name="expense-title"
@type="text"
@value={{this.title}}
@required={{true}} />
</p>
</label>
<label>
<p class="label">Description (optional):</p>
<p>
<Input @name="expense-description" @type="text" @value={{this.description}} />
</p>
</label>
<label>
<p class="label">URL (optional):</p>
<p>
<Input @name="expense-url" @type="url" @value={{this.url}} />
</p>
</label>
<label>
<p class="label">Tags (comma-separated, optional):</p>
<p>
<Input @name="expense-tags" @type="text" @value={{this.tags}} />
</p>
</label>
<p class="actions">
<Input @type="submit" @value="Add" @class="green"
@title="Add item to reimbursement" />
</p>
</form>
-66
View File
@@ -1,66 +0,0 @@
import Ember from 'ember';
const {
Component,
isPresent,
inject: {
service
},
computed
} = Ember;
export default Component.extend({
kredits: service(),
proposal: null,
contributors: null,
inProgress: false,
isValidRecipient: computed('proposal.recipientAddress', function() {
return this.get('kredits.web3').isAddress(this.get('proposal.recipientAddress'));
}),
isValidAmount: computed('proposal.amount', function() {
return parseInt(this.get('proposal.amount'), 10) > 0;
}),
isValidUrl: computed('proposal.url', function() {
return isPresent(this.get('proposal.url'));
}),
isValidDescription: computed('proposal.description', function() {
return isPresent(this.get('proposal.description'));
}),
isValid: computed.and('isValidRecipient',
'isValidAmount',
'isValidDescription'),
actions: {
save() {
if (! this.get('isValid')) {
alert('Invalid data. Please review and try again.');
return false;
}
this.set('inProgress', true);
let proposal = this.get('proposal');
// Set the recipient's IPFS profile hash so it can be used in the
// contribution object (which is to be stored in IPFS as well)
let contributor = this.get('contributors').findBy('address', proposal.get('recipientAddress'));
proposal.set('recipientProfile', contributor.get('ipfsHash'));
this.get('kredits').addProposal(proposal)
.then(() => {
this.attrs.onSave();
}).catch((error) => {
Ember.Logger.error('[add-proposal] error creating the proposal', error);
alert('Something went wrong.');
}).finally(() => {
this.set('inProgress', false);
});
}
}
});
-42
View File
@@ -1,42 +0,0 @@
<form {{action "save" on="submit"}}>
<p>
<select required onchange={{action (mut proposal.recipientAddress) value="target.value"}}>
<option value="" selected disabled hidden>Contributor</option>
{{#each contributors as |contributor|}}
<option value={{contributor.address}} selected={{eq proposal.recipientAddress contributor.address}}>{{contributor.github_username}}</option>
{{/each}}
</select>
</p>
<p>
<select required onchange={{action (mut proposal.kind) value="target.value"}}>
<option value="community" selected={{eq proposal.kind "community"}}>Community</option>
<option value="design" selected={{eq proposal.kind "design"}}>Design</option>
<option value="dev" selected={{eq proposal.kind "dev"}}>Development</option>
<option value="docs" selected={{eq proposal.kind "docs"}}>Documentation</option>
<option value="ops" selected={{eq proposal.kind "ops"}}>IT Operations</option>
</select>
</p>
<p>
{{input type="text"
placeholder="100"
value=proposal.amount
class=(if isValidAmount 'valid' '')}}
</p>
<p>
{{input type="text"
placeholder="Description"
value=proposal.description
class=(if isValidDescription 'valid' '')}}
</p>
<p>
{{input type="text"
placeholder="URL (optional)"
value=proposal.url
class=(if isValidUrl 'valid' '')}}
</p>
<p class="actions">
{{input type="submit" value=(if inProgress 'Processing' 'Save') disabled=inProgress}}
{{#link-to 'index'}}Back{{/link-to}}
</p>
</form>
@@ -0,0 +1,177 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { A } from '@ember/array';
import { scheduleOnce } from '@ember/runloop';
import { btcToSats, satsToBtc } from 'kredits-web/utils/btc-conversions';
import isValidAmount from 'kredits-web/utils/is-valid-amount';
import isoDateIsToday from 'kredits-web/utils/iso-date-is-today';
import readFileContent from 'kredits-web/utils/read-file-content';
import config from 'kredits-web/config/environment';
export default class AddReimbursementComponent extends Component {
@service router;
@service kredits;
@service exchangeRates;
@alias('kredits.contributorsSorted') contributors;
@tracked recipientId = null;
@tracked title = '';
@tracked total = '0';
@tracked expenses = A([]);
@tracked expenseFormVisible = true;
constructor() {
super(...arguments);
this.exchangeRates.fetchRates();
}
get contributorId () {
return this.recipientId || this.kredits.currentUser?.id;
}
get isValidTotal () {
return isValidAmount(this.total);
}
get totalInputClass () {
return this.isValidTotal ? 'valid' : '';
}
get totalEUR () {
const expenses = this.expenses.filterBy('currency', 'EUR');
if (expenses.length > 0) {
return expenses.mapBy('amount')
.reduce((summation, current) => summation + current);
} else {
return 0;
}
}
get totalUSD () {
const expenses = this.expenses.filterBy('currency', 'USD');
if (expenses.length > 0) {
return expenses.mapBy('amount')
.reduce((summation, current) => summation + current);
} else {
return 0;
}
}
get submitButtonEnabled () {
return this.isValidTotal &&
(this.expenses.length > 0);
}
get submitButtonDisabled () {
return !this.submitButtonEnabled;
}
scrollToExpenseItemForm () {
const anchor = document.getElementById('new-expense-item');
anchor.scrollIntoView();
}
// TODO use ember-concurrency here
// https://github.com/67P/kredits-web/pull/209#discussion_r1064234421
@action
async addExpensesFromFile (evt) {
const content = await readFileContent(evt.target.files[0]);
const expenses = JSON.parse(content);
if (expenses instanceof Array) {
for (const item of expenses) {
this.addExpenseItem(item);
}
} else {
console.warn("Expenses in file must be a list of items:");
console.debug(content);
}
}
@action
updateContributor (event) {
this.recipientId = parseInt(event.target.value);
}
@action
showExpenseForm () {
this.expenseFormVisible = true;
scheduleOnce('afterRender', this, this.scrollToExpenseItemForm);
}
@action
async addExpenseItem (expense) {
let totalBTC = parseFloat(this.total);
if (expense.currency === "BTC") {
expense.amountSats = btcToSats(expense.amount);
totalBTC += expense.amount;
} else {
let amountSats;
if (isoDateIsToday(expense.date)) {
amountSats = btcToSats(expense.amount / this.exchangeRates[expense.currency]);
} else {
const rates = await this.exchangeRates.fetchHistoricRates(expense.date);
amountSats = btcToSats(expense.amount / rates[expense.currency]);
}
expense.amountSats = amountSats;
totalBTC += satsToBtc(amountSats);
}
console.debug("Adding expense:", expense);
this.total = totalBTC.toFixed(8);
this.expenses.pushObject(expense);
this.expenseFormVisible = false;
}
@action
async removeExpenseItem (expense) {
let totalBTC = parseFloat(this.total);
let amountBTC = satsToBtc(expense.amountSats);
totalBTC = totalBTC - amountBTC;
this.total = totalBTC.toFixed(8);
this.expenses.removeObject(expense);
if (this.expenses.length === 0) {
this.expenseFormVisible = true;
}
}
@action
submit (e) {
e.preventDefault();
if (!this.kredits.currentUser) { window.alert('You need to connect your RSK account first.'); return false }
if (!this.kredits.currentUserIsCore) { window.alert('Only core contributors can submit reimbursements.'); return false }
const contributor = this.contributors.findBy('id', this.contributorId);
const attributes = {
amount: btcToSats(this.total),
token: config.tokens['BTC'],
recipientId: this.contributorId,
title: `Expenses covered by ${contributor.name}`,
description: this.description,
url: this.url,
expenses: JSON.parse(JSON.stringify((this.expenses)))
}
this.inProgress = true;
this.kredits.addReimbursement(attributes)
.then((/* reimbursement */) => {
this.router.transitionTo('budget');
})
.catch(e => {
console.error('Could not add reimbursement:', e);
window.alert('Something went wrong. Please check the browser console.')
})
.finally(() => {
this.inProgress = false;
});
}
}
@@ -0,0 +1,81 @@
<form {{on "submit" this.submit}} novalidate>
<label>
<p class="label">Contributor:</p>
<p>
<select id="contributor" required {{on "change" this.updateContributor}}>
{{#each this.contributors as |contributor|}}
<option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option>
{{/each}}
</select>
</p>
</label>
<fieldset class="horizontal thirds total-amounts">
<label>
<p class="label">Total amount (BTC):</p>
<p>
<Input @type="text"
@name="total-btc"
@placeholder="0.0015"
@value={{this.total}}
@required={{true}}
@pattern="([0-9]*[.])?[0-9]+"
@class={{this.totalInputClass}} />
</p>
</label>
<label>
<p class="label">EUR total</p>
<p>
<Input @type="text"
@name="total-eur"
@value={{this.totalEUR}}
@disabled={{true}} />
</p>
</label>
<label>
<p class="label">USD total</p>
<p>
<Input @type="text"
@name="total-usd"
@value={{this.totalUSD}}
@disabled={{true}} />
</p>
</label>
</fieldset>
<h3>Expense items</h3>
{{#if this.expenses}}
<ExpenseList @expenses={{this.expenses}}
@removeExpenseItem={{this.removeExpenseItem}}
@deletable={{true}} />
<p class="actions">
<button {{on "click" this.showExpenseForm}}
id="add-another-item" class="green small" type="button">
+ Add another item
</button>
</p>
{{else}}
<p>No line items yet.</p>
{{/if}}
<p class="actions">
{{#if this.inProgress}}
<Input @type="submit" @value="Submitting..." @disabled={{true}}
@title="Submit/propose this reimbursement" />
{{else}}
<Input @type="submit" @value="Submit" @disabled={{this.submitButtonDisabled}}
@title="Submit/propose this reimbursement" />
{{/if}}
</p>
{{#if this.expenseFormVisible}}
<h3 id="new-expense-item">New expense item</h3>
<AddExpenseItem @addExpenseItem={{fn this.addExpenseItem}} />
{{/if}}
</form>
<form id="add-expenses-from-file">
<h3>Add expense items from file</h3>
<input type="file" multiple="false"
onchange={{fn this.addExpensesFromFile}}
accept="application/json" />
</form>
@@ -0,0 +1,15 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
export default class BudgetBalancesComponent extends Component {
@service communityFunds;
get balancesSorted () {
return this.communityFunds.balances
.sortBy('confirmed_balance').reverse();
}
get loading () {
return !this.communityFunds.balancesLoaded;
}
}
@@ -0,0 +1,26 @@
<table class="token-balances {{if this.loading 'loading'}}">
<thead>
<tr>
<th>Token</th>
<th>Amount</th>
<th>Fiat value</th>
</tr>
</thead>
<tbody>
{{#each this.balancesSorted as |balance|}}
<tr>
<th>
<img src={{balance.token.icon}}
alt={{balance.token.description}}
title={{balance.token.description}} />
</th>
<td class="amount">
{{fmt-number balance.confirmed_balance}} <span class="unit">sats</span>
</td>
<td class="fiat-amount">
~{{balance.balanceUSD}} USD
</td>
</tr>
{{/each}}
</tbody>
</table>
@@ -0,0 +1,64 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
let categoryColors = {
community: "#fb6868",
design: "#fbe468",
dev: "#e068fb",
docs: "#97fb68",
ops: "#8f68fb",
}
export default Component.extend({
contributions: null,
chartOptions: Object.freeze({
legend: {
display: false
}
}),
chartData: computed('contributions', function() {
let kredits = this.contributions
.filterBy('vetoed', false)
.map(c => {
return { kind: c.kind, amount: c.amount }
})
.reduce(function (kinds, c) {
if (c.kind in kinds) {
kinds[c.kind] = kinds[c.kind] + c.amount
} else {
kinds[c.kind] = c.amount;
}
return kinds;
}, {});
return {
datasets: [{
data: [
kredits['community'],
kredits['design'],
kredits['dev'],
kredits['ops'],
kredits['docs'],
],
borderColor: [
categoryColors.community,
categoryColors.design,
categoryColors.dev,
categoryColors.ops,
categoryColors.docs,
],
borderWidth: 1
}],
labels: [
'Community',
'Design',
'Development',
'Operations & Infrastructure',
'Documentation'
],
}
})
});
@@ -0,0 +1,6 @@
<div class="chart">
<EmberChart @type="doughnut"
@data={{this.chartData}}
@options={{this.chartOptions}}
@width="200" @height="200" />
</div>
+24
View File
@@ -0,0 +1,24 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import moment from 'moment';
export default class ConfirmedInComponent extends Component {
@service kredits;
get confirmedInBlocks () {
return this.args.confirmedAtBlock - this.kredits.currentBlock;
}
get confirmedInSeconds () {
// A new block is mined every 30 seconds on average
return this.confirmedInBlocks * 30;
}
get confirmedInHumanTime () {
return moment.duration(this.confirmedInSeconds, "seconds").humanize();
}
get isConfirmed () {
return this.confirmedInBlocks <= 0;
}
}
+5
View File
@@ -0,0 +1,5 @@
{{#if this.isConfirmed}}
Confirmed at block <strong>{{@confirmedAtBlock}}</strong> (~ {{this.confirmedInHumanTime}} ago)
{{else}}
Confirming in <strong>{{this.confirmedInBlocks}}</strong> blocks (~ {{this.confirmedInHumanTime}})
{{/if}}
@@ -0,0 +1,69 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { sort } from '@ember/object/computed';
import { isPresent } from '@ember/utils';
import { inject as service } from '@ember/service';
export default Component.extend({
router: service(),
tagName: 'div',
classNames: ['contributions'],
selectedContribution: null,
showQuickFilter: false,
hideSmallContributions: false,
contributorId: null,
contributionKind: null,
kredits: service(),
contributorsSorting: Object.freeze(['name:asc']),
contributors: sort('kredits.contributors', 'contributorsSorting'),
contributorsActive: computed('contributors.[]', 'contributions', function() {
const activeIds = new Set(this.contributions.mapBy('contributorId'));
return this.contributors.filter(c => activeIds.has(c.id));
}),
contributionKinds: computed('contributions.[]', function() {
return this.contributions.mapBy('kind').uniq();
}),
contributionsFiltered: computed('contributions.[]', 'hideSmallContributions', 'contributorId', 'contributionKind', function() {
return this.contributions.filter(c => {
let included = true;
if (this.hideSmallContributions &&
c.amount <= 500) { included = false; }
if (isPresent(this.contributorId) &&
c.contributorId !== parseInt(this.contributorId)) { included = false; }
if (isPresent(this.contributionKind) &&
c.kind !== this.contributionKind) { included = false; }
return included;
});
}),
actions: {
veto (contributionId) {
if (this.contractInteractionEnabled) {
this.vetoContribution(contributionId);
} else {
window.alert('Only members can veto contributions. Please ask someone to set you up.');
}
},
openContributionDetails(contribution) {
this.router.transitionTo('dashboard.contributions.show', contribution);
}
}
});
@@ -0,0 +1,56 @@
{{#if @showQuickFilter}}
<div class="quick-filter">
<p>
<label class="filter-contributor">
Contributor:
<select onchange={{action (mut this.contributorId) value="target.value"}}>
<option value="" selected>all</option>
{{#each this.contributorsActive as |contributor|}}
<option value={{contributor.id}} selected={{eq this.contributorId contributor.id}}>{{contributor.name}}</option>
{{/each}}
</select>
</label>
<label class="filter-contribution-kind">
Kind:
<select onchange={{action (mut this.contributionKind) value="target.value"}}>
<option value="" selected>all</option>
{{#each this.contributionKinds as |kind|}}
<option value={{kind}} selected={{eq this.contributionKind kind}}>{{capitalize-string kind}}</option>
{{/each}}
</select>
</label>
<label class="filter-contribution-size">
<Input @type="checkbox" @checked={{this.hideSmallContributions}} />
Hide small contributions
</label>
</p>
</div>
{{/if}}
<ul class="item-list contribution-list {{if @loading 'loading'}}">
{{#each this.contributionsFiltered as |contribution|}}
<li role="button" data-contribution-id={{contribution.id}}
{{action "openContributionDetails" contribution}}
class="{{item-status contribution}}{{if (eq contribution.id @selectedContributionId) " selected"}}">
<p class="meta">
<span class="recipient"><UserAvatar @contributor={{contribution.contributor}} /></span>
<span class="category {{contribution.kind}}">({{contribution.kind}})</span>
<span class="title">{{contribution.description}}</span>
</p>
<p class="kredits-amount">
<span class="amount">{{contribution.amount}}</span><span class="symbol">₭S</span>
</p>
{{#unless contribution.vetoed}}
{{#unless (is-confirmed-contribution contribution)}}
<p class="voting">
{{input type="button" class="button small danger" value="veto"
click=(action "veto" contribution.id)
disabled=contribution.hasPendingChanges}}
</p>
{{/unless}}
{{/unless}}
</li>
{{/each}}
</ul>
+38 -17
View File
@@ -1,22 +1,43 @@
import Ember from 'ember';
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default Ember.Component.extend({
export default class ContributorComponent extends Component {
@service router;
tagName: 'table',
classNames: 'contributor-list',
selectedContributor: null,
actions: {
toggleContributorInfo(contributor) {
if (contributor.get('showMetadata')) {
contributor.set('showMetadata', false);
} else {
this.get('contributors').setEach('showMetadata', false);
contributor.set('showMetadata', true);
}
}
@tracked selectedContributorId = null;
@tracked showToplistOnly = true;
get contributorList () {
return this.args.contributorList;
}
});
get contributorTop10 () {
return this.contributorList ?
this.contributorList.slice(0, 10) : [];
}
get contributors () {
return this.showToplistOnly ?
this.contributorTop10 : this.contributorList;
}
get hiddenContributorsAmount () {
return this.contributorList.length - 10;
}
get showAllButtonText () {
return `Show ${this.hiddenContributorsAmount} more contributors`;
}
@action
openContributorDetails (contributor) {
this.router.transitionTo('dashboard.contributors.show', contributor);
}
@action
showAllContributors () {
this.showToplistOnly = false;
}
}
+32 -24
View File
@@ -1,24 +1,32 @@
<tbody>
{{#each contributors as |contributor|}}
<tr class="{{if contributor.isCurrentUser 'current-user'}}" {{action "toggleContributorInfo" contributor}}>
<td class="person">
<img class="avatar" src={{contributor.avatarURL}}>
{{contributor.name}}
</td>
<td class="kredits">
<span class="amount">{{contributor.kredits}}</span>
<span class="symbol">₭S</span>
</td>
</tr>
<tr class="metadata {{if contributor.isCurrentUser 'current-user'}} {{if contributor.showMetadata 'visible'}}">
<td colspan="2">
<ul>
<li><a href="https://testnet.etherscan.io/address/{{contributor.address}}">Inspect Ethereum transactions</a></li>
{{#if contributor.ipfsHash}}
<li><a href="https://ipfs.io/ipfs/{{contributor.ipfsHash}}">Inspect IPFS profile</a></li>
{{/if}}
</ul>
</td>
</tr>
{{/each}}
</tbody>
<table class="contributor-list {{if @loading 'loading'}}">
<thead>
</thead>
<tbody>
{{#each this.contributors as |c|}}
<tr role="button"
onclick={{action "openContributorDetails" c.contributor}}
class="{{if (is-current-user c.contributor) "current-user"}} {{if (eq c.contributor.id @selectedContributorId) "selected"}}">
<td class="person">
<UserAvatar @contributor={{c.contributor}} /> {{c.contributor.name}}
</td>
<td class="kredits">
<span class="amount">
{{#if @showUnconfirmedKredits}}
{{c.amountTotal}}
{{else}}
{{c.amountConfirmed}}
{{/if}}
</span>
<span class="symbol">₭S</span>
</td>
</tr>
{{/each}}
{{#if this.showToplistOnly}}
<tr role="button" onclick={{action "showAllContributors"}}>
<td colspan="2">
{{this.showAllButtonText}}
</td>
</tr>
{{/if}}
</tbody>
</table>

Some files were not shown because too many files have changed in this diff Show More