diff --git a/packages/forms/src/stores/form.ts b/packages/forms/src/stores/form.ts index 008f83f3..3001e20e 100644 --- a/packages/forms/src/stores/form.ts +++ b/packages/forms/src/stores/form.ts @@ -1,6 +1,6 @@ import {isFunction} from "lodash"; import {disposeOnUnmount} from "mobx-react"; -import {Component, useEffect, useId, useState} from "react"; +import {Component, useEffect, useId, useRef, useState} from "react"; import { buildNode, @@ -13,6 +13,7 @@ import { FormNodeBuilder, isAnyStoreNode, isStoreListNode, + SourceNodeType, StoreListNode, StoreNode, toFlatValues @@ -177,14 +178,21 @@ export function useFormNode(entityOrNode: any, builder: (x: any) => any = (x: an * @param formNode Le FormNode du formulaire. * @param builder Le configurateur. */ -export function makeFormActions( +export function makeFormActions< + FN extends FormListNode | FormNode, + A extends readonly any[] = never, + C extends SourceNodeType | void | string | number = never, + U extends SourceNodeType | void | string | number = never, + S extends SourceNodeType | void | string | number = never +>( componentClass: Component | null, formNode: FN, - builder: (s: FormActionsBuilder) => FormActionsBuilder + builder: (s: FormActionsBuilder) => FormActionsBuilder ): FormActions { const formActions = new FormActions(formNode, builder(new FormActionsBuilder())); if (componentClass) { disposeOnUnmount(componentClass, formActions.register()); + formActions.init(); } return formActions; } @@ -199,15 +207,25 @@ export function makeFormActions( - node: FN, - builder: (s: FormActionsBuilder) => FormActionsBuilder, - deps: any[] = [] -) { +export function useFormActions< + FN extends FormListNode | FormNode, + A extends readonly any[] = never, + C extends SourceNodeType | void | string | number = never, + U extends SourceNodeType | void | string | number = never, + S extends SourceNodeType | void | string | number = never +>(node: FN, builder: (s: FormActionsBuilder) => FormActionsBuilder, deps: any[] = []) { const trackingId = useId(); const [formActions] = useState(() => new FormActions(node, builder(new FormActionsBuilder()), trackingId)); - useEffect(() => formActions.register(node.sourceNode, builder(new FormActionsBuilder())), [node, ...deps]); + const firstRender = useRef(true); + useEffect(() => { + const disposer = formActions.register(node.sourceNode, builder(new FormActionsBuilder())); + if (firstRender.current) { + formActions.init(); + firstRender.current = false; + } + return disposer; + }, [node, ...deps]); return formActions; } diff --git a/packages/stores/src/entity/form/actions.ts b/packages/stores/src/entity/form/actions.ts index e370f0c6..7e41945c 100644 --- a/packages/stores/src/entity/form/actions.ts +++ b/packages/stores/src/entity/form/actions.ts @@ -1,14 +1,28 @@ import i18next from "i18next"; -import {uniqueId} from "lodash"; +import {merge, uniqueId} from "lodash"; import {action, autorun, computed, makeObservable, observable, runInAction} from "mobx"; import {v4} from "uuid"; import {messageStore, requestStore, Router, RouterConfirmation} from "@focus4/core"; import {LoadRegistration, NodeLoadBuilder, toFlatValues} from "../store"; -import {FormListNode, FormNode, isFormEntityField, isFormNode, isStoreNode, NodeToType} from "../types"; - -type FormActionsEvent = "cancel" | "edit" | "error" | "load" | "save"; +import {FormListNode, FormNode, isFormEntityField, isFormNode, isStoreNode, SourceNodeType} from "../types"; + +interface FormActionHandlers< + FN extends FormNode | FormListNode, + C extends SourceNodeType | void | string | number, + U extends SourceNodeType | void | string | number, + S extends SourceNodeType | void | string | number +> { + init?: ((event: "init", data: SourceNodeType) => void)[]; + load?: ((event: "load", data: SourceNodeType) => void)[]; + cancel?: ((event: "cancel") => void)[]; + edit?: ((event: "edit") => void)[]; + create?: ((event: "create", data: C) => void)[]; + update?: ((event: "update", data: U) => void)[]; + save?: ((event: "save", data: S) => void)[]; + error?: ((event: "error") => void)[]; +} /** Props passées au composant de formulaire. */ export interface ActionsFormProps { @@ -35,12 +49,15 @@ export interface ActionsPanelProps { /** Gère les actions d'un formulaire. A n'utiliser QUE pour des formulaires (avec de la sauvegarde). */ export class FormActions< FN extends FormListNode | FormNode = any, - A extends readonly any[] = any[] + A extends readonly any[] = never, + C extends SourceNodeType | void | string | number = never, + U extends SourceNodeType | void | string | number = never, + S extends SourceNodeType | void | string | number = never > extends LoadRegistration { /** Mode d'affichage des erreurs du formulaire. */ errorDisplay: "after-focus" | "always" | "never"; - protected declare builder: FormActionsBuilder; + protected declare builder: FormActionsBuilder; private readonly formNode: FN; @@ -50,7 +67,7 @@ export class FormActions< * @param builder Builder pour les actions de forumaire. * @param trackingId Id de suivi de requête pour ce load. */ - constructor(formNode: FN, builder: FormActionsBuilder, trackingId = v4()) { + constructor(formNode: FN, builder: FormActionsBuilder, trackingId = v4()) { super(formNode.sourceNode, builder, trackingId); this.formNode = formNode; @@ -93,7 +110,6 @@ export class FormActions< /** * Handler de clic sur le bouton "Annuler". * - * * Repasse le formulaire en mode consultation, annule toutes les modification et lance les handlers `"cancel"`. */ onClickCancel() { @@ -102,7 +118,7 @@ export class FormActions< } this.formNode.form.isEdit = false; this.formNode.reset(); - (this.builder.handlers.cancel || []).forEach(handler => handler("cancel")); + (this.builder.handlers.cancel ?? []).forEach(handler => handler("cancel")); } /** @@ -115,7 +131,7 @@ export class FormActions< this.errorDisplay = "after-focus"; } this.formNode.form.isEdit = true; - (this.builder.handlers.edit || []).forEach(handler => handler("edit")); + (this.builder.handlers.edit ?? []).forEach(handler => handler("edit")); } /** Appelle le service de sauvegarde avec le contenu du formulaire, si ce dernier est valide. */ @@ -124,8 +140,15 @@ export class FormActions< this.errorDisplay = "always"; } - // On ne fait rien si on est déjà en chargement. - if (this.isLoading || !this.builder.saveService) { + // On ne fait rien si on est déjà en chargement et qu'on a pas le bon service disponible à appeler. + if ( + this.isLoading || + !( + this.builder.saveService || + (this.builder.createService && !this.params) || + (this.builder.updateService && this.params) + ) + ) { return; } @@ -139,9 +162,16 @@ export class FormActions< }; } - const data = await requestStore.track([this.trackingId, ...this.builder.trackingIds], () => - this.builder.saveService!(toFlatValues(this.formNode)) - ); + const data = await requestStore.track([this.trackingId, ...this.builder.trackingIds], () => { + const d = toFlatValues(this.formNode); + if (this.builder.saveService) { + return this.builder.saveService(d); + } else if (this.params && this.builder.updateService) { + return this.builder.updateService(...[...this.params, d]); + } else { + return this.builder.createService!(d); + } + }); runInAction(() => { this.formNode.form.isEdit = false; @@ -162,7 +192,7 @@ export class FormActions< } updateInitialData(this.formNode, this.formNode.form._initialData); - if (data) { + if (data && typeof data === "object") { // En sauvegardant le retour du serveur dans le noeud de store, l'état du formulaire va se réinitialiser. if (isStoreNode(this.formNode.sourceNode)) { this.formNode.sourceNode.replace(data); @@ -181,9 +211,15 @@ export class FormActions< this.errorDisplay = "after-focus"; } - (this.builder.handlers.save || []).forEach(handler => handler("save")); + if (this.builder.saveService) { + (this.builder.handlers.save ?? []).forEach(handler => handler("save", data as S)); + } else if (this.builder.updateService && this.params) { + (this.builder.handlers.update ?? []).forEach(handler => handler("update", data as U)); + } else { + (this.builder.handlers.create ?? []).forEach(handler => handler("create", data as C)); + } } catch (e: unknown) { - (this.builder.handlers.error || []).forEach(handler => handler("error")); + (this.builder.handlers.error ?? []).forEach(handler => handler("error")); throw e; } } @@ -209,14 +245,34 @@ export class FormActions< return loadDisposer; } + + /** Appelle le service d'initilisation enregistré sur le `FormActions`, si aucun `load` ne peut être appelé. */ + init() { + if (this.builder.initService && (!this.params || !this.builder.loadService)) { + this.builder.initService().then(initData => { + this.formNode.form._initialData = merge(this.formNode.form._initialData ?? {}, initData); + + if (isFormNode(this.formNode)) { + this.formNode.sourceNode.replace(this.formNode.form._initialData!); + } else { + this.formNode.sourceNode.replaceNodes(this.formNode.form._initialData!); + } + + (this.builder.handlers.init ?? []).forEach(handler => handler("init", initData)); + }); + } + } } export class FormActionsBuilder< FN extends FormListNode | FormNode, - A extends readonly any[] = never -> extends NodeLoadBuilder { + P extends readonly any[] = never, + C extends SourceNodeType | void | string | number = never, + U extends SourceNodeType | void | string | number = never, + S extends SourceNodeType | void | string | number = never +> extends NodeLoadBuilder { /** @internal */ - readonly handlers = {} as Record void)[]>; + readonly handlers: FormActionHandlers = {}; /** @internal */ actionsErrorDisplay?: "after-focus" | "always" | "never"; @@ -225,40 +281,108 @@ export class FormActionsBuilder< /** @internal */ message = "focus.detail.saved"; /** @internal */ - saveService?: (entity: NodeToType) => Promise | void>; + initService?: () => Promise>; + /** @internal */ + createService?: (entity: SourceNodeType) => Promise; + /** @internal */ + updateService?: (...params: [...P, SourceNodeType]) => Promise; + /** @internal */ + saveService?: (entity: SourceNodeType) => Promise; /** * Précise la getter permettant de récupérer la liste des paramètres pour l'action de chargement. * Si le résultat contient des observables, le service de chargement sera rappelé à chaque modification. * @param get Getter. */ - params(get: () => NA | undefined): FormActionsBuilder>; - params(get: () => NA): FormActionsBuilder]>; + override params( + get: () => NP | undefined + ): FormActionsBuilder, C, U, S>; + override params(get: () => NP): FormActionsBuilder], C, U, S>; /** * Précise des paramètres fixes (à l'initialisation) pour l'action de chargement. * @param params Paramètres. */ - params(...params: NA): FormActionsBuilder>; - params(...params: NA): FormActionsBuilder> { - return super.params(...params) as any; + override params(params?: NP): FormActionsBuilder, C, U, S>; + override params(params?: NP): FormActionsBuilder], C, U, S>; + override params(params?: NP | (() => NP | undefined)): any { + return super.params(params); + } + + /** + * Enregistre un service d'initilisation du formulaire, qui sera appelé au premier rendu pour compléter les données + * déjà présentes dans le `formNode`, s'il n'y a pas de `load` a appeler. + * + * Les données seront ensuite recopiées dans le noeud source, afin de pouvoir correctement identifier les données + * qui ont été saisies par l'utilisateur dans le formulaire (via `formNode.form.hasChanged`). + * + * La méthode peut aussi être appelée sans service, simplement pour effectuer la recopie (ou un clear) du noeud source. + * @param service Service d'initialisation. + */ + init(service?: () => Promise>) { + this.initService = service ?? (async () => ({} as SourceNodeType)); + return this; } /** * Enregistre le service de chargement. * @param service Service de chargement. */ - load( - service: A extends never ? never : (...params: A) => Promise | undefined> - ): FormActionsBuilder { + override load( + service: P extends never ? never : (...params: P) => Promise> + ): FormActionsBuilder { return super.load(service) as any; } /** - * Enregistre un service de sauvegarde. + * Enregistre un service de création, appelé par `actions.save()` si les `params` sont `undefined`. + * @param service Service de création. + */ + create | void | string | number>( + service: (entity: SourceNodeType) => Promise + ): FormActionsBuilder { + if (this.saveService) { + throw new Error("Impossible de spécifier un `create` en même temps qu'un `save`."); + } + + // @ts-ignore + this.createService = service; + // @ts-ignore + return this as any; + } + + /** + * Enregistre un service de mise à jour, appelé par `actions.save()` si les `params` ne sont pas `undefined`. + * + * Le service sera appelé avec les `params`, puis le contenu du `formNode`. + * @param service Service de mise à jour. + */ + update | void | string | number>( + service: (...params: [...P, SourceNodeType]) => Promise + ): FormActionsBuilder { + if (this.saveService) { + throw new Error("Impossible de spécifier un `update` en même temps qu'un `save`."); + } + + // @ts-ignore + this.updateService = service; + // @ts-ignore + return this; + } + + /** + * Enregistre un service de sauvegarde, appelé par `actions.save()`. * @param service Service de sauvegarde. */ - save(service: (entity: NodeToType) => Promise | void>): FormActionsBuilder { + save | void | string | number>( + service: (entity: SourceNodeType) => Promise + ): FormActionsBuilder { + if (this.createService || this.updateService) { + throw new Error("Impossible de spécifier un `save` en même temps qu'un `create` ou un `update`."); + } + + // @ts-ignore this.saveService = service; + // @ts-ignore return this; } @@ -267,7 +391,10 @@ export class FormActionsBuilder< * @param event Nom de l'évènement. * @param handler Handler de l'évènement. */ - on(event: E | E[], handler: (event: E) => void): FormActionsBuilder { + override on>( + event: E | E[], + handler: (event: E, data: Parameters[E]>[0]>[1]) => void + ): FormActionsBuilder { return super.on(event as any, handler as any) as any; } @@ -277,7 +404,7 @@ export class FormActionsBuilder< * Cela permettra d'ajouter l'état du service au `isLoading` de cet(ces) id(s). * @param trackingIds Id(s) de suivi. */ - trackingId(...trackingIds: string[]): FormActionsBuilder { + override trackingId(...trackingIds: string[]): FormActionsBuilder { return super.trackingId(...trackingIds) as any; } @@ -293,7 +420,7 @@ export class FormActionsBuilder< * * @param mode Mode d'affichage des erreurs. */ - errorDisplay(mode: "after-focus" | "always" | "never"): FormActionsBuilder { + errorDisplay(mode: "after-focus" | "always" | "never") { this.actionsErrorDisplay = mode; return this; } @@ -302,7 +429,7 @@ export class FormActionsBuilder< * Surcharge le message de succès à la sauvegarde du formulaire. Si le message est vide, aucun message ne sera affiché. * @param message Le message de succès. */ - successMessage(message: string): FormActionsBuilder { + successMessage(message: string) { this.message = message; return this; } diff --git a/packages/stores/src/entity/index.ts b/packages/stores/src/entity/index.ts index e02a55aa..0c621d7c 100644 --- a/packages/stores/src/entity/index.ts +++ b/packages/stores/src/entity/index.ts @@ -39,7 +39,6 @@ export type { FieldEntry2, FormEntityField, FormNode, - FormNodeToSourceType, ListEntry, NodeToType, ObjectEntry, @@ -53,6 +52,7 @@ export type { PatchedFormNode, RecursiveListEntry, SingleDomainFieldType, + SourceNodeType, StoreListNode, StoreNode, Validator diff --git a/packages/stores/src/entity/store/load.ts b/packages/stores/src/entity/store/load.ts index 3b2cf77b..6c0807b7 100644 --- a/packages/stores/src/entity/store/load.ts +++ b/packages/stores/src/entity/store/load.ts @@ -50,7 +50,8 @@ export class LoadRegistration(this, { builder: observable.ref, isLoading: computed, - node: observable.ref + node: observable.ref, + params: computed.struct }); } @@ -59,19 +60,31 @@ export class LoadRegistration - this.builder.loadService!(...params) + this.builder.loadService!(...this.params!) ); runInAction(() => { if (data) { @@ -82,7 +95,7 @@ export class LoadRegistration handler("load")); + (this.builder.handlers.load ?? []).forEach(handler => handler("load", data)); }); } } @@ -125,14 +138,14 @@ export class LoadRegistration { +export class NodeLoadBuilder { /** @internal */ - readonly handlers = {} as Record<"load", ((event: "load") => void)[]>; + readonly handlers: {load?: ((event: "load", data: NodeToType) => void)[]} = {}; /** @internal */ getLoadParams?: () => any | undefined; /** @internal */ - loadService?: (...args: A) => Promise | undefined>; + loadService?: (...args: P) => Promise>; /** @internal */ trackingIds: string[] = []; @@ -141,22 +154,24 @@ export class NodeLoadBuilder(get: () => NA | undefined): NodeLoadBuilder>; - params(get: () => NA): NodeLoadBuilder]>; + params(get: () => NP | undefined): NodeLoadBuilder; + params(get: () => NP): NodeLoadBuilder]>; /** * Précise des paramètres fixes (à l'initialisation) pour l'action de chargement. * @param params Paramètres. */ - params(...params: NA): NodeLoadBuilder>; - params(...params: NA): NodeLoadBuilder> { - if (!params.length) { + params(params?: NP): NodeLoadBuilder; + params(params?: NP): NodeLoadBuilder]>; + params(params?: NP | (() => NP | undefined)): any { + if (params === undefined) { // @ts-ignore this.getLoadParams = () => []; - } else if (!isFunction(params[0])) { + } else if (isFunction(params)) { // @ts-ignore - this.getLoadParams = () => (params.length === 1 ? params[0] : params); + this.getLoadParams = params; } else { - this.getLoadParams = params[0]; + // @ts-ignore + this.getLoadParams = () => params; } // @ts-ignore @@ -167,9 +182,7 @@ export class NodeLoadBuilder Promise | undefined> - ): NodeLoadBuilder { + load(service: P extends never ? never : (...params: P) => Promise>): NodeLoadBuilder { this.loadService = service; return this; } @@ -179,7 +192,7 @@ export class NodeLoadBuilder void): NodeLoadBuilder { + on(event: "load"[] | "load", handler: (event: "load", data?: NodeToType) => void): NodeLoadBuilder { if (!Array.isArray(event)) { event = [event]; } @@ -201,7 +214,7 @@ export class NodeLoadBuilder { + trackingId(...trackingIds: string[]): NodeLoadBuilder { this.trackingIds = trackingIds; return this; } diff --git a/packages/stores/src/entity/types/index.ts b/packages/stores/src/entity/types/index.ts index 59e538e5..c02b2046 100644 --- a/packages/stores/src/entity/types/index.ts +++ b/packages/stores/src/entity/types/index.ts @@ -49,7 +49,7 @@ export type { PatchedFormNode } from "./patch"; export type {StoreListNode, StoreNode} from "./store"; -export type {FormNodeToSourceType, NodeToType} from "./utils"; +export type {NodeToType, SourceNodeType} from "./utils"; export type { DateValidator, EmailValidator, diff --git a/packages/stores/src/entity/types/utils.ts b/packages/stores/src/entity/types/utils.ts index 2cde69fa..834f8f74 100644 --- a/packages/stores/src/entity/types/utils.ts +++ b/packages/stores/src/entity/types/utils.ts @@ -6,24 +6,18 @@ import {FormEntityField, FormListNode, FormNode} from "./form"; import {StoreListNode, StoreNode} from "./store"; /** Génère l'objet JS "normal" équivalent à un noeud de store. */ -export type NodeToType = - SN extends FormListNode - ? EntityToType[] - : SN extends FormNode - ? EntityToType - : SN extends StoreListNode - ? EntityToType[] - : SN extends StoreNode - ? EntityToType - : never; - -/** Génère l'objet JS "normal" équivalent au SourceNode d'un FormNode. */ -export type FormNodeToSourceType = - SN extends FormListNode - ? EntityToType[] - : SN extends FormNode - ? EntityToType - : never; +export type NodeToType = SN extends FormListNode + ? EntityToType[] + : SN extends FormNode + ? EntityToType + : SN extends StoreListNode + ? EntityToType[] + : SN extends StoreNode + ? EntityToType + : never; + +/** Génère l'objet JS "normal" équivalent au noeud source d'un FormNode. */ +export type SourceNodeType = NodeToType; export function isEntityField(data: any): data is EntityField { return isObject(data) && "$field" in data; diff --git a/packages/stores/src/focus4.stores.ts b/packages/stores/src/focus4.stores.ts index 52d92fd7..aba796c7 100644 --- a/packages/stores/src/focus4.stores.ts +++ b/packages/stores/src/focus4.stores.ts @@ -49,7 +49,6 @@ export type { FormEntityField, FormListNode, FormNode, - FormNodeToSourceType, InputComponents, ListEntry, Metadata, @@ -66,6 +65,7 @@ export type { RecursiveListEntry, SelectComponents, SingleDomainFieldType, + SourceNodeType, StoreListNode, StoreNode, Validator