Skip to content

Commit

Permalink
feat: customization of widgets config types
Browse files Browse the repository at this point in the history
  • Loading branch information
divdavem committed Oct 3, 2023
1 parent 02c60e8 commit 4f250df
Show file tree
Hide file tree
Showing 18 changed files with 474 additions and 406 deletions.
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

0 comments on commit 4f250df

Please sign in to comment.