sds-drag-drop
v1.0.0
Published
A lightweight decorator-based request/response registry for Angular component libraries.
Readme
Angular Function Registry
A lightweight decorator-based request/response registry for Angular component libraries.
It supports:
- Dispatching typed payloads from components.
- Handling dispatched operations in one registered handler.
- Returning handler results to selectors.
- Routing results to the source component, all selectors, other selectors, the first selector, or matching repeated components.
- Synchronous and asynchronous handler methods.
- Automatic component registration and cleanup through Angular lifecycle hooks.
Installation
npm install your-package-nameImport the registry APIs from your package entry point:
import {
Dispatch,
Handler,
RegistryDispatchMode,
RegistryFunctionType,
RegistrySelectorContext,
Selector,
} from 'your-package-name';Replace your-package-name with the published npm package name.
Required Initialisation
FunctionRegistryService uses a static runtime instance. Angular services provided with providedIn: 'root' are created only when first injected.
Inject the service once in the top-level component of the library so it exists before decorated child components initialise:
import {
ChangeDetectionStrategy,
Component,
inject,
} from '@angular/core';
import { FunctionRegistryService } from 'your-package-name';
@Component({
selector: 'lib-audit-builder',
standalone: true,
templateUrl: './audit-builder.component.html',
styleUrl: './audit-builder.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AuditBuilderComponent {
private readonly registry = inject(FunctionRegistryService);
}No registration code is required in child components. Components using @Handler() or @Selector() are registered automatically during ngOnInit and removed during ngOnDestroy.
Registry Function Types
The included registry types are:
RegistryFunctionType.PAGE.add
RegistryFunctionType.PAGE.delete
RegistryFunctionType.PAGE.save
RegistryFunctionType.PAGE.sort
RegistryFunctionType.QUESTION.add
RegistryFunctionType.QUESTION.delete
RegistryFunctionType.QUESTION.save
RegistryFunctionType.QUESTION.sort
RegistryFunctionType.LOGIC.add
RegistryFunctionType.LOGIC.delete
RegistryFunctionType.LOGIC.saveExample:
RegistryFunctionType.QUESTION.addresolves to:
'ADD_QUESTION'Basic Request and Response Flow
The registry flow is:
@Dispatch method
↓ returns payload
@Handler method
↓ returns result
@Selector method
↓ receives result and original dispatch informationShared models:
export interface AddQuestionPayload {
pageId: string;
parentId: string | null;
position: number;
}
export interface Question {
id: string;
pageId: string;
parentId: string | null;
position: number;
title: string;
}Dispatch component
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import {
Dispatch,
RegistryFunctionType,
} from 'your-package-name';
@Component({
selector: 'lib-add-question-button',
standalone: true,
template: `
<button
type="button"
(click)="addQuestion()">
Add question
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddQuestionButtonComponent {
readonly pageId = input.required<string>();
readonly parentId = input<string | null>(null);
readonly position = input.required<number>();
@Dispatch(RegistryFunctionType.QUESTION.add)
addQuestion(): AddQuestionPayload {
return {
pageId: this.pageId(),
parentId: this.parentId(),
position: this.position(),
};
}
}Handler component
import {
ChangeDetectionStrategy,
Component,
inject,
} from '@angular/core';
import {
Handler,
RegistryFunctionType,
} from 'your-package-name';
@Component({
selector: 'lib-question-handler',
standalone: true,
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuestionHandlerComponent {
private readonly questionApi = inject(QuestionApiService);
@Handler(RegistryFunctionType.QUESTION.add)
addQuestion(
payload: AddQuestionPayload,
): Promise<Question> {
return this.questionApi.addQuestion(payload);
}
}Selector component
import {
ChangeDetectionStrategy,
Component,
signal,
} from '@angular/core';
import {
RegistryFunctionType,
RegistrySelectorContext,
Selector,
} from 'your-package-name';
@Component({
selector: 'lib-question-list',
standalone: true,
template: `
@for (question of questions(); track question.id) {
<article>
{{ question.title }}
</article>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuestionListComponent {
readonly questions = signal<readonly Question[]>([]);
@Selector(RegistryFunctionType.QUESTION.add)
onQuestionAdded(
context: RegistrySelectorContext<Question>,
): void {
this.questions.update(questions => [
...questions,
context.result,
]);
}
}Selector Context
Every selector receives a RegistrySelectorContext<TResult>:
export interface RegistrySelectorContext<TResult = unknown> {
type: RegistryFunctionType;
result: TResult;
payload: unknown;
args: unknown[];
source?: object;
}Usage:
@Selector(RegistryFunctionType.QUESTION.add)
onQuestionAdded(
context: RegistrySelectorContext<Question>,
): void {
console.log(context.type);
console.log(context.result);
console.log(context.payload);
console.log(context.args);
console.log(context.source);
}type
The registry operation that was dispatched:
context.type === RegistryFunctionType.QUESTION.addresult
The value returned by the registered handler:
const question = context.result;payload
The value returned by the original @Dispatch() method:
const payload =
context.payload as AddQuestionPayload;args
The original arguments passed to the decorated dispatch method:
@Dispatch(RegistryFunctionType.QUESTION.delete)
deleteQuestion(
questionId: string,
event: MouseEvent,
): DeleteQuestionPayload {
return {
questionId,
};
}The selector can access them through:
const [questionId, event] = context.args;source
The component instance that called the dispatch method:
const dispatchingComponent = context.source;Dispatch Modes
The available modes are:
export type RegistryDispatchMode =
| 'SOURCE_OR_FIRST'
| 'SOURCE'
| 'ALL'
| 'OTHERS'
| 'FIRST'
| 'MATCH';Set the mode in @Dispatch():
@Dispatch(
RegistryFunctionType.QUESTION.add,
{
mode: 'ALL',
},
)SOURCE_OR_FIRST
This is the default mode.
@Dispatch(RegistryFunctionType.QUESTION.add)
addQuestion(): AddQuestionPayload {
return this.createPayload();
}It behaves as follows:
- When the dispatching component has a matching selector, that selector receives the result.
- Otherwise, the first matching selector registered for the operation receives the result.
Example with dispatch and selector in the same component:
@Component({
selector: 'lib-question-editor',
standalone: true,
template: `
<button
type="button"
(click)="addQuestion()">
Add question
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuestionEditorComponent {
readonly lastAddedQuestion =
signal<Question | null>(null);
@Dispatch(RegistryFunctionType.QUESTION.add)
addQuestion(): AddQuestionPayload {
return {
pageId: 'page-1',
parentId: null,
position: 1,
};
}
@Selector(RegistryFunctionType.QUESTION.add)
onQuestionAdded(
context: RegistrySelectorContext<Question>,
): void {
this.lastAddedQuestion.set(context.result);
}
}Only the selector owned by the dispatching QuestionEditorComponent instance receives the result.
SOURCE
Only selectors declared on the component instance that dispatched the operation receive the result.
@Dispatch(
RegistryFunctionType.QUESTION.save,
{
mode: 'SOURCE',
},
)
saveQuestion(): SaveQuestionPayload {
return {
question: this.question(),
};
}Selector in the same component:
@Selector(RegistryFunctionType.QUESTION.save)
onQuestionSaved(
context: RegistrySelectorContext<Question>,
): void {
this.question.set(context.result);
}When the source component has no selector for that operation, no selector is called.
ALL
Every selector registered for the operation receives the result, subject to any selector matcher.
@Dispatch(
RegistryFunctionType.QUESTION.add,
{
mode: 'ALL',
},
)
addQuestion(): AddQuestionPayload {
return {
pageId: this.pageId(),
parentId: null,
position: this.position(),
};
}Example selectors:
@Selector(RegistryFunctionType.QUESTION.add)
refreshQuestionList(
context: RegistrySelectorContext<Question>,
): void {
this.questions.update(items => [
...items,
context.result,
]);
}@Selector(RegistryFunctionType.QUESTION.add)
updateQuestionCount(
context: RegistrySelectorContext<Question>,
): void {
this.questionCount.update(count => count + 1);
}Both selectors receive the same result.
OTHERS
Every matching selector except selectors owned by the dispatching component receives the result.
@Dispatch(
RegistryFunctionType.QUESTION.save,
{
mode: 'OTHERS',
},
)
saveQuestion(): SaveQuestionPayload {
return {
question: this.question(),
};
}This is useful when the source component already updates itself directly and only other components need notification.
FIRST
Only the first matching selector registered for the operation receives the result.
@Dispatch(
RegistryFunctionType.QUESTION.delete,
{
mode: 'FIRST',
},
)
deleteQuestion(
questionId: string,
): DeleteQuestionPayload {
return {
questionId,
};
}Selector registration order follows component creation and ngOnInit order. Do not use FIRST when application behaviour depends on a specific component instance.
MATCH
MATCH sends the result only to selectors that:
- Define a
matchcallback. - Return
truefrom that callback.
This is used for repeated components where only one or selected component instances should receive the result.
Shared types:
export interface SaveQuestionPayload {
questionId: string;
title: string;
}
export interface Question {
id: string;
title: string;
}Dispatch component
The dispatch payload must contain the value used by the selector matcher.
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import {
Dispatch,
RegistryFunctionType,
} from 'your-package-name';
@Component({
selector: 'lib-save-question-button',
standalone: true,
template: `
<button
type="button"
(click)="saveQuestion()">
Save
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SaveQuestionButtonComponent {
readonly questionId = input.required<string>();
readonly title = input.required<string>();
@Dispatch(
RegistryFunctionType.QUESTION.save,
{
mode: 'MATCH',
},
)
saveQuestion(): SaveQuestionPayload {
return {
questionId: this.questionId(),
title: this.title(),
};
}
}Repeated selector component
import {
ChangeDetectionStrategy,
Component,
input,
signal,
} from '@angular/core';
import {
RegistryFunctionType,
RegistrySelectorContext,
Selector,
} from 'your-package-name';
@Component({
selector: 'lib-question-row',
standalone: true,
template: `
@if (question(); as item) {
<article>
{{ item.title }}
</article>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuestionRowComponent {
readonly questionId = input.required<string>();
readonly question = signal<Question | null>(null);
@Selector<QuestionRowComponent>(
RegistryFunctionType.QUESTION.save,
{
match: (component, context) =>
component.questionId() ===
(
context.payload as SaveQuestionPayload
).questionId,
},
)
onQuestionSaved(
context: RegistrySelectorContext<Question>,
): void {
this.question.set(context.result);
}
}Loop template
@for (question of questions(); track question.id) {
<lib-question-row
[questionId]="question.id"
/>
}When the dispatch payload contains:
{
questionId: 'question-2',
title: 'Updated question',
}the matcher checks each row:
'question-1' === 'question-2' // false
'question-2' === 'question-2' // true
'question-3' === 'question-2' // falseOnly the row with questionId() === 'question-2' receives the handler result.
Where component.questionId() comes from
The selector generic specifies the owner component type:
@Selector<QuestionRowComponent>(...)The registry invokes the matcher with:
selector.match?.(
selector.owner,
context,
)selector.owner is the actual registered QuestionRowComponent instance.
The component declares:
readonly questionId = input.required<string>();Angular signal inputs are read by calling them:
this.questionId()Inside the matcher, the owner instance is named component, so the same input is read as:
component.questionId()The dispatch component does not call component.questionId(). It only places its target identifier into the payload:
return {
questionId: this.questionId(),
title: this.title(),
};The selector compares:
selector component questionId
===
dispatch payload questionIdMatching Multiple Selectors
A matcher does not have to identify only one component. Every selector whose matcher returns true receives the result in MATCH mode.
Example matching every row on the same page:
export interface SavePagePayload {
pageId: string;
}@Selector<QuestionRowComponent>(
RegistryFunctionType.PAGE.save,
{
match: (component, context) =>
component.pageId() ===
(
context.payload as SavePagePayload
).pageId,
},
)
onPageSaved(
context: RegistrySelectorContext<AuditPage>,
): void {
this.refreshFromPage(context.result);
}All question rows belonging to the saved page receive the result.
Matchers with Other Dispatch Modes
Matchers are evaluated before dispatch mode routing.
This means selector matchers also filter selectors when using:
'SOURCE_OR_FIRST'
'SOURCE'
'ALL'
'OTHERS'
'FIRST'Example:
@Dispatch(
RegistryFunctionType.QUESTION.save,
{
mode: 'ALL',
},
)Only selectors with no matcher or selectors whose matcher returns true are included.
Selectors without a matcher always pass the initial matching filter:
selector.match?.(
selector.owner,
context,
) ?? trueIn MATCH mode, selectors without a matcher are then excluded.
Async Dispatch Methods
A dispatch method can return a value or a promise:
@Dispatch(RegistryFunctionType.QUESTION.save)
async prepareQuestionSave(): Promise<SaveQuestionPayload> {
const valid = await this.validateQuestion();
if (!valid) {
return undefined as never;
}
return {
question: this.question(),
};
}When a dispatch method returns undefined, registry execution stops:
@Dispatch(RegistryFunctionType.QUESTION.save)
saveQuestion(): SaveQuestionPayload | undefined {
if (this.form.invalid) {
return undefined;
}
return {
question: this.form.getRawValue(),
};
}No handler or selector is called.
Async Handlers
Handlers can return either a direct result or a promise:
@Handler(RegistryFunctionType.QUESTION.save)
saveQuestion(
payload: SaveQuestionPayload,
): Promise<Question> {
return this.questionApi.save(payload);
}The registry waits for the handler before notifying selectors.
Handler Returning undefined
When a handler returns undefined, selectors are not notified:
@Handler(RegistryFunctionType.QUESTION.save)
async saveQuestion(
payload: SaveQuestionPayload,
): Promise<Question | undefined> {
const saved = await this.questionApi.save(payload);
if (!saved) {
return undefined;
}
return saved;
}Handler Errors
Errors thrown by a handler reject the dispatch promise:
try {
await this.saveQuestion();
} catch (error) {
console.error(error);
}A decorated dispatch method always returns a promise because the decorator wrapper is asynchronous:
const result = await this.saveQuestion();Directly Receiving the Dispatch Result
The dispatch method resolves with the handler result:
@Dispatch(RegistryFunctionType.QUESTION.add)
addQuestion(): AddQuestionPayload {
return {
pageId: this.pageId(),
parentId: null,
position: this.position(),
};
}Call it with:
const question = await this.addQuestion();For strong typing at the calling location, define the decorated method return according to the original payload contract and cast the resolved handler result only where required by the current decorator typing.
Selectors remain the primary typed response mechanism.
Accessing Original Dispatch Arguments
@Dispatch(RegistryFunctionType.QUESTION.sort)
sortQuestion(
questionId: string,
previousIndex: number,
currentIndex: number,
): SortQuestionPayload {
return {
questionId,
previousIndex,
currentIndex,
};
}Selector:
@Selector(RegistryFunctionType.QUESTION.sort)
onQuestionSorted(
context: RegistrySelectorContext<SortQuestionResult>,
): void {
const [
questionId,
previousIndex,
currentIndex,
] = context.args;
console.log({
questionId,
previousIndex,
currentIndex,
});
}Use context.payload for the normalized request sent to the handler. Use context.args only when the original method arguments are required.
Multiple Selectors in One Component
A component can declare multiple selectors for different registry operations:
export class QuestionListComponent {
@Selector(RegistryFunctionType.QUESTION.add)
onQuestionAdded(
context: RegistrySelectorContext<Question>,
): void {
// Add question to local state.
}
@Selector(RegistryFunctionType.QUESTION.save)
onQuestionSaved(
context: RegistrySelectorContext<Question>,
): void {
// Replace question in local state.
}
@Selector(RegistryFunctionType.QUESTION.delete)
onQuestionDeleted(
context: RegistrySelectorContext<string>,
): void {
// Remove question from local state.
}
@Selector(RegistryFunctionType.QUESTION.sort)
onQuestionSorted(
context: RegistrySelectorContext<SortQuestionResult>,
): void {
// Apply returned ordering.
}
}A component can also declare multiple selector methods for the same operation. Each method is registered separately.
One Handler Per Registry Type
The current registry stores handlers in:
Map<RegistryFunctionType, RegistryStoredFn>Only one handler can be active for each registry type.
When another component registers a handler for the same type, it replaces the previously registered handler:
@Handler(RegistryFunctionType.QUESTION.add)Do not register the same handler type in multiple active components.
Use one handler owner for each operation:
ADD_QUESTION → one handler
SAVE_QUESTION → one handler
DELETE_QUESTION → one handler
SORT_QUESTION → one handlerMultiple selectors are supported.
Component Lifecycle
Components using @Handler() or @Selector() are automatically registered by patched lifecycle methods:
ngOnInit → registerHost(component)
ngOnDestroy → unregisterHost(component)Existing lifecycle methods continue to execute:
export class QuestionRowComponent {
ngOnInit(): void {
// Existing component setup still runs.
}
ngOnDestroy(): void {
// Existing component cleanup still runs.
}
@Selector(RegistryFunctionType.QUESTION.save)
onQuestionSaved(
context: RegistrySelectorContext<Question>,
): void {
// Selector logic.
}
}Do not replace ngOnInit or ngOnDestroy dynamically after decorators have been evaluated.
No Registered Handler
When no handler is registered for a dispatched type:
const handler = this.handlers.get(type);the dispatch resolves with:
undefinedNo selector is notified.
Ensure the component containing the required handler exists in the rendered component tree before dispatching.
No Matching Selector
When the selected dispatch mode produces no selectors, the handler still executes and returns its result. No selector callback runs.
Example:
@Dispatch(
RegistryFunctionType.QUESTION.save,
{
mode: 'SOURCE',
},
)When the source component has no QUESTION.save selector:
handler executes
result is returned
no selector is calledComplete Feature Example
Parent component
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import {
FunctionRegistryService,
} from 'your-package-name';
@Component({
selector: 'lib-question-feature',
standalone: true,
imports: [
AddQuestionButtonComponent,
QuestionHandlerComponent,
QuestionListComponent,
],
templateUrl: './question-feature.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuestionFeatureComponent {
private readonly registry = inject(FunctionRegistryService);
readonly questions = signal<readonly Question[]>([]);
}Parent template
<lib-question-handler />
<lib-question-list />
<lib-add-question-button
[pageId]="'page-1'"
[parentId]="null"
[position]="1"
/>Execution flow
User clicks Add question
↓
AddQuestionButtonComponent.addQuestion()
↓
@Dispatch returns AddQuestionPayload
↓
FunctionRegistryService.execute()
↓
QuestionHandlerComponent.addQuestion(payload)
↓
@Handler returns Question
↓
FunctionRegistryService.notifySelectors()
↓
QuestionListComponent.onQuestionAdded(context)Public Package Exports
Export the registry APIs from the library public entry point:
export {
Dispatch,
FunctionRegistryService,
Handler,
RegistryFunctionType,
Selector,
} from './lib/function-registry';
export type {
RegistryDispatchMode,
RegistryDispatchOptions,
RegistrySelectorContext,
RegistrySelectorOptions,
} from './lib/function-registry';Consumers can then import from the package root:
import {
Dispatch,
Handler,
RegistryFunctionType,
RegistrySelectorContext,
Selector,
} from 'your-package-name';Mode Reference
| Mode | Selector delivery |
|---|---|
| SOURCE_OR_FIRST | Source selector when present; otherwise the first matching selector |
| SOURCE | Selectors owned by the dispatching component |
| ALL | Every matching selector |
| OTHERS | Every matching selector except selectors owned by the source component |
| FIRST | First matching selector |
| MATCH | Every selector that defines a matcher and returns true |
Usage Rules
- Inject
FunctionRegistryServiceonce in the library feature root. - Register only one active handler per registry function type.
- Return
undefinedfrom a dispatch method to cancel execution. - Return
undefinedfrom a handler to prevent selector notification. - Use
SOURCE_OR_FIRSTfor the default source-aware behaviour. - Use
SOURCEwhen only the dispatching component should receive the result. - Use
ALLwhen every relevant selector should receive the result. - Use
OTHERSwhen the source should be excluded. - Use
FIRSTonly when registration order is acceptable. - Use
MATCHfor repeated components that must be selected using payload data. - Include matching identifiers in the dispatch payload when using
MATCH. - Ensure the handler component is rendered before dispatching.
