Skip to content

Commit 2b005b8

Browse files
committed
feat: make FlashMessage component generic for type-safe custom fields
1 parent 096df5b commit 2b005b8

File tree

10 files changed

+208
-31
lines changed

10 files changed

+208
-31
lines changed

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ This ember addon adds a flash message service and component to your app.
2929
- [TypeScript](#typescript)
3030
- [Basic Usage](#basic-usage)
3131
- [Custom Fields with Generics](#custom-fields-with-generics)
32+
- [Typing Dynamically Registered Methods](#typing-dynamically-registered-methods)
3233
- [Displaying flash messages](#displaying-flash-messages)
3334
- [Custom `close` action](#custom-close-action)
3435
- [Styling with Foundation or Bootstrap](#styling-with-foundation-or-bootstrap)
@@ -499,6 +500,58 @@ this.flashMessages.success('Oops', {
499500
});
500501
```
501502

503+
Custom fields are also type-safe in templates. The `FlashMessage` component exposes the properly typed flash object with your custom fields:
504+
505+
```gjs
506+
import { FlashMessage } from 'ember-cli-flash';
507+
508+
<template>
509+
{{#each this.flashMessages.queue as |flash|}}
510+
<FlashMessage @flash={{flash}} as |component flash close|>
511+
{{flash.message}}
512+
{{flash.category}} {{! ✓ Typed as 'system' | 'user' | 'background' | undefined }}
513+
{{#if flash.action}}
514+
<button type="button" {{on "click" flash.action}}>Action</button>
515+
{{/if}}
516+
</FlashMessage>
517+
{{/each}}
518+
</template>
519+
```
520+
521+
### Typing Dynamically Registered Methods
522+
523+
When you configure custom `types` in `flashMessageDefaults`, the service dynamically creates convenience methods for each type at runtime. The base class already declares types for the default methods (`success`, `info`, `warning`, `danger`, `alert`, `secondary`), but TypeScript doesn't automatically recognize any custom types you add.
524+
525+
To get type safety for custom type methods, declare them explicitly in your service subclass:
526+
527+
```typescript
528+
import { FlashMessagesService } from 'ember-cli-flash';
529+
import type { FlashObjectOptions } from 'ember-cli-flash';
530+
531+
interface CustomFlashFields {
532+
id?: string;
533+
category?: string;
534+
}
535+
536+
type Options = FlashObjectOptions & CustomFlashFields;
537+
538+
export default class MyFlashMessages extends FlashMessagesService<CustomFlashFields> {
539+
// Only declare custom types not in the base class
540+
// (success, info, warning, danger, alert, secondary are already typed)
541+
declare error: (message: string, options?: Options) => this;
542+
declare custom: (message: string, options?: Options) => this;
543+
544+
get flashMessageDefaults() {
545+
return {
546+
...super.flashMessageDefaults,
547+
types: ['error', 'success', 'warning', 'custom'],
548+
};
549+
}
550+
}
551+
```
552+
553+
This pattern uses TypeScript's `declare` keyword to inform the type system about methods that exist at runtime but aren't defined in the base class types.
554+
502555
## Displaying flash messages
503556

504557
Then, to display somewhere in your app, add this to your component:

UPGRADING.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,14 @@ Register custom types for your application:
132132
```typescript
133133
// app/services/flash-messages.ts
134134
import { FlashMessagesService } from 'ember-cli-flash';
135+
import type { FlashObjectOptions } from 'ember-cli-flash';
135136

136137
export default class FlashMessages extends FlashMessagesService {
138+
// Declare custom types for TypeScript (base types like success, warning are already typed)
139+
declare notice: (message: string, options?: FlashObjectOptions) => this;
140+
declare error: (message: string, options?: FlashObjectOptions) => this;
141+
declare system: (message: string, options?: FlashObjectOptions) => this;
142+
137143
get flashMessageDefaults() {
138144
return {
139145
...super.flashMessageDefaults,
@@ -247,6 +253,17 @@ const flash = this.flashMessages.findBy('id', 'save-notification');
247253
this.flashMessages.removeBy('userId', 123);
248254
```
249255

256+
The `FlashMessage` component is also generic and infers the type from the `@flash` arg, giving you type-safe access to custom fields in templates:
257+
258+
```gjs
259+
{{#each this.flashMessages.queue as |flash|}}
260+
<FlashMessage @flash={{flash}} as |component flash close|>
261+
{{flash.message}}
262+
{{flash.actionUrl}} {{! ✓ Typed as string | undefined }}
263+
</FlashMessage>
264+
{{/each}}
265+
```
266+
250267
### New Methods: `findBy` and `removeBy`
251268

252269
Two new methods have been added to the service for finding and removing flash messages by any field:

demo-app/components/demo-examples.gts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ export default class DemoExamples extends Component {
7676
});
7777
};
7878

79+
// Custom typed methods (declared in service)
80+
showError = () => {
81+
this.flashMessages.error('Something went wrong!', {
82+
category: 'system',
83+
});
84+
};
85+
86+
showNotice = () => {
87+
this.flashMessages.notice('Did you know? This is a custom notice type.', {
88+
timeout: 5000,
89+
});
90+
};
91+
7992
// Custom fields with generics
8093
showWithCustomFields = () => {
8194
this.flashMessages.success('Message with custom fields', {
@@ -319,17 +332,35 @@ export default class DemoExamples extends Component {
319332
<p class="text-muted small mb-3">
320333
Use
321334
<code>add()</code>
322-
with any Bootstrap alert type or your own custom types
335+
with any Bootstrap alert type, or declare custom types in your service
323336
</p>
324-
<button
325-
type="button"
326-
class="btn btn-secondary"
327-
{{on "click" this.showCustomType}}
328-
>
329-
Show Secondary Type
330-
</button>
331-
<pre><code>this.flashMessages.add({ message: 'Custom type message', type:
332-
'secondary', timeout: 4000, });</code></pre>
337+
<div class="btn-group-example mb-2">
338+
<button
339+
type="button"
340+
class="btn btn-secondary"
341+
{{on "click" this.showCustomType}}
342+
>
343+
Secondary (via add)
344+
</button>
345+
<button
346+
type="button"
347+
class="btn btn-danger"
348+
{{on "click" this.showError}}
349+
>
350+
Error (custom type)
351+
</button>
352+
<button
353+
type="button"
354+
class="btn btn-light"
355+
{{on "click" this.showNotice}}
356+
>
357+
Notice (custom type)
358+
</button>
359+
</div>
360+
<pre><code>// Custom types declared in service: // declare error:
361+
(message: string, options?: Options) => this;
362+
this.flashMessages.error('Something went wrong!');
363+
this.flashMessages.notice('Did you know?');</code></pre>
333364
</section>
334365

335366
{{! TypeScript Generics }}

demo-app/components/flash-container.gts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,17 @@ export default class FlashContainer extends Component {
1616
<div class="d-flex justify-content-between align-items-start">
1717
<div>
1818
<strong>{{component.flashType}}</strong>
19-
<p class="mb-0 mt-1">{{flashData.message}}</p>
19+
{{#if flashData.category}}
20+
<span class="badge bg-secondary ms-2">
21+
{{flashData.category}}
22+
</span>
23+
{{/if}}
24+
<p class="mb-0 mt-1">
25+
{{flashData.message}}
26+
</p>
27+
{{#if flashData.id}}
28+
<small class="text-muted">ID: {{flashData.id}}</small>
29+
{{/if}}
2030
</div>
2131
<button
2232
type="button"

demo-app/services/flash-messages.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import FlashMessagesService from '#src/services/flash-messages.ts';
22

3+
import type { FlashObjectOptions } from '#src/flash/object.ts';
4+
35
/**
46
* Custom fields interface for our flash messages.
57
* This demonstrates TypeScript generics support.
@@ -15,13 +17,24 @@ export interface CustomFlashFields {
1517
onAction?: () => void;
1618
}
1719

20+
/** Options type combining flash options with custom fields */
21+
type Options = FlashObjectOptions<CustomFlashFields> & CustomFlashFields;
22+
1823
/**
1924
* Extended FlashMessages service demonstrating:
2025
* - TypeScript generics for custom fields
2126
* - Custom default configuration
2227
* - Custom convenience methods
28+
* - Dynamically registered custom types
2329
*/
2430
export default class MyFlashMessagesService extends FlashMessagesService<CustomFlashFields> {
31+
/**
32+
* Declare custom types not in the base class.
33+
* Base class already declares: success, info, warning, danger, alert, secondary
34+
*/
35+
declare error: (message: string, options?: Options) => this;
36+
declare notice: (message: string, options?: Options) => this;
37+
2538
/**
2639
* Override defaults to customize behavior
2740
*/
@@ -30,6 +43,17 @@ export default class MyFlashMessagesService extends FlashMessagesService<CustomF
3043
...super.flashMessageDefaults,
3144
timeout: 4000,
3245
showProgress: false,
46+
// Add custom types (base types are included automatically)
47+
types: [
48+
'success',
49+
'info',
50+
'warning',
51+
'danger',
52+
'alert',
53+
'secondary',
54+
'error',
55+
'notice',
56+
],
3357
};
3458
}
3559

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
"@embroider/compat": "^4.1.13",
8787
"@embroider/core": "^4.4.3",
8888
"@embroider/macros": "^1.19.7",
89-
"@embroider/vite": "^1.5.1",
89+
"@embroider/vite": "^1.5.2",
9090
"@eslint/js": "^9.39.2",
9191
"@glimmer/component": "^2.0.0",
9292
"@glint/ember-tsc": "^1.1.1",
@@ -111,7 +111,7 @@
111111
"globals": "^17.3.0",
112112
"prettier": "^3.8.1",
113113
"prettier-plugin-ember-template-tag": "^2.1.3",
114-
"publint": "^0.3.16",
114+
"publint": "^0.3.17",
115115
"qunit": "^2.25.0",
116116
"qunit-dom": "^3.5.0",
117117
"release-plan": "^0.17.4",
@@ -125,7 +125,7 @@
125125
"@embroider/macros": "^1.13.2",
126126
"ember-modifier": ">= 4.0.0"
127127
},
128-
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
128+
"packageManager": "pnpm@10.29.1+sha512.48dae233635a645768a3028d19545cacc1688639eeb1f3734e42d6d6b971afbf22aa1ac9af52a173d9c3a20c15857cfa400f19994d79a2f626fcc73fccda9bbc",
129129
"engines": {
130130
"node": ">= 24",
131131
"pnpm": ">= 10"

pnpm-lock.yaml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/flash-message.gts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,28 @@ import { modifier } from 'ember-modifier';
88
import type { Timer } from '@ember/runloop';
99
import type FlashObject from '../flash/object.ts';
1010

11-
export interface FlashMessageSignature {
11+
// FlashObject with custom properties from T accessible in yielded block
12+
type FlashWithCustomProps<T extends Record<string, unknown>> = FlashObject<T> &
13+
T;
14+
15+
export interface FlashMessageSignature<
16+
T extends Record<string, unknown> = Record<string, unknown>,
17+
> {
1218
Args: {
13-
flash: FlashObject;
19+
flash: FlashObject<T>;
1420
messageStyle?: 'bootstrap' | 'foundation';
1521
messageStylePrefix?: string;
1622
exitingClass?: string;
1723
};
1824
Element: HTMLDivElement;
1925
Blocks: {
20-
default: [FlashMessage, FlashObject, onClose: () => void];
26+
default: [FlashMessage<T>, FlashWithCustomProps<T>, onClose: () => void];
2127
};
2228
}
2329

24-
export default class FlashMessage extends Component<FlashMessageSignature> {
30+
export default class FlashMessage<
31+
T extends Record<string, unknown> = Record<string, unknown>,
32+
> extends Component<FlashMessageSignature<T>> {
2533
@tracked active = false;
2634
@tracked pendingSet: Timer | undefined;
2735
@tracked _mouseEnterHandler: ((event?: Event) => void) | undefined;
@@ -31,6 +39,11 @@ export default class FlashMessage extends Component<FlashMessageSignature> {
3139
return this.args.messageStyle ?? 'bootstrap';
3240
}
3341

42+
// Expose flash with custom properties typed (for yielding to blocks)
43+
get flash(): FlashWithCustomProps<T> {
44+
return this.args.flash as FlashWithCustomProps<T>;
45+
}
46+
3447
get showProgress() {
3548
return this.args.flash.showProgress;
3649
}
@@ -167,7 +180,7 @@ export default class FlashMessage extends Component<FlashMessageSignature> {
167180
{{this.bindEvents}}
168181
>
169182
{{#if (has-block)}}
170-
{{yield this @flash this.onClose}}
183+
{{yield this this.flash this.onClose}}
171184
{{else}}
172185
{{@flash.message}}
173186
{{#if this.showProgressBar}}

src/flash/object.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ export default class FlashObject<
4242
// Defaults to true in test environment via @embroider/macros
4343
static isTimeoutDisabled = defaultDisableTimeout;
4444

45-
// Index signature for custom properties from T
46-
[key: string]: unknown;
45+
// Note: Custom properties from T are copied at runtime in constructor
46+
// TypeScript sees them via FlashObject<T> & T intersection in service/component
4747

4848
@tracked exiting = false;
4949

0 commit comments

Comments
 (0)