Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: customization of widgets config types #170

Merged
merged 1 commit into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions angular/headless/src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type {Partial2Levels, Widget, WidgetFactory, WidgetProps, WidgetsConfigStore} from '@agnos-ui/core';
import {createWidgetsConfig} from '@agnos-ui/core';
import type {ReadableSignal} from '@amadeus-it-group/tansu';
import {computed} from '@amadeus-it-group/tansu';
import type {FactoryProvider} from '@angular/core';
import {InjectionToken, Injector, Optional, SkipSelf, inject, runInInjectionContext} from '@angular/core';
import type {AdaptPropsSlots} from './slotTypes';
import type {WithPatchSlots} from './utils';
import {callWidgetFactoryWithConfig} from './utils';

export type WidgetsConfig = {
[WidgetName in keyof import('@agnos-ui/core').WidgetsConfig]: AdaptPropsSlots<import('@agnos-ui/core').WidgetsConfig[WidgetName]>;
};

export const widgetsConfigFactory = <Config extends {[widgetName: string]: object} = WidgetsConfig>(
widgetsConfigInjectionToken = new InjectionToken<WidgetsConfigStore<Config>>('widgetsConfig')
) => {
/**
* Creates a provider of widgets default configuration that inherits from any widgets default configuration already defined at an upper level
* in the Angular dependency injection system. It contains its own set of widgets configuration properties that override the same properties form
* the parent configuration.
*
* @remarks
* The configuration is computed from the parent configuration in two steps:
* - first step: the parent configuration is transformed by the adaptParentConfig function (if specified).
* If adaptParentConfig is not specified, this step is skipped.
* - second step: the configuration from step 1 is merged (2-levels deep) with the own$ store. The own$ store initially contains
* an empty object (i.e. no property from the parent is overridden). It can be changed by calling set on the store returned by
* {@link injectWidgetsConfig}.
*
* @param adaptParentConfig - optional function that receives a 2-levels copy of the widgets default configuration
* defined at an upper level in the Angular dependency injection system (or an empty object if there is none) and returns the widgets
* default configuration to be used.
* It is called only if the configuration is needed, and was not yet computed for the current value of the parent configuration.
* It is called in a tansu reactive context, so it can use any tansu store and will be called again if those stores change.
* It is also called in an Angular injection context, so it can call the Angular inject function to get and use dependencies from the
* Angular dependency injection system.

* @returns DI provider to be included a list of `providers` (for example at a component level or
* any other level of the Angular dependency injection system)
*
* @example
* ```typescript
* @Component({
* // ...
* providers: [
* provideWidgetsConfig((parentConfig) => {
* // first step configuration: transforms the parent configuration
* parentConfig.rating = parentConfig.rating ?? {};
* parentConfig.rating.className = `${parentConfig.rating.className ?? ''} my-rating-extra-class`
* return parentConfig;
* })
* ]
* })
* class MyComponent {
* widgetsConfig = injectWidgetsConfig();
* constructor() {
* this.widgetsConfig.set({
* // second step configuration: overrides the parent configuration
* rating: {
* slotStar: MyCustomSlotStar
* }
* });
* }
* // ...
* }
* ```
*/
const provideWidgetsConfig = (adaptParentConfig?: (config: Partial2Levels<Config>) => Partial2Levels<Config>): FactoryProvider => ({
provide: widgetsConfigInjectionToken,
useFactory: (parent: WidgetsConfigStore<Config> | null) => {
if (adaptParentConfig) {
const injector = inject(Injector);
const originalAdaptParentConfig = adaptParentConfig;
adaptParentConfig = (value) => runInInjectionContext(injector, () => originalAdaptParentConfig(value));
}
return createWidgetsConfig(parent ?? undefined, adaptParentConfig);
},
deps: [[new SkipSelf(), new Optional(), widgetsConfigInjectionToken]],
});

/**
* Returns the widgets default configuration store that was provided in the current injection context.
* Throws if the no widgets default configuration store was provided.
*
* @remarks
* This function must be called from an injection context, such as a constructor, a factory function, a field initializer or
* a function used with {@link https://angular.io/api/core/runInInjectionContext | runInInjectionContext}.
*
* @returns the widgets default configuration store.
*/
const injectWidgetsConfig = () => inject(widgetsConfigInjectionToken);

const injectWidgetConfig = <N extends keyof Config>(widgetName: N): ReadableSignal<Partial<Config[N]> | undefined> => {
const widgetsConfig = inject(widgetsConfigInjectionToken, {optional: true});
return computed(() => widgetsConfig?.()[widgetName]);
};

const callWidgetFactory = <W extends Widget>(
factory: WidgetFactory<W>,
widgetName?: null | keyof Config,
defaultConfig: Partial<WidgetProps<W>> | ReadableSignal<Partial<WidgetProps<W>>> = {}
): WithPatchSlots<W> => callWidgetFactoryWithConfig(factory, defaultConfig, widgetName ? (injectWidgetConfig(widgetName) as any) : null);

return {
/**
* Dependency Injection token which can be used to provide or inject the widgets default configuration store.
*/
widgetsConfigInjectionToken,
provideWidgetsConfig,
injectWidgetsConfig,
injectWidgetConfig,
callWidgetFactory,
};
};

export const {widgetsConfigInjectionToken, provideWidgetsConfig, injectWidgetConfig, injectWidgetsConfig, callWidgetFactory} =
widgetsConfigFactory<WidgetsConfig>();
5 changes: 3 additions & 2 deletions angular/headless/src/lib/slot.directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import {ChangeDetectionStrategy, Component, Injectable, Input, ViewChild, inject
import {toSignal} from '@angular/core/rxjs-interop';
import {TestBed} from '@angular/core/testing';
import {describe, expect, it} from 'vitest';
import {injectWidgetsConfig, provideWidgetsConfig} from './config';
import {SlotDirective} from './slot.directive';
import type {SlotContent} from './utils';
import {ComponentTemplate, injectWidgetsConfig, provideWidgetsConfig} from './utils';
import type {SlotContent} from './slotTypes';
import {ComponentTemplate} from './slotTypes';

describe('slot directive', () => {
@Component({
Expand Down
4 changes: 2 additions & 2 deletions angular/headless/src/lib/slot.directive.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {DOCUMENT} from '@angular/common';
import type {ComponentRef, EmbeddedViewRef, OnChanges, OnDestroy, SimpleChanges, Type} from '@angular/core';
import {Directive, EnvironmentInjector, Input, TemplateRef, ViewContainerRef, createComponent, inject, reflectComponentType} from '@angular/core';
import type {SlotContent} from './utils';
import {ComponentTemplate} from './utils';
import type {SlotContent} from './slotTypes';
import {ComponentTemplate} from './slotTypes';

abstract class SlotHandler<Props extends Record<string, any>, Slot extends SlotContent<Props> = SlotContent<Props>> {
constructor(public viewContainerRef: ViewContainerRef, public document: Document) {}
Expand Down
2 changes: 1 addition & 1 deletion angular/headless/src/lib/slotDefault.directive.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {WritableSignal} from '@amadeus-it-group/tansu';
import type {OnInit} from '@angular/core';
import {Directive, Input, TemplateRef, inject} from '@angular/core';
import type {SlotContent} from './utils';
import type {SlotContent} from './slotTypes';

@Directive({selector: '[auSlotDefault]', standalone: true})
export class SlotDefaultDirective<T extends object> implements OnInit {
Expand Down
32 changes: 32 additions & 0 deletions angular/headless/src/lib/slotTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type {SlotContent as CoreSlotContent, Widget, WidgetFactory, WidgetProps, WidgetSlotContext, WidgetState} from '@agnos-ui/core';
import type {TemplateRef, Type} from '@angular/core';

export class ComponentTemplate<Props, K extends string, T extends {[key in K]: TemplateRef<Props>}> {
constructor(public readonly component: Type<T>, public readonly templateProp: K) {}
}

export type SlotContent<Props extends object = object> =
| CoreSlotContent<Props>
| TemplateRef<Props>
| Type<unknown>
| ComponentTemplate<Props, any, any>;

export type AdaptSlotContentProps<Props extends Record<string, any>> = Props extends WidgetSlotContext<infer U>
? WidgetSlotContext<AdaptWidgetSlots<U>> & AdaptPropsSlots<Omit<Props, keyof WidgetSlotContext<any>>>
: AdaptPropsSlots<Props>;

export type AdaptPropsSlots<Props> = Omit<Props, `slot${string}`> & {
[K in keyof Props & `slot${string}`]: Props[K] extends CoreSlotContent<infer U> ? SlotContent<AdaptSlotContentProps<U>> : Props[K];
};

export type AdaptWidgetFactories<T> = {
[K in keyof T]: T[K] extends WidgetFactory<infer U> ? WidgetFactory<AdaptWidgetSlots<U>> : T[K];
};

export type AdaptWidgetSlots<W extends Widget> = Widget<
AdaptPropsSlots<WidgetProps<W>>,
AdaptPropsSlots<WidgetState<W>>,
AdaptWidgetFactories<W['api']>,
W['actions'],
W['directives']
>;
156 changes: 13 additions & 143 deletions angular/headless/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,136 +1,9 @@
import type {
SlotContent as CoreSlotContent,
WidgetsConfig as CoreWidgetsConfig,
Partial2Levels,
Widget,
WidgetFactory,
WidgetProps,
WidgetSlotContext,
WidgetState,
WidgetsConfigStore,
} from '@agnos-ui/core';
import {createWidgetsConfig} from '@agnos-ui/core';
import type {ReadableSignal, SubscribableStore} from '@amadeus-it-group/tansu';
import {computed, readable, writable} from '@amadeus-it-group/tansu';
import type {FactoryProvider, SimpleChanges, TemplateRef, Type} from '@angular/core';
import {InjectionToken, Injector, Optional, SkipSelf, inject, runInInjectionContext} from '@angular/core';

export class ComponentTemplate<Props, K extends string, T extends {[key in K]: TemplateRef<Props>}> {
constructor(public readonly component: Type<T>, public readonly templateProp: K) {}
}

export type SlotContent<Props extends object = object> =
| CoreSlotContent<Props>
| TemplateRef<Props>
| Type<unknown>
| ComponentTemplate<Props, any, any>;

export type AdaptSlotContentProps<Props extends Record<string, any>> = Props extends WidgetSlotContext<infer U>
? WidgetSlotContext<AdaptWidgetSlots<U>> & AdaptPropsSlots<Omit<Props, keyof WidgetSlotContext<any>>>
: AdaptPropsSlots<Props>;

export type AdaptPropsSlots<Props> = Omit<Props, `slot${string}`> & {
[K in keyof Props & `slot${string}`]: Props[K] extends CoreSlotContent<infer U> ? SlotContent<AdaptSlotContentProps<U>> : Props[K];
};

export type AdaptWidgetFactories<T> = {
[K in keyof T]: T[K] extends WidgetFactory<infer U> ? WidgetFactory<AdaptWidgetSlots<U>> : T[K];
};

export type AdaptWidgetSlots<W extends Widget> = Widget<
AdaptPropsSlots<WidgetProps<W>>,
AdaptPropsSlots<WidgetState<W>>,
AdaptWidgetFactories<W['api']>,
W['actions'],
W['directives']
>;

export type WidgetsConfig = {
[WidgetName in keyof CoreWidgetsConfig]: AdaptPropsSlots<CoreWidgetsConfig[WidgetName]>;
};

/**
* Dependency Injection token which can be used to provide or inject the widgets default configuration store.
*/
export const widgetsConfigInjectionToken = new InjectionToken<WidgetsConfigStore<WidgetsConfig>>('widgetsConfig');

/**
* Creates a provider of widgets default configuration that inherits from any widgets default configuration already defined at an upper level
* in the Angular dependency injection system. It contains its own set of widgets configuration properties that override the same properties form
* the parent configuration.
*
* @remarks
* The configuration is computed from the parent configuration in two steps:
* - first step: the parent configuration is transformed by the adaptParentConfig function (if specified).
* If adaptParentConfig is not specified, this step is skipped.
* - second step: the configuration from step 1 is merged (2-levels deep) with the own$ store. The own$ store initially contains
* an empty object (i.e. no property from the parent is overridden). It can be changed by calling set on the store returned by
* {@link injectWidgetsConfig}.
*
* @param adaptParentConfig - optional function that receives a 2-levels copy of the widgets default configuration
* defined at an upper level in the Angular dependency injection system (or an empty object if there is none) and returns the widgets
* default configuration to be used.
* It is called only if the configuration is needed, and was not yet computed for the current value of the parent configuration.
* It is called in a tansu reactive context, so it can use any tansu store and will be called again if those stores change.
* It is also called in an Angular injection context, so it can call the Angular inject function to get and use dependencies from the
* Angular dependency injection system.

* @returns DI provider to be included a list of `providers` (for example at a component level or
* any other level of the Angular dependency injection system)
*
* @example
* ```typescript
* @Component({
* // ...
* providers: [
* provideWidgetsConfig((parentConfig) => {
* // first step configuration: transforms the parent configuration
* parentConfig.rating = parentConfig.rating ?? {};
* parentConfig.rating.className = `${parentConfig.rating.className ?? ''} my-rating-extra-class`
* return parentConfig;
* })
* ]
* })
* class MyComponent {
* widgetsConfig = injectWidgetsConfig();
* constructor() {
* this.widgetsConfig.set({
* // second step configuration: overrides the parent configuration
* rating: {
* slotStar: MyCustomSlotStar
* }
* });
* }
* // ...
* }
* ```
*/
export const provideWidgetsConfig = (
adaptParentConfig?: (config: Partial2Levels<WidgetsConfig>) => Partial2Levels<WidgetsConfig>
): FactoryProvider => ({
provide: widgetsConfigInjectionToken,
useFactory: (parent: WidgetsConfigStore<WidgetsConfig> | null) => {
if (adaptParentConfig) {
const injector = inject(Injector);
const originalAdaptParentConfig = adaptParentConfig;
adaptParentConfig = (value) => runInInjectionContext(injector, () => originalAdaptParentConfig(value));
}
return createWidgetsConfig(parent ?? undefined, adaptParentConfig);
},
deps: [[new SkipSelf(), new Optional(), widgetsConfigInjectionToken]],
});

/**
* Returns the widgets default configuration store that was provided in the current injection context.
* Throws if the no widgets default configuration store was provided.
*
* @remarks
* This function must be called from an injection context, such as a constructor, a factory function, a field initializer or
* a function used with {@link https://angular.io/api/core/runInInjectionContext | runInInjectionContext}.
*
* @returns the widgets default configuration store.
*/
export const injectWidgetsConfig = () => inject(widgetsConfigInjectionToken);
import type {Widget, WidgetFactory, WidgetProps} from '@agnos-ui/core';
import {toReadableStore} from '@agnos-ui/core';
import type {ReadableSignal} from '@amadeus-it-group/tansu';
import {computed, writable} from '@amadeus-it-group/tansu';
import type {SimpleChanges, TemplateRef} from '@angular/core';
import type {SlotContent} from './slotTypes';

const createPatchSlots = <T extends object>(set: (object: Partial<T>) => void) => {
let lastValue: Partial<T> = {};
Expand Down Expand Up @@ -160,18 +33,17 @@ export type WithPatchSlots<W extends Widget> = W & {
}): void;
};

export const callWidgetFactory = <W extends Widget>(
export const callWidgetFactoryWithConfig = <W extends Widget>(
factory: WidgetFactory<W>,
widgetName: keyof WidgetsConfig | null,
defaultConfig: Partial<WidgetProps<W>> | ReadableSignal<Partial<WidgetProps<W>>> = {}
defaultConfig?: Partial<WidgetProps<W>> | ReadableSignal<Partial<WidgetProps<W>> | undefined>,
widgetConfig?: null | undefined | ReadableSignal<Partial<WidgetProps<W>> | undefined>
): WithPatchSlots<W> => {
const defaultConfigStore = typeof defaultConfig !== 'function' ? readable(defaultConfig) : defaultConfig;
const defaultConfig$ = toReadableStore(defaultConfig);
const slots$ = writable({});
const widgetsConfig = widgetName ? inject(widgetsConfigInjectionToken, {optional: true}) : undefined;
return {
...(factory({
config: computed(() => ({...(defaultConfigStore() as any), ...(widgetName ? widgetsConfig?.()[widgetName] : undefined), ...slots$()})),
}) as any),
...factory({
config: computed(() => ({...defaultConfig$(), ...widgetConfig?.(), ...slots$()})),
}),
patchSlots: createPatchSlots(slots$.set),
};
};
Expand All @@ -185,5 +57,3 @@ export function patchSimpleChanges(patchFn: (obj: any) => void, changes: SimpleC
}
patchFn(obj);
}

export type ExtractStoreType<T> = T extends SubscribableStore<infer U> ? U : never;
8 changes: 6 additions & 2 deletions angular/headless/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ export * from '@agnos-ui/core';
export * from './lib/slot.directive';
export * from './lib/slotDefault.directive';
export * from './lib/use.directive';
export * from './lib/slotTypes';
export * from './lib/utils';
export * from './lib/config';

export type {SlotContent, WidgetsConfig} from './lib/utils';
import type {PropsConfig, WidgetFactory, WidgetProps, WidgetState} from '@agnos-ui/core';
import type {AdaptSlotContentProps, AdaptWidgetSlots} from './lib/utils';
import type {AdaptSlotContentProps, AdaptWidgetSlots} from './lib/slotTypes';

export type {SlotContent} from './lib/slotTypes';
export type {WidgetsConfig} from './lib/config';

export type AccordionWidget = AdaptWidgetSlots<import('@agnos-ui/core').AccordionWidget>;
export type AccordionProps = WidgetProps<AccordionWidget>;
Expand Down
2 changes: 1 addition & 1 deletion react/headless/Slot.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Component} from 'react';
import type {SlotContent} from './utils';
import type {SlotContent} from './slotTypes';

type SlotFunction<Props = Record<string, never>> = ((props: Props) => React.ReactNode) | React.FunctionComponent<Props>;

Expand Down
Loading
Loading