From 8fb5c22fab37facd16c305f6b11a3c0bfde8a092 Mon Sep 17 00:00:00 2001 From: Damien Frikha Date: Fri, 1 Dec 2017 12:48:34 +0100 Subject: [PATCH 01/89] Renommage : $entity -> $field (pour EntityField) et ViewModel -> FormNode. --- src/collections/components/search/summary.tsx | 2 +- src/collections/store/search.ts | 2 +- src/entity/__test__/index.ts | 120 +++++++++--------- src/entity/auto-form.tsx | 14 +- src/entity/field-helpers.tsx | 10 +- src/entity/{view-model.ts => form-node.ts} | 46 +++---- src/entity/index.ts | 2 +- src/entity/readme.md | 25 ++-- src/entity/store.ts | 2 +- src/entity/types.ts | 2 +- 10 files changed, 113 insertions(+), 112 deletions(-) rename src/entity/{view-model.ts => form-node.ts} (50%) diff --git a/src/collections/components/search/summary.tsx b/src/collections/components/search/summary.tsx index c5e4ac3a7..51278340d 100644 --- a/src/collections/components/search/summary.tsx +++ b/src/collections/components/search/summary.tsx @@ -55,7 +55,7 @@ export class Summary extends React.Component, void> { // On ajoute la liste des critères. if (!hideCriteria) { for (const criteriaKey in store.flatCriteria) { - const {translationKey, domain} = store.criteria[criteriaKey].$entity; + const {translationKey, domain} = store.criteria[criteriaKey].$field; const value = (store.flatCriteria as any)[criteriaKey]; topicList.push({ key: criteriaKey, diff --git a/src/collections/store/search.ts b/src/collections/store/search.ts index d72086543..31e42f07e 100644 --- a/src/collections/store/search.ts +++ b/src/collections/store/search.ts @@ -156,7 +156,7 @@ export class SearchStore extends ListStoreBa for (const key in criteria) { if (key !== "set" && key !== "clear") { const entry = ((criteria as any)[key] as EntityField); - const {$entity: {domain}, value} = entry; + const {$field: {domain}, value} = entry; if (domain && domain.validator && value !== undefined && value !== null) { const validStat = validate({value, name: ""}, domain.validator); if (validStat.errors.length) { diff --git a/src/entity/__test__/index.ts b/src/entity/__test__/index.ts index cfa8cc0fe..a052e6e6e 100644 --- a/src/entity/__test__/index.ts +++ b/src/entity/__test__/index.ts @@ -3,8 +3,8 @@ import {isObservable, isObservableArray} from "mobx"; import test = require("tape"); +import {makeFormNode} from "../form-node"; import {makeEntityStore, toFlatValues} from "../store"; -import {createViewModel} from "../view-model"; import {LigneEntity} from "./ligne"; import {OperationEntity, OperationNode} from "./operation"; import {ProjetEntity, ProjetNode} from "./projet"; @@ -57,13 +57,13 @@ test("EntityStore: Création", t => { const {id, numero, montant} = OperationEntity.fields; t.deepEqual(store.operation, { - id: {$entity: id, value: undefined}, - numero: {$entity: numero, value: undefined}, - montant: {$entity: montant, value: undefined}, + id: {$field: id, value: undefined}, + numero: {$field: numero, value: undefined}, + montant: {$field: montant, value: undefined}, structure: { - id: {$entity: StructureEntity.fields.id, value: undefined}, - nom: {$entity: StructureEntity.fields.nom, value: undefined}, - siret: {$entity: StructureEntity.fields.siret, value: undefined}, + id: {$field: StructureEntity.fields.id, value: undefined}, + nom: {$field: StructureEntity.fields.nom, value: undefined}, + siret: {$field: StructureEntity.fields.siret, value: undefined}, set: store.operation.structure.set, clear: store.operation.structure.clear }, @@ -147,7 +147,7 @@ test("EntityStore: Ajout élément dans une liste", t => { store.structureList.set(structureList); store.structureList.pushNode({id: 8}); t.assert(store.structureList.length === 4, "La liste 'structureList' possède bien un élément de plus."); - t.deepEqual(store.structureList[3].id.$entity, StructureEntity.fields.id, "L'élément ajouté est bien un node avec les bonnes métadonnées."); + t.deepEqual(store.structureList[3].id.$field, StructureEntity.fields.id, "L'élément ajouté est bien un node avec les bonnes métadonnées."); t.equal(store.structureList[3].id.value, 8, "L'élement ajouté possède bien les valeurs attendues"); t.end(); @@ -180,70 +180,70 @@ test("toFlatValues", t => { t.end(); }); -test("ViewModel: Création", t => { +test("FormNode: Création", t => { const entry = getStore().operation; - const viewModel = createViewModel(entry); + const formNode = makeFormNode(entry); const entry2 = getStore().projetTest; - const viewModel2 = createViewModel(entry2); + const formNode2 = makeFormNode(entry2); - t.deepEqual(viewModel.numero, entry.numero, "Les champs simples du viewModel sont bien identiques à ceux du model."); - t.deepEqual(viewModel.structure, entry.structure, "Les champs composites du viewModel sont bien identiques à ceux du model."); - t.assert(isObservableArray(viewModel2.ligneList), "Une sous liste est bien toujours observable"); - t.deepEqual(viewModel2.ligneList.$entity, entry2.ligneList.$entity, "Une sous liste a bien toujours son entité attachée."); - t.assert(!isObservable(viewModel2.ligneList.$entity), "Le champ '$entity' d'une sous liste n'est bien pas observable"); - t.assert(viewModel2.ligneList.set, "Une sous liste a bien toujours sa méthode 'set' attachée"); + t.deepEqual(formNode.numero, entry.numero, "Les champs simples du FormNode sont bien identiques à ceux du StoreNode."); + t.deepEqual(formNode.structure, entry.structure, "Les champs composites du FormNode sont bien identiques à ceux du StoreNode."); + t.assert(isObservableArray(formNode2.ligneList), "Une sous liste est bien toujours observable"); + t.deepEqual(formNode2.ligneList.$entity, entry2.ligneList.$entity, "Une sous liste a bien toujours son entité attachée."); + t.assert(!isObservable(formNode2.ligneList.$entity), "Le champ '$entity' d'une sous liste n'est bien pas observable"); + t.assert(formNode2.ligneList.set, "Une sous liste a bien toujours sa méthode 'set' attachée"); - t.comment("ViewModel: Modification de model."); + t.comment("FormNode: Modification de StoreNode."); entry.set(operation); - t.equal(viewModel.id.value, entry.id.value, "Les modifications du model sont bien répercutées sur les champs simples."); - t.deepEqual(viewModel.structure, entry.structure, "Les modifications du model sont bien répercutées sur les champs composites."); - - t.comment("ViewModel: Modification de viewModel"); - viewModel.montant.value = 1000; - viewModel.set({structure: {id: 26}}); - viewModel.structure.set({nom: "yolo"}); - - t.equal(viewModel.montant.value, 1000, "Champ simple: le viewModel a bien été modifié."); - t.equal(entry.montant.value, operation.montant, "Champ simple: le model est bien toujours identique."); - t.equal(viewModel.structure.id.value, 26, "Champ composite via set global: le viewModel a bien été modifié."); - t.equal(entry.structure.id.value, operation.structure.id, "Champ composite via set global: le model est bien toujours identique."); - t.equal(viewModel.structure.nom.value, "yolo", "Champ composite via set local: le viewModel a bien été modifié."); - t.equal(entry.structure.nom.value, operation.structure.nom, "Champ composite via set local: le model est bien toujours identique."); - - t.comment("ViewModel: model.set(toFlatValues(viewModel))"); - entry.set(toFlatValues(viewModel)); - - t.equal(viewModel.montant.value, 1000, "Champ simple: le viewModel est bien toujours identique."); - t.equal(entry.montant.value, 1000, "Champ simple: le model a bien été mis à jour."); - t.equal(viewModel.structure.id.value, 26, "Champ composite via set global: le viewModel est bien toujours identique."); - t.equal(entry.structure.id.value, 26, "Champ composite via set global: le model a bien été mis à jour."); - t.equal(viewModel.structure.nom.value, "yolo", "Champ composite via set local: le viewModel est bien toujours identique."); - t.equal(entry.structure.nom.value, "yolo", "Champ composite via set local: le model a bien été mis à jour."); - - t.comment("ViewModel: reset"); - viewModel.set({montant: 3000, structure: {id: 23, nom: "LOL"}}); - viewModel.reset(); - - t.equal(viewModel.montant.value, 1000, "Champ simple: le viewModel a bien été réinitialisé."); - t.equal(entry.montant.value, 1000, "Champ simple: le model est bien toujours identique."); - t.equal(viewModel.structure.id.value, 26, "Champ composite via set global: le viewModel a bien été réinitialisé."); - t.equal(entry.structure.id.value, 26, "Champ composite via set global: le model est bien toujours identique."); - t.equal(viewModel.structure.nom.value, "yolo", "Champ composite via set local: le viewModel a bien été réinitialisé."); - t.equal(entry.structure.nom.value, "yolo", "Champ composite via set local: le model est bien toujours identique."); - - t.comment("ViewModel: unsubscribe"); - viewModel.unsubscribe(); + t.equal(formNode.id.value, entry.id.value, "Les modifications du StoreNode sont bien répercutées sur les champs simples."); + t.deepEqual(formNode.structure, entry.structure, "Les modifications du StoreNode sont bien répercutées sur les champs composites."); + + t.comment("FormNode: Modification de FormNode"); + formNode.montant.value = 1000; + formNode.set({structure: {id: 26}}); + formNode.structure.set({nom: "yolo"}); + + t.equal(formNode.montant.value, 1000, "Champ simple: le FormNode a bien été modifié."); + t.equal(entry.montant.value, operation.montant, "Champ simple: le StoreNode est bien toujours identique."); + t.equal(formNode.structure.id.value, 26, "Champ composite via set global: le FormNode a bien été modifié."); + t.equal(entry.structure.id.value, operation.structure.id, "Champ composite via set global: le StoreNode est bien toujours identique."); + t.equal(formNode.structure.nom.value, "yolo", "Champ composite via set local: le FormNode a bien été modifié."); + t.equal(entry.structure.nom.value, operation.structure.nom, "Champ composite via set local: le StoreNode est bien toujours identique."); + + t.comment("FormNode: StoreNode.set(toFlatValues(FormNode))"); + entry.set(toFlatValues(formNode)); + + t.equal(formNode.montant.value, 1000, "Champ simple: le FormNode est bien toujours identique."); + t.equal(entry.montant.value, 1000, "Champ simple: le StoreNode a bien été mis à jour."); + t.equal(formNode.structure.id.value, 26, "Champ composite via set global: le FormNode est bien toujours identique."); + t.equal(entry.structure.id.value, 26, "Champ composite via set global: le StoreNode a bien été mis à jour."); + t.equal(formNode.structure.nom.value, "yolo", "Champ composite via set local: le FormNode est bien toujours identique."); + t.equal(entry.structure.nom.value, "yolo", "Champ composite via set local: le StoreNode a bien été mis à jour."); + + t.comment("FormNode: reset"); + formNode.set({montant: 3000, structure: {id: 23, nom: "LOL"}}); + formNode.reset(); + + t.equal(formNode.montant.value, 1000, "Champ simple: le FormNode a bien été réinitialisé."); + t.equal(entry.montant.value, 1000, "Champ simple: le StoreNode est bien toujours identique."); + t.equal(formNode.structure.id.value, 26, "Champ composite via set global: le FormNode a bien été réinitialisé."); + t.equal(entry.structure.id.value, 26, "Champ composite via set global: le StoreNode est bien toujours identique."); + t.equal(formNode.structure.nom.value, "yolo", "Champ composite via set local: le FormNode a bien été réinitialisé."); + t.equal(entry.structure.nom.value, "yolo", "Champ composite via set local: le StoreNode est bien toujours identique."); + + t.comment("FormNode: unsubscribe"); + formNode.unsubscribe(); entry.montant.value = 2; - t.equal(viewModel.montant.value, 1000, "Le viewModel n'a pas été mis à jour."); + t.equal(formNode.montant.value, 1000, "Le FormNode n'a pas été mis à jour."); - t.comment("ViewModel: subscribe"); - viewModel.subscribe(); + t.comment("FormNode: subscribe"); + formNode.subscribe(); entry.montant.value = 5; - t.equal(viewModel.montant.value, 5, "Le viewModel a bien été mis à jour."); + t.equal(formNode.montant.value, 5, "Le FormNode a bien été mis à jour."); t.end(); }); diff --git a/src/entity/auto-form.tsx b/src/entity/auto-form.tsx index 70f0244fd..93cb4b149 100644 --- a/src/entity/auto-form.tsx +++ b/src/entity/auto-form.tsx @@ -10,9 +10,9 @@ import {messageStore} from "../message"; import {classAutorun} from "../util"; import {Field, FieldProps, RefValues} from "./field"; +import {FormNode, makeFormNode} from "./form-node"; import {StoreNode, toFlatValues} from "./store"; import {Domain, EntityField} from "./types"; -import {createViewModel, ViewModel} from "./view-model"; import { displayFor, @@ -30,8 +30,8 @@ export interface AutoFormOptions { /** Pour ajouter une classe particulière sur le formulaire. */ className?: string; - /** ViewModel externe de `storeData`, s'il y a besoin d'externaliser le state interne du formulaire. */ - entity?: E & ViewModel; + /** FormNode externe de `storeData`, s'il y a besoin d'externaliser le state interne du formulaire. */ + entity?: E & FormNode; /** Par défaut: true */ hasForm?: boolean; @@ -81,7 +81,7 @@ export abstract class AutoForm extends React.Component

; @@ -132,7 +132,7 @@ export abstract class AutoForm extends React.Component

, {entity, className, hasForm, i18nPrefix, initiallyEditing}: AutoFormOptions = {}) { this.storeData = storeData; this.services = services; - this.entity = entity || createViewModel(storeData); + this.entity = entity || makeFormNode(storeData); this.isCustomEntity = entity !== undefined; this.isEdit = initiallyEditing || false; this.hasForm = hasForm !== undefined ? hasForm : true; @@ -373,9 +373,9 @@ export abstract class AutoForm extends React.Component

this.fields[field.$entity.translationKey] = f; + options.innerRef = f => this.fields[field.$field.translationKey] = f; if (!options.error) { - options.error = this.errors[field.$entity.translationKey]; + options.error = this.errors[field.$field.translationKey]; } } diff --git a/src/entity/field-helpers.tsx b/src/entity/field-helpers.tsx index e5514bba6..fa34a4d5b 100644 --- a/src/entity/field-helpers.tsx +++ b/src/entity/field-helpers.tsx @@ -9,8 +9,8 @@ import {EntityField} from "../entity"; import Field, {FieldProps, RefValues} from "./field"; import {Domain} from "./types"; -/** $entity par défaut dans le cas où on n'a pas de métadonnées particulière pour afficher un champ. */ -export const $entity = { +/** $field par défaut dans le cas où on n'a pas de métadonnées particulière pour afficher un champ. */ +export const $field = { domain: {}, type: "field" as "field", isRequired: false, @@ -62,7 +62,7 @@ export function fieldFor field.value = value)) as any; } else { - trueField = {$entity, value: field}; + trueField = {$field, value: field}; } // Si on ne pose pas de ref, on considère qu'on n'a pas de formulaire et donc qu'on attend un comportement par défaut un peu différent. @@ -120,7 +120,7 @@ export function buildFieldProps>, options: Partial> ) { - const {value, $entity: {domain = {}, translationKey, isRequired, name, comment}} = field; + const {value, $field: {domain = {}, translationKey, isRequired, name, comment}} = field; const {hasLabel = true, innerRef, inputProps = {}, displayProps = {}, labelProps = {}, ...otherOptions} = options; const { inputProps: inputPropsD = {}, @@ -156,5 +156,5 @@ export function buildFieldProps(field: EntityField> | T): field is EntityField> { - return !!(field && (field as EntityField).$entity); + return !!(field && (field as EntityField).$field); } diff --git a/src/entity/view-model.ts b/src/entity/form-node.ts similarity index 50% rename from src/entity/view-model.ts rename to src/entity/form-node.ts index 7d48fc550..9286aa48d 100644 --- a/src/entity/view-model.ts +++ b/src/entity/form-node.ts @@ -2,52 +2,52 @@ import {action, autorun, isObservableArray, isObservableObject, observable, untr import {StoreNode, toFlatValues} from "./store"; -export interface ViewModel { +export interface FormNode { /** @internal */ - /** Précise l'état de la synchronisation entre le model et le viewModel. */ + /** Précise l'état de la synchronisation entre le StoreNode et le FormNode. */ isSubscribed: boolean; - /** Réinitialise le viewModel à partir du model. */ + /** Réinitialise le FormNode à partir du StoreNode. */ reset(): void; - /** Active la synchronisation model -> viewModel. La fonction est appelée à la création. */ + /** Active la synchronisation StoreNode -> FormNode. La fonction est appelée à la création. */ subscribe(): void; - /** Désactive la synchronisation model -> viewModel. */ + /** Désactive la synchronisation StoreNode -> FormNode. */ unsubscribe(): void; } /** - * Construit un ViewModel à partir d'une entrée d'entityStore. - * Le ViewModel est un clone d'un model qui peut être librement modifié sans l'impacter, et propose des méthodes pour se synchroniser. - * Toute mise à jour du model réinitialise le viewModel. + * Construit un FormNode à partir d'un StoreNode. + * Le FormNode est un clone d'un StoreNode qui peut être librement modifié sans l'impacter, et propose des méthodes pour se synchroniser. + * Toute mise à jour du StoreNode réinitialise le FormNode. */ -export function createViewModel(model: T) { - const viewModel = clone(model) as T & ViewModel; +export function makeFormNode(node: T) { + const formNode = clone(node) as T & FormNode; - // La fonction `reset` va simplement vider et reremplir le viewModel avec les valeurs du model. + // La fonction `reset` va simplement vider et reremplir le FormNode avec les valeurs du StoreNode. const reset = () => { - untracked(() => viewModel.clear()); - viewModel.set(toFlatValues(model as any)); + untracked(() => formNode.clear()); + formNode.set(toFlatValues(node as any)); }; - viewModel.reset = action(reset); - viewModel.subscribe = () => { - if (!viewModel.isSubscribed) { + formNode.reset = action(reset); + formNode.subscribe = () => { + if (!formNode.isSubscribed) { const disposer = autorun(reset); // On crée la réaction de synchronisation. - viewModel.unsubscribe = () => { + formNode.unsubscribe = () => { disposer(); - viewModel.isSubscribed = false; + formNode.isSubscribed = false; }; - viewModel.isSubscribed = true; + formNode.isSubscribed = true; } }; - viewModel.subscribe(); // On s'abonne par défaut, puisque c'est à priori le comportement souhaité la plupart du temps. - return viewModel; + formNode.subscribe(); // On s'abonne par défaut, puisque c'est à priori le comportement souhaité la plupart du temps. + return formNode; } -/** Version adaptée de `toJS` de MobX pour prendre en compte `$entity` et les fonctions `set` et `clear`. */ +/** Version adaptée de `toJS` de MobX pour prendre en compte `$entity` et les fonctions `set` et `pushNode` pour les arrays. */ function clone(source: any): any { if (isObservableArray(source)) { let res = []; @@ -73,7 +73,7 @@ function clone(source: any): any { } else if (isObservableObject(source)) { const res: any = {}; for (const key in source) { - if (key === "$entity") { + if (key === "$entity" || key === "$field") { res[key] = observable.ref((source as any)[key]); } else { res[key] = clone((source as any)[key]); diff --git a/src/entity/index.ts b/src/entity/index.ts index eebe7210f..1c52b4ac9 100644 --- a/src/entity/index.ts +++ b/src/entity/index.ts @@ -7,8 +7,8 @@ export { selectFor, stringFor } from "./field-helpers"; +export {makeFormNode, FormNode} from "./form-node"; export {formatNumber} from "./formatter"; export {buildEntityEntry, makeEntityStore, toFlatValues, StoreListNode, StoreNode, StoreType} from "./store"; export {Domain, Entity, FieldEntry, ObjectEntry, ListEntry, EntityField} from "./types"; export {validate} from "./validation"; -export {createViewModel, ViewModel} from "./view-model"; diff --git a/src/entity/readme.md b/src/entity/readme.md index 6d354fa42..dc51692c8 100644 --- a/src/entity/readme.md +++ b/src/entity/readme.md @@ -23,15 +23,15 @@ sera stocké dans un `EntityStore` sous la forme : { operation: { id: { - $entity: {type: "field", isRequired: true, domain: DO_ID, translationKey: "operation.id"}, + $field: {type: "field", isRequired: true, domain: DO_ID, translationKey: "operation.id"}, value: 1 }, number: { - $entity: {type: "field", isRequired: false, domain: DO_NUMBER, translationKey: "operation.number"}, + $field: {type: "field", isRequired: false, domain: DO_NUMBER, translationKey: "operation.number"}, value: "1.3" }, amount: { - $entity: {type: "field", isRequired: true, domain: DO_AMOUNT, translationKey: "operation.amount"}, + $field: {type: "field", isRequired: true, domain: DO_AMOUNT, translationKey: "operation.amount"}, value: 34.3 } } @@ -62,15 +62,16 @@ Et on retrouve le même fonctionnement d'avant. ### Description -Un `EntityStore` contient des *items* (`EntityStoreItem`) qui peuvent être soit un noeud (`EntityStoreNode`), soit une liste de noeuds (`StoreListNode`). Un `EntityStoreNode` contient des *valeurs* (`EntityValue`) qui peuvent être soit des *primitives*, soit un autre *item*. Chaque `EntityValue` se présente sous la forme `{$entity, value}` où `$entity` est la métadonnée associée à la valeur. Chaque *item* (objet ou liste), ainsi que le store lui-même, est également muni de deux méthodes `set(data)` et `clear()`, permettant respectivement de les remplir ou de les vider. +Un `EntityStore` contient des *items* (`EntityStoreItem`) qui peuvent être soit un noeud (`EntityStoreNode`), soit une liste de noeuds (`StoreListNode`). Un `EntityStoreNode` contient des *valeurs* (`EntityValue`) qui peuvent être soit des *primitives*, soit un autre *item*. Chaque `EntityValue` se présente sous la forme `{$field, value}` où `$field` est la métadonnée associée à la valeur. Chaque *item* (objet ou liste), ainsi que le store lui-même, est également muni de deux méthodes `set(data)` et `clear()`, permettant respectivement de les remplir ou de les vider. -Un `StoreNode`, qui est la partie commune à tous les *items* (objet ou liste) d'un store, est conçu pour être utilisé par les `fieldHelpers` (`fieldFor`, `selectFor`...) et par extension par l'`AutoForm`, qui sont des composants qui consomment des métadonnées. Le `fieldHelper` prend en entrée une `EntityValue` (`{$entity, value}`) qui vient à priori d'un `StoreNode`, mais peut également être construite à la main sur place. +Un `StoreNode`, qui est la partie commune à tous les *items* (objet ou liste) d'un store, est conçu pour être utilisé par les `fieldHelpers` (`fieldFor`, `selectFor`...) et par extension par l'`AutoForm`, qui sont des composants qui consomment des métadonnées. Le `fieldHelper` prend en entrée une `EntityValue` (`{$field, value}`) qui vient à priori d'un `StoreNode`, mais peut également être construite à la main sur place. La modification du store ou de l'une de ses entrées n'est pas limitée à l'usage des méthodes `set()` ou `clear()`. Etant toujours une observable MobX, il est tout à fait possible d'affecter des valeurs directement, comme `store.operation.id.value = undefined` par exemple. Ca peut être utile car **`set()` ne mettra à jour que les valeurs qu'il reçoit**. Pour un array, la méthode `set()` prend un array en paramètre qui remplacera toutes les valeurs courantes. -Un `EntityStore` peut contenir des objets avec autant de niveau de composition que l'on veut, que ça soit des objets dans des objets ou des dans arrays ou des arrays dans des objets... L'arbre des entités et propriétés est généré à la création du store pour les objets, et la méthode `set` de l'array (`StoreListNode`) va construire l'arbre de chaque entité dans l'array à l'insertion. A noter du coup que pour des listes, on ne gère que le remplacement de toutes les valeurs de la liste à chaque fois. Pour ajouter un seul élément (par exemple), il est nécessaire de reconstruire à la main le noeud lié à l'objet de l'array. Pour une primitive, ça serait simplement un `{$entity: Entity.fields.field, value: 2}` par exemple à ajouter. Pour un objet, le plus simple serait de reprendre un noeud issu d'une autre entrée de store pour l'ajouter (et éventuellement d'en faire un `deepClone`). Cette utilisation semble néanmoins marginale : la plupart du temps, on va récupérer la liste entière du serveur et la `set` directement dans le store. +Un `EntityStore` peut contenir des objets avec autant de niveau de composition que l'on veut, que ça soit des objets dans des objets ou des dans arrays ou des arrays dans des objets... L'arbre des entités et propriétés est généré à la création du store pour les objets, et la méthode `set` de l'array (`StoreListNode`) va construire l'arbre de chaque entité dans l'array à l'insertion. Pour les listes, la méthode `set` vide l'array et remplace tous les éléments par les nouveaux éléments fournis (en interne, elle appelle la méthode `replace` définie sur les arrays observables MobX). Pour ajouter un élément dans un array, la méthode `push` attend le `StoreNode` équivalent en entrée, ce qui n'est rarement pratique. Un `StoreListNode` possède donc une méthode supplémentaire `pushNode` qui permet d'ajouter un élement "brut" dans une liste et qui va se charger de créer sur `StoreNode` associé. -**Remarque importante** : Cela a été précisé à de nombreuses reprises dans la présentation mais l'accent jamais mis dessus : **`store.operation.id` n'est *pas* la valeur dans le store**, c'est **`store.operation.id.value`**. En particulier, quelque soit la valeur de `id`, **`store.operation.id` est toujours défini et vaut `{$entity, value}`**, même si `value` vaut `undefined`. + +**Remarque importante** : Cela a été précisé à de nombreuses reprises dans la présentation mais l'accent jamais mis dessus : **`store.operation.id` n'est *pas* la valeur dans le store**, c'est **`store.operation.id.value`**. En particulier, quelque soit la valeur de `id`, **`store.operation.id` est toujours défini et vaut `{$field, value}`**, même si `value` vaut `undefined`. ### API @@ -141,7 +142,7 @@ const store = makeEntityStore({ L'`AutoForm` est une classe dont un composant de formulaire doit hériter. C'est le remplacant du `formMixin` de la v2. C'est un "vestige" de Focus v2 qui fait le travail qu'on lui demande de façon très simple et les comportements qu'il apporte ne sont pas reproductibles simplement d'une autre manière. Focus v3 en est un excellent exemple. C'est la seule classe de base (ou seul équivalent "mixin") de la librairie (la v2 possède une 20taine de mixins et la v3 5 ou 6 connecteurs, à titre de comparaison), et son usage est très précis : **c'est pour faire un formulaire**, avec : -* un service de chargement +* un service de chargement (ou pas, si formulaire de création) * un service de sauvegarde * un état consulation/modification * un state interne initialisé par un store, miroir synchrone de l'état des champs qu'on l'on saisit @@ -157,10 +158,10 @@ La config d'un formulaire se fait intégralement dans la méthode `init()` dans * `serviceConfig`, qui est un objet contenant **les services** de `load` et de `save` (les actions n'existant plus en tant que telles, on saute l'étape), ainsi que la fonction `getLoadParams()` qui doit retourner les paramètres du `load`. Cette fonction sera appelée pendant `componentWillMount` puis une réaction MobX sera construite sur cette fonction : à chaque fois qu'une des observables utilisées dans la fonction est modifiée et que la valeur retournée à structurellement changée, le formulaire sera rechargé. Cela permet de synchroniser le formulaire sur une autre observable (en particulier un `ViewStore`) et de ne pas avoir à passer par une prop pour charger le formulaire. Rien n'empêche par contre de définir `getLoadParams` comme `() => [this.props.id]` et par conséquent de ne pas bénéficier de la réaction. C'est une moins bonne solution. * `options?`, un objet qui contient des options de configuration secondaires. -### this.entity et ViewModel -Le formulaire construit un `ViewModel` qu'il place dans la propriété `this.entity` à partir de `storeData`. C'est une copie conforme de `storeData` et fera office de state interne au formulaire (il est possible de passer son propre `ViewModel` dans les options du constructeur si on veut externaliser le state du formulaire). +### this.entity et FormNode +Le formulaire construit un `FormNode` qu'il place dans la propriété `this.entity` à partir de `storeData`. C'est une copie conforme de `storeData` et fera office de state interne au formulaire (il est possible de passer son propre `FormNode` dans les options du constructeur si on veut externaliser le state du formulaire). -Le ViewModel possède une méthode `reset` (qui s'ajoute en plus de tout ce que contient déjà `storeData` en tant que `StoreNode`) qui lui permet de se resynchroniser sur les valeurs de `storeData`. Une réaction MobX est enregistrée à la création qui va appeler cette fonction à chaque modification de store. En attendant, `this.entity` est librement modifiable et est synchronisé avec l'état des champs (via le `onChange` passé par le `this.fieldFor` si on l'utilise, sinon on peut le faire à la main). +Le FormNode possède une méthode `reset` (qui s'ajoute en plus de tout ce que contient déjà `storeData` en tant que `StoreNode`) qui lui permet de se resynchroniser sur les valeurs de `storeData`. Une réaction MobX est enregistrée à la création qui va appeler cette fonction à chaque modification de store. En attendant, `this.entity` est librement modifiable et est synchronisé avec l'état des champs (via le `onChange` passé par le `this.fieldFor` si on l'utilise, sinon on peut le faire à la main). L'appel de `load()` va appeler le service et mettre le résultat dans le store, qui via la réaction mettra à jour `this.entity`. @@ -168,7 +169,7 @@ L'appel de `save()` sur le formulaire va appeler le service avec la valeur coura L'appel de `cancel()` sur le formulaire appelle simplement `this.entity.reset()`. -Il est important de noter que puisque les valeurs de stores sont toutes stockées dans un objet `{$entity, value}`, copier cette objet puis modifier `value` va modifier la valeur initiale. C'est très pratique lorsque le contenu du store ne correspond pas à ce qu'on veut afficher, puisqu'il n'y a pas besoin de se soucier de mettre à jour le store lorsque l'on modifier sa transformée. `createViewModel` construit une copie profonde du store, ce qui veut dire que ceci ne s'applique pas de `this.entity` vers `storeData` (heureusement !). +Il est important de noter que puisque les valeurs de stores sont toutes stockées dans un objet `{$field, value}`, copier cette objet puis modifier `value` va modifier la valeur initiale. C'est très pratique lorsque le contenu du store ne correspond pas à ce qu'on veut afficher, puisqu'il n'y a pas besoin de se soucier de mettre à jour le store lorsque l'on modifier sa transformée. `createFormNode` construit une copie profonde du store, ce qui veut dire que ceci ne s'applique pas de `this.entity` vers `storeData` (heureusement !). ### Autres fonctionnalités * Chaque `Field` gère ses erreurs et expose un champ dérivé `error` qui contient le message d'erreur courant (ou `undefined` du coup s'il n'y en a pas), surchargeable par la prop `error` (passée au `Field` par `this.fieldFor` dans le cas d'une erreur serveur). Pour la validation du formulaire, on parcourt tous les champs (d'où la `ref` passée par `this.fieldFor`) et on regarde s'il y a des erreurs. diff --git a/src/entity/store.ts b/src/entity/store.ts index 6d8afa51a..0d8faf0a0 100644 --- a/src/entity/store.ts +++ b/src/entity/store.ts @@ -167,7 +167,7 @@ export function buildEntityEntry(config: EntityStor // On s'assure que les métadonnées du champ ne soient pas observables. return { - $entity: observable.ref(entityMap[trueEntry].fields[key]), + $field: observable.ref(entityMap[trueEntry].fields[key]), value: undefined }; }), diff --git a/src/entity/types.ts b/src/entity/types.ts index 5f3f29906..0aced9688 100644 --- a/src/entity/types.ts +++ b/src/entity/types.ts @@ -89,7 +89,7 @@ export interface Entity { export interface EntityField { /** Métadonnées. */ - readonly $entity: FieldEntry; + readonly $field: FieldEntry; /** Valeur. */ value: T | undefined; From 0012304d6511e998f46372f702279aecb66377ab Mon Sep 17 00:00:00 2001 From: Damien Frikha Date: Fri, 1 Dec 2017 16:41:16 +0100 Subject: [PATCH 02/89] Ajout `patchField` et `makeField` + refonte API Field pour forcer leurs usages. --- .../components/search/search-bar.tsx | 4 +- src/components/display.tsx | 2 +- src/entity/auto-form.tsx | 89 ++++----- src/entity/field-helpers.tsx | 169 +++++++++--------- src/entity/field-transforms.ts | 51 ++++++ src/entity/field.tsx | 103 +++++++---- src/entity/index.ts | 9 +- src/entity/types.ts | 8 +- 8 files changed, 254 insertions(+), 181 deletions(-) create mode 100644 src/entity/field-transforms.ts diff --git a/src/collections/components/search/search-bar.tsx b/src/collections/components/search/search-bar.tsx index 2c661e6f2..c0d566c16 100644 --- a/src/collections/components/search/search-bar.tsx +++ b/src/collections/components/search/search-bar.tsx @@ -10,7 +10,7 @@ import {Dropdown} from "react-toolbox/lib/dropdown"; import {FontIcon} from "react-toolbox/lib/font_icon"; import {getIcon} from "../../../components"; -import {fieldFor, StoreNode, toFlatValues} from "../../../entity"; +import {fieldFor, makeField, StoreNode, toFlatValues} from "../../../entity"; import {SearchStore} from "../../store"; @@ -224,7 +224,7 @@ export class SearchBar extends React.Component - {fieldFor(store.query, {label: `${i18nPrefix}.search.bar.query`, onChange: query => store.query = query})} + {fieldFor(makeField(store.query, {translationKey: `${i18nPrefix}.search.bar.query`}), {onChange: query => store.query = query})} {criteriaComponent}

- {children} - + + {theme => +
+
+ {canChangeMode ? + this.childContext.mode = "list"} + icon={getIcon(`${i18nPrefix}.icons.listWrapper.list`)} + tooltip={i18next.t(`${i18nPrefix}.list.mode.list`)} + /> + : null} + {canChangeMode ? + this.childContext.mode = "mosaic"} + icon={getIcon(`${i18nPrefix}.icons.listWrapper.mosaic`)} + tooltip={i18next.t(`${i18nPrefix}.list.mode.mosaic`)} + /> + : null} + {!hideAddItemHandler && addItemHandler && mode === "list" ? +
+ {children} +
+ } +
); } } - -export default themr("listWrapper", styles)(ListWrapper); diff --git a/src/collections/components/list/list.tsx b/src/collections/components/list/list.tsx index fcaca57b0..a8a0b46ef 100644 --- a/src/collections/components/list/list.tsx +++ b/src/collections/components/list/list.tsx @@ -3,24 +3,24 @@ import {action, computed, observable} from "mobx"; import {observer} from "mobx-react"; import * as PropTypes from "prop-types"; import * as React from "react"; -import {themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; import {spring, Style, TransitionMotion} from "react-motion"; import {IconButton} from "react-toolbox/lib/button"; import {FontIcon} from "react-toolbox/lib/font_icon"; import {getIcon} from "../../../components"; -import {ReactComponent} from "../../../config"; +import {themr} from "../../../theme"; import {classAutorun, classReaction} from "../../../util"; import {ListStoreBase} from "../../store"; import {OperationListItem} from "./contextual-actions"; import {addDragSource} from "./dnd-utils"; -import DndDragLayer, {DragLayerStyle} from "./drag-layer"; -import LineWrapper, {LineProps, LineWrapperProps} from "./line"; +import {DndDragLayer, DragLayerStyle} from "./drag-layer"; +import {LineProps, LineWrapper, LineWrapperProps} from "./line"; import {ListBase, ListBaseProps} from "./list-base"; import * as styles from "./__style__/list.css"; +const Theme = themr("list", styles); /** Props de base d'un composant de détail. */ export interface DetailProps { @@ -45,7 +45,7 @@ export interface ListProps extends ListBaseProps { /** Précise si chaque élément peut ouvrir le détail ou non. Par défaut () => true. */ canOpenDetail?: (data: T) => boolean; /** Composant de détail, à afficher dans un "accordéon" au clic sur un objet. */ - DetailComponent?: ReactComponent>; + DetailComponent?: React.ComponentType>; /** Hauteur du composant de détail. Par défaut : 200. */ detailHeight?: number | ((data: T) => number); /** Nombre d'éléments à partir du quel on n'affiche plus d'animation de drag and drop sur les lignes. */ @@ -55,19 +55,19 @@ export interface ListProps extends ListBaseProps { /** CSS du DragLayer. */ dragLayerTheme?: DragLayerStyle; /** Component à afficher lorsque la liste est vide. */ - EmptyComponent?: ReactComponent>; + EmptyComponent?: React.ComponentType>; /** Active le drag and drop. */ hasDragAndDrop?: boolean; /** Cache le bouton "Ajouter" dans la mosaïque et le composant vide. */ hideAdditionalItems?: boolean; /** Composant de ligne. */ - LineComponent?: ReactComponent>; + LineComponent?: React.ComponentType>; /** Mode des listes dans le wrapper. Par défaut : celui du composant fourni, ou "list". */ mode?: "list" | "mosaic"; /** Taille de la mosaïque. */ mosaic?: {width: number, height: number}; /** Composant de mosaïque. */ - MosaicComponent?: ReactComponent>; + MosaicComponent?: React.ComponentType>; /** La liste des actions sur chaque élément de la liste. */ operationList?: (data: T) => OperationListItem[]; } @@ -78,7 +78,7 @@ export interface LineItem

{ key: string; /** Description du composant, avec ses props. */ data?: { - Component: ReactComponent

, + Component: React.ComponentType

, props?: P }; /** Style interpolé (ou pas) par react-motion. */ @@ -194,7 +194,7 @@ export class List = ListProps & {data: T[]}> extend * Transforme les données en éléments de liste. * @param Component Le composant de ligne. */ - protected getItems(Component: ReactComponent>): LineItem>[] { + protected getItems(Component: React.ComponentType>): LineItem>[] { const {canOpenDetail = () => true, i18nPrefix, itemKey, lineTheme, operationList, hasDragAndDrop} = this.props; return this.displayedData.map((item, idx) => ({ @@ -231,7 +231,7 @@ export class List = ListProps & {data: T[]}> extend /** Construit les lignes de la liste à partir des données, en tenant compte du mode, de l'affichage du détail et du bouton d'ajout. */ @computed private get lines() { - const {theme, i18nPrefix = "focus", LineComponent, MosaicComponent, DetailComponent, detailHeight = 200} = this.props; + const {i18nPrefix = "focus", LineComponent, MosaicComponent, DetailComponent, detailHeight = 200} = this.props; /* On détermine quel composant on utilise. */ let Component; @@ -262,20 +262,24 @@ export class List = ListProps & {data: T[]}> extend key: `detail-${idx}`, data: { Component: ({style: {height}}: {style: {height: number}}) => ( -

  • - {/* Le calcul de la position du triangle en mosaïque n'est pas forcément évident... et il suppose qu'on ne touche pas au marges par défaut entre les mosaïques. */} -
    -
    - - -
    -
  • + + {theme => +
  • + {/* Le calcul de la position du triangle en mosaïque n'est pas forcément évident... et il suppose qu'on ne touche pas au marges par défaut entre les mosaïques. */} +
    +
    + + +
    +
  • + } +
    ) }, style: {height: spring((typeof detailHeight === "number" ? detailHeight : detailHeight(item)) + 40)} // On indique l'animation d'ouverture. Le +40 permet de prendre en compte les marges de 20px en haut et en bas. @@ -288,14 +292,18 @@ export class List = ListProps & {data: T[]}> extend key: "mosaic-add", data: { Component: () => ( -
    - {getIcon(`${i18nPrefix}.icons.list.add`)} - {i18next.t(`${i18nPrefix}.list.add`)} -
    + + {theme => +
    + {getIcon(`${i18nPrefix}.icons.list.add`)} + {i18next.t(`${i18nPrefix}.list.add`)} +
    + } +
    ) }, style: {} @@ -306,47 +314,48 @@ export class List = ListProps & {data: T[]}> extend } render() { - const {dragLayerTheme, EmptyComponent, hasDragAndDrop, hideAdditionalItems, i18nPrefix = "focus", theme} = this.props; + const {dragLayerTheme, EmptyComponent, hasDragAndDrop, hideAdditionalItems, i18nPrefix = "focus"} = this.props; return !hideAdditionalItems && !this.displayedData.length && EmptyComponent ? : !hideAdditionalItems && !this.displayedData.length ?
    {i18next.t(`${i18nPrefix}.list.empty`)}
    : ( -
    - {!navigator.userAgent.match(/Trident/) && hasDragAndDrop ? : null} -
    - ({height: 0, opacity: 1})} - willLeave={({style}: {style: Style}) => { - // Est appelé au retrait d'un élément de la liste. - if (style.height) { // `height` n'existe que pour le détail - return {height: spring(0)}; // On ajoute l'animation de fermeture. - } - return undefined; // Pour les autres éléments, on les retire immédiatement. - }} - styles={this.lines.slice()} - > - {(items: LineItem[]) => ( -
      - {items.map(({key, style, data: {Component = {} as any, props = {}} = {}}) => )} -
    - )} -
    - {this.renderBottomRow()} -
    -
    + + {theme => + <> + {!navigator.userAgent.match(/Trident/) && hasDragAndDrop ? : null} +
    + ({height: 0, opacity: 1})} + willLeave={({style}: {style: Style}) => { + // Est appelé au retrait d'un élément de la liste. + if (style.height) { // `height` n'existe que pour le détail + return {height: spring(0)}; // On ajoute l'animation de fermeture. + } + return undefined; // Pour les autres éléments, on les retire immédiatement. + }} + styles={this.lines.slice()} + > + {(items: LineItem[]) => ( +
      + {items.map(({key, style, data: {Component = {} as any, props = {}} = {}}) => )} +
    + )} +
    + {this.renderBottomRow(theme)} +
    + + } +
    ); } } -const ThemedList = themr("list", styles)(List); -export default ThemedList; - /** * Crée un composant de liste standard. * @param props Les props de la liste. */ export function listFor(props: ListProps & {data: T[]}) { - const List2 = ThemedList as any; + const List2 = List as any; return ; } diff --git a/src/collections/components/list/store-list.tsx b/src/collections/components/list/store-list.tsx index e606bf7ca..97b718449 100644 --- a/src/collections/components/list/store-list.tsx +++ b/src/collections/components/list/store-list.tsx @@ -2,16 +2,11 @@ import i18next from "i18next"; import {action, computed} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; - -import {ReactComponent} from "../../../config"; import {isSearch, ListStoreBase} from "../../store"; import {LineProps, LineWrapperProps} from "./line"; import {LineItem, List, ListProps} from "./list"; -import * as styles from "./__style__/list.css"; - /** Props additionnelles pour un StoreList. */ export interface StoreListProps extends ListProps { /** Code du groupe à afficher, pour une recherche groupée. */ @@ -72,7 +67,7 @@ export class StoreList extends List> { * Quelques props supplémentaires à ajouter pour la sélection. * @param Component Le composant de ligne. */ - protected getItems(Component: ReactComponent>) { + protected getItems(Component: React.ComponentType>) { const {hasSelection = false, store} = this.props; return super.getItems(Component) .map(({key, data, style}) => ({ @@ -104,13 +99,10 @@ export class StoreList extends List> { } } -const ThemedStoreList = themr("list", styles)(StoreList); -export default ThemedStoreList; - /** * Crée un composant de liste avec store. * @param props Les props de la liste. */ export function storeListFor(props: ListProps & StoreListProps) { - return ; + return ; } diff --git a/src/collections/components/list/store-table.tsx b/src/collections/components/list/store-table.tsx index e3e92ae34..78f894520 100644 --- a/src/collections/components/list/store-table.tsx +++ b/src/collections/components/list/store-table.tsx @@ -2,7 +2,6 @@ import i18next from "i18next"; import {action, computed} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {IconButton} from "react-toolbox/lib/button"; import {getIcon} from "../../../components"; @@ -10,8 +9,6 @@ import {getIcon} from "../../../components"; import {isSearch, ListStoreBase} from "../../store"; import {Table, TableProps} from "./table"; -import * as styles from "./__style__/list.css"; - /** Props additionnelles pour un StoreTable. */ export interface StoreTableProps extends TableProps { /** Code du groupe à afficher, pour une recherche groupée. */ @@ -124,13 +121,10 @@ export class StoreTable extends Table> { } } -const ThemedStoreTable = themr("list", styles)(StoreTable); -export default ThemedStoreTable; - /** * Crée un composant de tableau avec store. * @param props Les props du tableau. */ export function storeTableFor(props: TableProps & StoreTableProps) { - return ; + return ; } diff --git a/src/collections/components/list/table.tsx b/src/collections/components/list/table.tsx index 1c247e5e7..0e9acd939 100644 --- a/src/collections/components/list/table.tsx +++ b/src/collections/components/list/table.tsx @@ -2,21 +2,21 @@ import i18next from "i18next"; import {values} from "lodash"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; -import {ReactComponent} from "../../../config"; +import {themr} from "../../../theme"; import {LineProps, LineWrapper} from "./line"; import {ListBase, ListBaseProps} from "./list-base"; import * as styles from "./__style__/list.css"; +const Theme = themr("list", styles); /** Props du tableau de base. */ export interface TableProps extends ListBaseProps { /** La description des colonnes du tableau avec leur libellés. */ columns: {[field: string]: string}; /** Le composant de ligne. */ - RowComponent: ReactComponent>; + RowComponent: React.ComponentType>; } /** Tableau standard */ @@ -65,25 +65,26 @@ export class Table = TableProps & {data: T[]}> ext render() { return ( -
    - - {this.renderTableHeader()} - {this.renderTableBody()} -
    - {this.renderBottomRow()} -
    + + {theme => + <> + + {this.renderTableHeader()} + {this.renderTableBody()} +
    + {this.renderBottomRow(theme)} + + } +
    ); } } -const ThemedTable = themr("list", styles)(Table); -export default ThemedTable; - /** * Crée un composant de tableau standard. * @param props Les props du tableau. */ export function tableFor(props: TableProps & {data: T[]}) { - const Table2 = ThemedTable as any; + const Table2 = Table as any; return ; } diff --git a/src/collections/components/list/timeline.tsx b/src/collections/components/list/timeline.tsx index e2a4c1913..ce28fad4e 100644 --- a/src/collections/components/list/timeline.tsx +++ b/src/collections/components/list/timeline.tsx @@ -1,14 +1,14 @@ import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; -import {ReactComponent} from "../../../config"; import {EntityField, FieldEntry} from "../../../entity"; +import {themr} from "../../../theme"; -import LineWrapper, {LineProps} from "./line"; +import {LineProps, LineWrapper} from "./line"; import {ListBase, ListBaseProps} from "./list-base"; import * as styles from "./__style__/list.css"; +const Theme = themr("list", styles); /** Props du composant de TimeLine. */ export interface TimelineProps extends ListBaseProps { @@ -17,7 +17,7 @@ export interface TimelineProps extends ListBaseProps { /** Le sélecteur du champ contenant la date. */ dateSelector: (data: T) => EntityField>; /** Le composant de ligne. */ - TimelineComponent: ReactComponent>; + TimelineComponent: React.ComponentType>; } /** Composant affichant une liste sous forme de Timeline. */ @@ -44,21 +44,22 @@ export class Timeline extends ListBase> { render() { return ( -
      - {this.renderLines()} - {this.renderBottomRow()} -
    + + {theme => +
      + {this.renderLines()} + {this.renderBottomRow(theme)} +
    + } +
    ); } } -const ThemedTimeline = themr("list", styles)(Timeline); -export default ThemedTimeline; - /** * Crée un composant affichant une liste sous forme de Timeline. * @param props Les props de la timeline. */ export function timelineFor(props: TimelineProps) { - return ; + return ; } diff --git a/src/collections/components/search/advanced-search.tsx b/src/collections/components/search/advanced-search.tsx index d1248fa25..017e89cba 100644 --- a/src/collections/components/search/advanced-search.tsx +++ b/src/collections/components/search/advanced-search.tsx @@ -1,19 +1,18 @@ import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {ButtonBackToTop} from "../../../components"; -import {ReactComponent} from "../../../config"; +import {themr} from "../../../theme"; import {GroupResult, SearchStore} from "../../store"; import {ActionBar, ActionBarStyle, DetailProps, DragLayerStyle, EmptyProps, LineProps, LineStyle, ListStyle, ListWrapper, OperationListItem} from "../list"; -import FacetBox, {FacetBoxStyle} from "./facet-box"; -import Results, {GroupStyle} from "./results"; -import Summary, {SummaryStyle} from "./summary"; +import {FacetBox, FacetBoxStyle} from "./facet-box"; +import {GroupStyle, Results} from "./results"; +import {Summary, SummaryStyle} from "./summary"; import * as styles from "./__style__/advanced-search.css"; - export type AdvancedSearchStyle = Partial; +const Theme = themr("advancedSearch", styles); /** Props de l'AdvancedSearch. */ export interface AdvancedSearchProps { @@ -26,7 +25,7 @@ export interface AdvancedSearchProps { /** Permet de supprimer le tri. Par défaut : true */ canRemoveSort?: boolean; /** Composant de détail, à afficher dans un "accordéon" au clic sur un objet. */ - DetailComponent?: ReactComponent>; + DetailComponent?: React.ComponentType>; /** Hauteur du composant de détail. Par défaut : 200. */ detailHeight?: number | ((data: T) => number); /** Nombre d'éléments à partir du quel on n'affiche plus d'animation de drag and drop sur les lignes. */ @@ -36,13 +35,13 @@ export interface AdvancedSearchProps { /** CSS du DragLayer. */ dragLayerTheme?: DragLayerStyle; /** Component à afficher lorsque la liste est vide. */ - EmptyComponent?: ReactComponent>; + EmptyComponent?: React.ComponentType>; /** Emplacement de la FacetBox. Par défaut : "left" */ facetBoxPosition?: "action-bar" | "left" | "none"; /** CSS de la FacetBox (si position = "left") */ facetBoxTheme?: FacetBoxStyle; /** Header de groupe personnalisé. */ - GroupHeader?: ReactComponent<{group: GroupResult}>; + GroupHeader?: React.ComponentType<{group: GroupResult}>; /** Actions de groupe par scope. */ groupOperationList?: (group: GroupResult) => OperationListItem[]; /** Nombre d'éléments affichés par page de groupe. Par défaut : 5. */ @@ -74,7 +73,7 @@ export interface AdvancedSearchProps { /** Chargement manuel (à la place du scroll infini). */ isManualFetch?: boolean; /** Composant de ligne. */ - LineComponent?: ReactComponent>; + LineComponent?: React.ComponentType>; /** La liste des actions sur chaque élément de la liste. */ lineOperationList?: (data: T) => OperationListItem[]; /** CSS des lignes. */ @@ -86,7 +85,7 @@ export interface AdvancedSearchProps { /** Mode des listes dans le wrapper. Par défaut : "list". */ mode?: "list" | "mosaic"; /** Composants de mosaïque. */ - MosaicComponent?: ReactComponent>; + MosaicComponent?: React.ComponentType>; /** Largeur des mosaïques. Par défaut : 200. */ mosaicWidth?: number; /** Hauteur des mosaïques. Par défaut : 200. */ @@ -126,12 +125,12 @@ export class AdvancedSearch extends React.Component> { } } - protected renderFacetBox() { - const {theme, facetBoxPosition = "left", facetBoxTheme, i18nPrefix, nbDefaultDataListFacet, showSingleValuedFacets, store} = this.props; + protected renderFacetBox(theme: AdvancedSearchStyle) { + const {facetBoxPosition = "left", facetBoxTheme, i18nPrefix, nbDefaultDataListFacet, showSingleValuedFacets, store} = this.props; if (facetBoxPosition === "left") { return ( -
    +
    extends React.Component> { } render() { - const {addItemHandler, i18nPrefix, LineComponent, MosaicComponent, mode, mosaicHeight, mosaicWidth, hasBackToTop = true, theme} = this.props; + const {addItemHandler, i18nPrefix, LineComponent, MosaicComponent, mode, mosaicHeight, mosaicWidth, hasBackToTop = true} = this.props; return ( -
    - {this.renderFacetBox()} -
    - - {this.renderListSummary()} - {this.renderActionBar()} - {this.renderResults()} - -
    - {hasBackToTop ? : null} -
    + + {theme => + <> + {this.renderFacetBox(theme)} +
    + + {this.renderListSummary()} + {this.renderActionBar()} + {this.renderResults()} + +
    + {hasBackToTop ? : null} + + } +
    ); } } -const ThemedAdvancedSearch = themr("advancedSearch", styles)(AdvancedSearch); -export default ThemedAdvancedSearch; - /** * Crée un composant de recherche avancée. * @param props Les props de l'AdvancedSearch. */ export function advancedSearchFor(props: AdvancedSearchProps) { - return ; + return ; } diff --git a/src/collections/components/search/facet-box/facet.tsx b/src/collections/components/search/facet-box/facet.tsx index 822d57b44..4f0e5b94e 100644 --- a/src/collections/components/search/facet-box/facet.tsx +++ b/src/collections/components/search/facet-box/facet.tsx @@ -2,17 +2,17 @@ import i18next from "i18next"; import {observable} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {Chip} from "react-toolbox/lib/chip"; import {Checkbox} from "../../../../components"; +import {themr} from "../../../../theme"; import {FacetOutput, SearchStore} from "../../../store"; import {addFacetValue, removeFacetValue} from "./utils"; import * as styles from "./__style__/facet.css"; - export type FacetStyle = Partial; +const Theme = themr("facet", styles); /** Props de Facet. */ export interface FacetProps { @@ -34,8 +34,8 @@ export class Facet extends React.Component { @observable protected isShowAll = false; - protected renderFacetDataList() { - const {theme, facet, nbDefaultDataList, store} = this.props; + protected renderFacetDataList(theme: FacetStyle) { + const {facet, nbDefaultDataList, store} = this.props; const selectedValues = store.selectedFacets[facet.code] || []; if (!facet.isMultiSelectable && selectedValues.length === 1) { @@ -45,7 +45,7 @@ export class Facet extends React.Component { key={sfv.code} deletable onClick={() => removeFacetValue(store, facet.code, sfv.code)} - theme={{chip: theme!.chip}} + theme={{chip: theme.chip}} > {i18next.t(sfv.label)} @@ -64,7 +64,7 @@ export class Facet extends React.Component {
  • {facet.isMultiSelectable ? : null}
    {i18next.t(sfv.label)}
    -
    {sfv.count}
    +
    {sfv.count}
  • ); })} @@ -73,11 +73,11 @@ export class Facet extends React.Component { } } - protected renderShowAllDataList() { - const {theme, facet, i18nPrefix = "focus", nbDefaultDataList} = this.props; + protected renderShowAllDataList(theme: FacetStyle) { + const {facet, i18nPrefix = "focus", nbDefaultDataList} = this.props; if (facet.values.length > nbDefaultDataList) { return ( -
    this.isShowAll = !this.isShowAll}> +
    this.isShowAll = !this.isShowAll}> {i18next.t(this.isShowAll ? `${i18nPrefix}.list.show.less` : `${i18nPrefix}.list.show.all`)}
    ); @@ -87,15 +87,17 @@ export class Facet extends React.Component { } render() { - const {theme, facet} = this.props; + const {facet} = this.props; return ( -
    -

    {i18next.t(facet.label)}

    - {this.renderFacetDataList()} - {this.renderShowAllDataList()} -
    + + {theme => +
    +

    {i18next.t(facet.label)}

    + {this.renderFacetDataList(theme)} + {this.renderShowAllDataList(theme)} +
    + } +
    ); } } - -export default themr("facet", styles)(Facet); diff --git a/src/collections/components/search/facet-box/index.tsx b/src/collections/components/search/facet-box/index.tsx index ae23b5cc5..485f1c256 100644 --- a/src/collections/components/search/facet-box/index.tsx +++ b/src/collections/components/search/facet-box/index.tsx @@ -1,16 +1,17 @@ import i18next from "i18next"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; + +import {themr} from "../../../../theme"; import {SearchStore} from "../../../store"; -import Facet, {FacetStyle} from "./facet"; +import {Facet, FacetStyle} from "./facet"; import {addFacetValue, removeFacetValue, shouldDisplayFacet} from "./utils"; export {addFacetValue, removeFacetValue, shouldDisplayFacet, FacetStyle}; import * as styles from "./__style__/facet-box.css"; - export type FacetBoxStyle = Partial; +const Theme = themr("facetBox", styles); /** Props de la FacetBox. */ export interface FacetBoxProps { @@ -31,30 +32,32 @@ export interface FacetBoxProps { export class FacetBox extends React.Component> { render() { - const {theme, i18nPrefix = "focus", nbDefaultDataList = 6, showSingleValuedFacets, store} = this.props; + const {i18nPrefix = "focus", nbDefaultDataList = 6, showSingleValuedFacets, store} = this.props; return ( -
    -

    {i18next.t(`${i18nPrefix}.search.facets.title`)}

    - {store.facets.filter(facet => shouldDisplayFacet(facet, store.selectedFacets, showSingleValuedFacets)) - .map(facet => { - if (store.selectedFacets[facet.code] || Object.keys(facet).length > 1) { - return ( - - ); - } else { - return null; + + {theme => +
    +

    {i18next.t(`${i18nPrefix}.search.facets.title`)}

    + {store.facets.filter(facet => shouldDisplayFacet(facet, store.selectedFacets, showSingleValuedFacets)) + .map(facet => { + if (store.selectedFacets[facet.code] || Object.keys(facet).length > 1) { + return ( + + ); + } else { + return null; + } + }) } - }) +
    } -
    + ); } } - -export default themr("facetBox", styles)(FacetBox); diff --git a/src/collections/components/search/index.ts b/src/collections/components/search/index.ts index 05b7c93db..4b454617a 100644 --- a/src/collections/components/search/index.ts +++ b/src/collections/components/search/index.ts @@ -1,5 +1,5 @@ -export {default as AdvancedSearch, advancedSearchFor, AdvancedSearchStyle} from "./advanced-search"; -export {default as FacetBox, FacetBoxStyle, FacetStyle, shouldDisplayFacet} from "./facet-box"; -export {default as Results, resultsFor, GroupStyle} from "./results"; -export {default as SearchBar, SearchBarStyle} from "./search-bar"; -export {default as Summary, SummaryStyle} from "./summary"; +export {AdvancedSearch, advancedSearchFor, AdvancedSearchStyle} from "./advanced-search"; +export {FacetBox, FacetBoxStyle, FacetStyle, shouldDisplayFacet} from "./facet-box"; +export {Results, resultsFor, GroupStyle} from "./results"; +export {SearchBar, SearchBarStyle} from "./search-bar"; +export {Summary, SummaryStyle} from "./summary"; diff --git a/src/collections/components/search/results/group.tsx b/src/collections/components/search/results/group.tsx index bf4244533..5063d65c5 100644 --- a/src/collections/components/search/results/group.tsx +++ b/src/collections/components/search/results/group.tsx @@ -5,24 +5,24 @@ import i18next from "i18next"; import {action, computed} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {IconButton} from "react-toolbox/lib/button"; import {getIcon} from "../../../../components"; -import {ReactComponent} from "../../../../config"; +import {themr} from "../../../../theme"; + import {GroupResult, ListStoreBase, SearchStore} from "../../../store"; import {ActionBar, DetailProps, DragLayerStyle, EmptyProps, LineProps, LineStyle, ListStyle, OperationListItem, StoreList} from "../../list"; import * as styles from "./__style__/group.css"; - export type GroupStyle = Partial; +const Theme = themr("group", styles); /** Props du composant de groupe. */ export interface GroupProps { /** Précise si chaque élément peut ouvrir le détail ou non. Par défaut () => true. */ canOpenDetail?: (data: T) => boolean; /** Composant de détail, à afficher dans un "accordéon" au clic sur un objet. */ - DetailComponent?: ReactComponent>; + DetailComponent?: React.ComponentType>; /** Hauteur du composant de détail. Par défaut : 200. */ detailHeight?: number | ((data: T) => number); /** Nombre d'éléments à partir du quel on n'affiche plus d'animation de drag and drop sur les lignes. */ @@ -32,11 +32,11 @@ export interface GroupProps { /** CSS du DragLayer. */ dragLayerTheme?: DragLayerStyle; /** Component à afficher lorsque la liste est vide. */ - EmptyComponent?: ReactComponent>; + EmptyComponent?: React.ComponentType>; /** Constituion du groupe à afficher. */ group: GroupResult; /** Header de groupe personnalisé. */ - GroupHeader?: ReactComponent<{group: GroupResult}>; + GroupHeader?: React.ComponentType<{group: GroupResult}>; /** Actions de groupe. */ groupOperationList?: OperationListItem[]; /** Active le drag and drop. */ @@ -48,7 +48,7 @@ export interface GroupProps { /** Champ de l'objet à utiliser pour la key des lignes. */ itemKey?: keyof T; /** Composant de ligne. */ - LineComponent?: ReactComponent>; + LineComponent?: React.ComponentType>; /** La liste des actions sur chaque élément de la liste. */ lineOperationList?: (data: T) => OperationListItem[]; /** CSS des lignes. */ @@ -56,7 +56,7 @@ export interface GroupProps { /** CSS de la liste. */ listTheme?: ListStyle; /** Composant de mosaïque. */ - MosaicComponent?: ReactComponent>; + MosaicComponent?: React.ComponentType>; /** Nombre d'éléments par page. Par défaut : 5. */ perPage?: number; /** Store contenant la liste. */ @@ -92,54 +92,58 @@ export class Group extends React.Component> { } render() { - const {canOpenDetail, DetailComponent, detailHeight, disableDragAnimThreshold, dragItemType, dragLayerTheme, EmptyComponent, group, GroupHeader = DefaultGroupHeader, groupOperationList, hasDragAndDrop, hasSelection, i18nPrefix = "focus", itemKey, LineComponent, lineOperationList, lineTheme, listTheme, MosaicComponent, perPage = 5, store, theme, useGroupActionBars} = this.props; + const {canOpenDetail, DetailComponent, detailHeight, disableDragAnimThreshold, dragItemType, dragLayerTheme, EmptyComponent, group, GroupHeader = DefaultGroupHeader, groupOperationList, hasDragAndDrop, hasSelection, i18nPrefix = "focus", itemKey, LineComponent, lineOperationList, lineTheme, listTheme, MosaicComponent, perPage = 5, store, useGroupActionBars} = this.props; return ( -
    - {useGroupActionBars ? - - : -
    - {hasSelection ? - + {theme => +
    + {useGroupActionBars ? + - : null} - + : +
    + {hasSelection ? + + : null} + +
    + } + +
    } - - -
    + ); } } @@ -157,13 +161,10 @@ export function DefaultGroupHeader({group}: {group: GroupResult}) { return {`${i18next.t(group.label)} (${group.totalCount})`}; } -const ThemedGroup = themr("group", styles)(Group); -export default ThemedGroup; - /** * Crée un composant de groupe. * @param props Les props du groupe. */ export function groupFor(props: GroupProps) { - return ; + return ; } diff --git a/src/collections/components/search/results/index.tsx b/src/collections/components/search/results/index.tsx index b3e9b759a..72f276f64 100644 --- a/src/collections/components/search/results/index.tsx +++ b/src/collections/components/search/results/index.tsx @@ -2,11 +2,10 @@ import {computed} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {ReactComponent} from "../../../../config"; import {DetailProps, DragLayerStyle, EmptyProps, LineProps, LineStyle, ListStyle, OperationListItem, StoreList} from "../../list"; import {GroupResult, SearchStore} from "../../../store"; -import Group, {GroupLoadingBar, GroupStyle} from "./group"; +import {Group, GroupLoadingBar, GroupStyle} from "./group"; export {GroupStyle}; /** Props de Results. */ @@ -14,7 +13,7 @@ export interface ResultsProps { /** Précise si chaque élément peut ouvrir le détail ou non. Par défaut () => true. */ canOpenDetail?: (data: T) => boolean; /** Composant de détail, à afficher dans un "accordéon" au clic sur un objet. */ - DetailComponent?: ReactComponent>; + DetailComponent?: React.ComponentType>; /** Hauteur du composant de détail. Par défaut : 200. */ detailHeight?: number | ((data: T) => number); /** Nombre d'éléments à partir du quel on n'affiche plus d'animation de drag and drop sur les lignes. */ @@ -24,9 +23,9 @@ export interface ResultsProps { /** CSS du DragLayer. */ dragLayerTheme?: DragLayerStyle; /** Component à afficher lorsque la liste est vide. */ - EmptyComponent?: ReactComponent>; + EmptyComponent?: React.ComponentType>; /** Header de groupe personnalisé. */ - GroupHeader?: ReactComponent<{group: GroupResult}>; + GroupHeader?: React.ComponentType<{group: GroupResult}>; /** Actions de groupe par groupe (code / valeur). */ groupOperationList?: (group: GroupResult) => OperationListItem[]; /** Nombre d'éléments affichés par page de groupe. Par défaut : 5. */ @@ -44,7 +43,7 @@ export interface ResultsProps { /** Champ de l'objet à utiliser pour la key des lignes. */ itemKey?: keyof T; /** Composant de ligne. */ - LineComponent?: ReactComponent>; + LineComponent?: React.ComponentType>; /** La liste des actions sur chaque élément de la liste. */ lineOperationList?: (data: T) => OperationListItem[]; /** CSS des lignes. */ @@ -54,7 +53,7 @@ export interface ResultsProps { /** CSS de la liste. */ listTheme?: ListStyle; /** Composant de mosaïque. */ - MosaicComponent?: ReactComponent>; + MosaicComponent?: React.ComponentType>; /** Offset pour le scroll infini. Par défaut : 250 */ offset?: number; /** Store de recherche. */ @@ -131,8 +130,6 @@ export class Results extends React.Component> { } } -export default Results; - /** * Crée un composant de Results. * @param props Les props du Results. diff --git a/src/collections/components/search/search-bar.tsx b/src/collections/components/search/search-bar.tsx index 5d3b29bf4..570205a52 100644 --- a/src/collections/components/search/search-bar.tsx +++ b/src/collections/components/search/search-bar.tsx @@ -3,19 +3,19 @@ import {difference, toPairs} from "lodash"; import {action, computed, observable} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {Button, IconButton} from "react-toolbox/lib/button"; import {Dropdown} from "react-toolbox/lib/dropdown"; import {FontIcon} from "react-toolbox/lib/font_icon"; import {getIcon} from "../../../components"; import {Entity, fieldFor, FormEntityField, makeField, toFlatValues} from "../../../entity"; +import {themr} from "../../../theme"; import {SearchStore} from "../../store"; import * as styles from "./__style__/search-bar.css"; - export type SearchBarStyle = Partial; +const Theme = themr("searchBar", styles); /** Props de la SearchBar. */ export interface SearchBarProps { @@ -187,53 +187,55 @@ export class SearchBar extends React.Component - {this.showCriteriaComponent ?
    : null} -
    - {scopes && store.criteria && scopeKey ? - ({value: code, label}))]} - theme={{dropdown: theme!.dropdown, values: theme!.scopes, valueKey: ""}} - /> - : null} -
    - {getIcon(`${i18nPrefix}.icons.searchBar.search`)} - this.input = input} - value={this.text} - /> -
    - {this.text && !this.showCriteriaComponent ? : null} - {store.criteria && criteriaComponent && !this.showCriteriaComponent ? - - : null} -
    - {!this.showCriteriaComponent && this.error ? - - {this.error} - - : null} - {this.showCriteriaComponent ? -
    - - {fieldFor(makeField(store.query, {label: `${i18nPrefix}.search.bar.query`}), {onChange: query => store.query = query})} - {criteriaComponent} -
    -
    +
    + : null}
    - : null} -
    + } + ); } } - -export default themr("searchBar", styles)(SearchBar); diff --git a/src/collections/components/search/summary.tsx b/src/collections/components/search/summary.tsx index 24567f950..eb5950e38 100644 --- a/src/collections/components/search/summary.tsx +++ b/src/collections/components/search/summary.tsx @@ -2,20 +2,20 @@ import i18next from "i18next"; import {computed} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {Button} from "react-toolbox/lib/button"; import {Chip} from "react-toolbox/lib/chip"; import {getIcon} from "../../../components"; import {FormEntityField} from "../../../entity"; +import {themr} from "../../../theme"; import {SearchStore} from "../../store"; import {removeFacetValue} from "./facet-box"; import * as styles from "./__style__/summary.css"; - export type SummaryStyle = Partial; +const Theme = themr("summary", styles); /** Props du ListSummary. */ export interface ListSummaryProps { @@ -98,72 +98,73 @@ export class Summary extends React.Component> { } render() { - const {canRemoveSort = true, theme, exportAction, hideGroup, hideSort, i18nPrefix = "focus", store} = this.props; + const {canRemoveSort = true, exportAction, hideGroup, hideSort, i18nPrefix = "focus", store} = this.props; const {groupingKey, totalCount, query} = store; const plural = totalCount !== 1 ? "s" : ""; - const sentence = theme!.sentence; return ( -
    - - {/* Nombre de résultats. */} - - {totalCount} {i18next.t(`${i18nPrefix}.search.summary.result${plural}`)} - - - {/* Texte de recherche. */} - {query && query.trim().length > 0 ? - {`${i18next.t(`${i18nPrefix}.search.summary.for`)} "${query}"`} - : null} - - {/* Liste des filtres (scope + facettes + critères) */} - {this.filterList.length ? -
    - {i18next.t(`${i18nPrefix}.search.summary.by`)} - {this.filterList.map(chip => {chip.label})} -
    - : null} - - {/* Groupe. */} - {groupingKey && !hideGroup ? -
    - {i18next.t(`${i18nPrefix}.search.summary.group${plural}`)} - store.groupingKey = undefined} - > - {i18next.t(store.groupingLabel!)} - -
    - : null} - - {/* Tri. */} - {this.currentSort && !hideSort && !groupingKey && totalCount > 1 ? -
    - {i18next.t(`${i18nPrefix}.search.summary.sortBy`)} - store.sortBy = undefined : undefined} - > - {i18next.t(this.currentSort.label)} + + {theme => +
    + + {/* Nombre de résultats. */} + + {totalCount} {i18next.t(`${i18nPrefix}.search.summary.result${plural}`)} + + + {/* Texte de recherche. */} + {query && query.trim().length > 0 ? + {`${i18next.t(`${i18nPrefix}.search.summary.for`)} "${query}"`} + : null} + + {/* Liste des filtres (scope + facettes + critères) */} + {this.filterList.length ? +
    + {i18next.t(`${i18nPrefix}.search.summary.by`)} + {this.filterList.map(chip => {chip.label})} +
    + : null} + + {/* Groupe. */} + {groupingKey && !hideGroup ? +
    + {i18next.t(`${i18nPrefix}.search.summary.group${plural}`)} + store.groupingKey = undefined} + > + {i18next.t(store.groupingLabel!)} + +
    + : null} + + {/* Tri. */} + {this.currentSort && !hideSort && !groupingKey && totalCount > 1 ? +
    + {i18next.t(`${i18nPrefix}.search.summary.sortBy`)} + store.sortBy = undefined : undefined} + > + {i18next.t(this.currentSort.label)} +
    + : null} + + {/* Action d'export. */} + {exportAction ? +
    +
    + : null}
    - : null} - - {/* Action d'export. */} - {exportAction ? -
    -
    - : null} -
    + } + ); } } - -export default themr("summary", styles)(Summary); diff --git a/src/components/autocomplete.tsx b/src/components/autocomplete.tsx index d1df8e42f..f700bf523 100644 --- a/src/components/autocomplete.tsx +++ b/src/components/autocomplete.tsx @@ -3,7 +3,6 @@ import {debounce} from "lodash-decorators"; import {action, observable, ObservableMap, runInAction} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; export {ObservableMap}; @@ -11,8 +10,11 @@ import {Autocomplete as RTAutocomplete, AutocompleteProps as RTAutocompleteProps import {InputTheme} from "react-toolbox/lib/input"; import {ProgressBar} from "react-toolbox/lib/progress_bar"; +import {themr} from "../theme"; + import * as styles from "./__style__/autocomplete.css"; export type AutocompleteStyle = Partial & AutocompleteTheme & InputTheme; +const Theme = themr("autocomplete", styles); /** Résultat du service de recherche. */ export interface AutocompleteResult { @@ -140,25 +142,28 @@ export class Autocomplete extends React.Component { } render() { - const {keyResolver, querySearcher, ...props} = this.props; + const {keyResolver, querySearcher, theme: pTheme, ...props} = this.props; return ( -
    - - {this.isLoading ? - - : null} -
    + + {theme => +
    + + {this.isLoading ? + + : null} +
    + } +
    ); } } - -export default themr("autocomplete", styles)(Autocomplete); diff --git a/src/components/boolean-radio.tsx b/src/components/boolean-radio.tsx index b36e3a6ab..efd348ec1 100644 --- a/src/components/boolean-radio.tsx +++ b/src/components/boolean-radio.tsx @@ -1,10 +1,12 @@ import i18next from "i18next"; import React from "react"; -import {themr} from "react-css-themr"; import {RadioButton, RadioGroup} from "react-toolbox/lib/radio"; +import {themr} from "../theme"; + import * as styles from "./__style__/boolean-radio.css"; export type BooleanRadioStyle = Partial; +const Theme = themr("booleanRadio", styles); export interface BooleanRadioProps { /** Disabled radio-select, default to: false */ @@ -26,31 +28,33 @@ export function BooleanRadio({ error, name, onChange, - theme, + theme: pTheme, value }: BooleanRadioProps) { return ( -
    - - - - - {error ?
    {error}
    : null} -
    + + {theme => ( + <> + + + + + {error ?
    {error}
    : null} + + )} +
    ); } - -export default themr("booleanRadio", styles)(BooleanRadio); diff --git a/src/components/button-back-to-top.tsx b/src/components/button-back-to-top.tsx index e124b1251..5dcdefee9 100644 --- a/src/components/button-back-to-top.tsx +++ b/src/components/button-back-to-top.tsx @@ -4,11 +4,13 @@ scroll.polyfill(); import {action, observable} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {Button, ButtonTheme} from "react-toolbox/lib/button"; +import {themr} from "../theme"; + import * as styles from "./__style__/button-btt.css"; -export type ButtonBackToTopStyle = Partial; +export type ButtonBackToTopStyle = Partial & ButtonTheme; +const Theme = themr("buttonBTT", styles as ButtonBackToTopStyle); /** Props du bouton de retour en haut de page. */ export interface ButtonBackToTopProps { @@ -17,7 +19,7 @@ export interface ButtonBackToTopProps { /** Comportement du scroll. Par défaut : "smooth" */ scrollBehaviour?: ScrollBehavior; /** CSS. */ - theme?: ButtonTheme & ButtonBackToTopStyle; + theme?: ButtonBackToTopStyle; } /** Bouton de retour en haut de page. */ @@ -54,19 +56,20 @@ export class ButtonBackToTop extends React.Component { } render() { - const {theme} = this.props; return this.isVisible ? -
    -
    + + {theme => +
    +
    + } +
    : null; } } - -export default themr("buttonBTT", styles)(ButtonBackToTop); diff --git a/src/components/button-help.tsx b/src/components/button-help.tsx index 41f283091..0e7e0a7f5 100644 --- a/src/components/button-help.tsx +++ b/src/components/button-help.tsx @@ -25,5 +25,3 @@ export function ButtonHelp({blockName, i18nPrefix = "focus"}: {blockName: string /> ); } - -export default ButtonHelp; diff --git a/src/components/button-menu.tsx b/src/components/button-menu.tsx index 9edb9c75c..b84fd8b5c 100644 --- a/src/components/button-menu.tsx +++ b/src/components/button-menu.tsx @@ -87,5 +87,3 @@ export class ButtonMenu extends React.Component { ); } } - -export default ButtonMenu; diff --git a/src/components/checkbox.tsx b/src/components/checkbox.tsx index ac4bc06d6..daa74a050 100644 --- a/src/components/checkbox.tsx +++ b/src/components/checkbox.tsx @@ -22,5 +22,3 @@ export function Checkbox(props: CheckboxProps) { return ; } - -export default Checkbox; diff --git a/src/components/display.tsx b/src/components/display.tsx index dae3438c4..c899a3d42 100644 --- a/src/components/display.tsx +++ b/src/components/display.tsx @@ -1,10 +1,12 @@ import {observable} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; + +import {themr} from "../theme"; import * as styles from "./__style__/display.css"; export type DisplayStyle = Partial; +const Theme = themr("display", styles); /** Props du composant d'affichage. */ export interface DisplayProps { @@ -49,16 +51,18 @@ export class Display extends React.Component { } render() { - const {valueKey = "code", labelKey = "label", values, value, formatter, theme} = this.props; + const {valueKey = "code", labelKey = "label", values, value, formatter} = this.props; // tslint:disable-next-line:triple-equals ---> Le "==" est volontaire pour convertir un éventuel ID de type string (comme celui donné par un Select) en number. const ref = values && values.find(v => (v as any)[valueKey] == value); const displayed = ref && (ref as any)[labelKey] || this.value; return ( -
    - {formatter && formatter(displayed) || displayed} -
    + + {theme => +
    + {formatter && formatter(displayed) || displayed} +
    + } +
    ); } } - -export default themr("display", styles)(Display); diff --git a/src/components/index.ts b/src/components/index.ts index a113a20d3..4478f523e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,18 +1,19 @@ -export {default as Autocomplete, AutocompleteProps, AutocompleteStyle} from "./autocomplete"; -export {default as BooleanRadio, BooleanRadioProps, BooleanRadioStyle} from "./boolean-radio"; -export {default as ButtonBackToTop, ButtonBackToTopProps, ButtonBackToTopStyle} from "./button-back-to-top"; -export {default as ButtonMenu, IconMenu, MenuItem} from "./button-menu"; -export {default as Checkbox, CheckboxProps} from "./checkbox"; -export {default as Display, DisplayProps, DisplayStyle} from "./display"; +export {Autocomplete, AutocompleteProps, AutocompleteStyle} from "./autocomplete"; +export {BooleanRadio, BooleanRadioProps, BooleanRadioStyle} from "./boolean-radio"; +export {ButtonBackToTop, ButtonBackToTopProps, ButtonBackToTopStyle} from "./button-back-to-top"; +export {ButtonHelp} from "./button-help"; +export {ButtonMenu, IconMenu, MenuItem} from "./button-menu"; +export {Checkbox, CheckboxProps} from "./checkbox"; +export {Display, DisplayProps, DisplayStyle} from "./display"; export {getIcon} from "./icon"; -export {default as Input, InputProps} from "./input"; -export {default as InputDate, InputDateProps} from "./input-date"; -export {default as InputTime, InputTimeProps} from "./input-time"; -export {default as Label, LabelProps, LabelStyle} from "./label"; -export {default as Panel, PanelButtons, PanelProps, PanelStyle} from "./panel"; -export {default as Popin, PopinStyle} from "./popin"; -export {default as ScrollspyContainer, ScrollspyContainerProps, ScrollspyStyle} from "./scrollspy-container"; -export {default as Select, SelectProps} from "./select"; -export {default as SelectCheckbox, SelectCheckboxProps, SelectCheckboxStyle} from "./select-checkbox"; -export {default as SelectRadio, SelectRadioProps, SelectRadioStyle} from "./select-radio"; -export {default as Switch, SwitchProps} from "./switch"; +export {Input, InputProps} from "./input"; +export {InputDate, InputDateProps} from "./input-date"; +export {InputTime, InputTimeProps} from "./input-time"; +export {Label, LabelProps, LabelStyle} from "./label"; +export {Panel, PanelButtons, PanelProps, PanelStyle} from "./panel"; +export {Popin, PopinStyle} from "./popin"; +export {ScrollspyContainer, ScrollspyContainerProps, ScrollspyStyle} from "./scrollspy-container"; +export {Select, SelectProps} from "./select"; +export {SelectCheckbox, SelectCheckboxProps, SelectCheckboxStyle} from "./select-checkbox"; +export {SelectRadio, SelectRadioProps, SelectRadioStyle} from "./select-radio"; +export {Switch, SwitchProps} from "./switch"; diff --git a/src/components/input-date.tsx b/src/components/input-date.tsx index c94a3a72d..b1177e12e 100644 --- a/src/components/input-date.tsx +++ b/src/components/input-date.tsx @@ -3,15 +3,17 @@ import {action, computed, observable} from "mobx"; import {observer} from "mobx-react"; import moment from "moment"; import * as React from "react"; -import {themr} from "react-css-themr"; import {IconButton} from "react-toolbox/lib/button"; import {DatePickerTheme} from "react-toolbox/lib/date_picker"; import calendarFactory from "react-toolbox/lib/date_picker/Calendar"; import {InputTheme} from "react-toolbox/lib/input"; -import {Input, InputProps} from "./input"; +import {Input, InputProps} from "../components"; +import {themr} from "../theme"; import * as styles from "react-toolbox/lib/date_picker/theme.css"; +const Theme = themr("RTDatePicker", styles); + import {calendar, down, fromRight, input, toggle, up} from "./__style__/input-date.css"; const Calendar = calendarFactory(IconButton); @@ -227,51 +229,53 @@ export class InputDate extends React.Component { } render() { - const {theme, inputFormat = "MM/DD/YYYY", calendarFormat = "ddd, MMM D", displayFrom = "left", ISOStringFormat = "utc-midnight", ...inputProps} = this.props; + const {theme: pTheme, inputFormat = "MM/DD/YYYY", calendarFormat = "ddd, MMM D", displayFrom = "left", ISOStringFormat = "utc-midnight", ...inputProps} = this.props; return ( -
    - this.dateText = value} - onKeyDown={this.handleKeyDown} - onFocus={() => this.showCalendar = true} - theme={theme} - value={this.dateText || ""} - /> - {this.showCalendar ? -
    this.calendar = cal} - className={`${calendar} ${displayFrom === "right" ? fromRight : ""} ${this.calendarPosition === "up" ? up : down}`} - > -
    - this.calendarDisplay = "years"}> - {this.date.year()} - -

    this.calendarDisplay = "months"}> - {(ISOStringFormat === "local-utc-midnight" ? this.date.clone().local() : this.date).format(calendarFormat)} -

    - this.showCalendar = false} /> -
    -
    - null} - selectedDate={this.jsDate} - display={this.calendarDisplay} - locale={moment.locale()} - onChange={this.onCalendarChange} - theme={theme} - /> -
    + + {theme => +
    + this.dateText = value} + onKeyDown={this.handleKeyDown} + onFocus={() => this.showCalendar = true} + theme={theme} + value={this.dateText || ""} + /> + {this.showCalendar ? +
    this.calendar = cal} + className={`${calendar} ${displayFrom === "right" ? fromRight : ""} ${this.calendarPosition === "up" ? up : down}`} + > +
    + this.calendarDisplay = "years"}> + {this.date.year()} + +

    this.calendarDisplay = "months"}> + {(ISOStringFormat === "local-utc-midnight" ? this.date.clone().local() : this.date).format(calendarFormat)} +

    + this.showCalendar = false} /> +
    +
    + null} + selectedDate={this.jsDate} + display={this.calendarDisplay} + locale={moment.locale()} + onChange={this.onCalendarChange} + theme={theme} + /> +
    +
    + : null}
    - : null} -
    + } + ); } } -export default themr("RTDatePicker", styles)(InputDate); - /** Détermine si une valeur est un ISO String. */ function isISOString(value?: string) { return moment(value, moment.ISO_8601, true) diff --git a/src/components/input-time.tsx b/src/components/input-time.tsx index e94d1a3f5..4fde572d6 100644 --- a/src/components/input-time.tsx +++ b/src/components/input-time.tsx @@ -3,7 +3,6 @@ import {action, observable} from "mobx"; import {observer} from "mobx-react"; import moment from "moment"; import * as React from "react"; -import {themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; import {IconButton} from "react-toolbox/lib/button"; import {InputTheme} from "react-toolbox/lib/input"; @@ -11,8 +10,11 @@ import {TimePickerTheme} from "react-toolbox/lib/time_picker"; import Clock from "react-toolbox/lib/time_picker/Clock"; import {Input, InputProps} from "../components"; +import {themr} from "../theme"; import * as styles from "react-toolbox/lib/time_picker/theme.css"; +const Theme = themr("RTTimePicker", styles); + import {calendar, clock, down, fromRight, input, toggle, up} from "./__style__/input-date.css"; /** Props de l'InputTime. */ @@ -185,53 +187,55 @@ export class InputTime extends React.Component { } render() { - const {theme, inputFormat = "HH:mm", displayFrom = "left", ...inputProps} = this.props; + const {theme: pTheme, inputFormat = "HH:mm", displayFrom = "left", ...inputProps} = this.props; return ( -
    - this.timeText = value} - onKeyDown={this.handleKeyDown} - onFocus={() => this.showClock = true} - theme={theme} - value={this.timeText || ""} - /> - {this.showClock ? -
    this.clock = clo} - className={`${calendar} ${theme!.dialog} ${this.clockDisplay === "hours" ? theme!.hoursDisplay : theme!.minutesDisplay} ${displayFrom === "right" ? fromRight : ""} ${this.clockPosition === "up" ? up : down}`} - > -
    - this.clockDisplay = "hours"}> - {(`0${this.time.hours()}`).slice(-2)} - - : - this.clockDisplay = "minutes"}> - {(`0${this.time.minutes()}`).slice(-2)} - - this.showClock = false} /> -
    -
    - this.clockComp = c} - display={this.clockDisplay} - format="24hr" - onChange={this.onClockChange} - onHandMoved={this.onHandMoved} - theme={theme} - time={this.time.toDate()} - /> -
    + + {theme => +
    + this.timeText = value} + onKeyDown={this.handleKeyDown} + onFocus={() => this.showClock = true} + theme={theme} + value={this.timeText || ""} + /> + {this.showClock ? +
    this.clock = clo} + className={`${calendar} ${theme!.dialog} ${this.clockDisplay === "hours" ? theme!.hoursDisplay : theme!.minutesDisplay} ${displayFrom === "right" ? fromRight : ""} ${this.clockPosition === "up" ? up : down}`} + > +
    + this.clockDisplay = "hours"}> + {(`0${this.time.hours()}`).slice(-2)} + + : + this.clockDisplay = "minutes"}> + {(`0${this.time.minutes()}`).slice(-2)} + + this.showClock = false} /> +
    +
    + this.clockComp = c} + display={this.clockDisplay} + format="24hr" + onChange={this.onClockChange} + onHandMoved={this.onHandMoved} + theme={theme} + time={this.time.toDate()} + /> +
    +
    + : null}
    - : null} -
    + } + ); } } -export default themr("RTTimePicker", styles)(InputTime); - /** Détermine si une valeur est un ISO String. */ function isISOString(value?: string) { return moment(value, moment.ISO_8601, true) diff --git a/src/components/input.tsx b/src/components/input.tsx index 3de38e2c0..208b31165 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -187,8 +187,6 @@ export class Input extends React.Component { } } -export default Input; - const KEYCODE_Z = 90; const KEYCODE_Y = 89; diff --git a/src/components/label.tsx b/src/components/label.tsx index 070303b94..13eeefc86 100644 --- a/src/components/label.tsx +++ b/src/components/label.tsx @@ -1,14 +1,16 @@ import i18next from "i18next"; import * as React from "react"; -import {themr} from "react-css-themr"; import {FontIcon} from "react-toolbox/lib/font_icon"; import Tooltip from "react-toolbox/lib/tooltip"; const TooltipIcon = Tooltip(FontIcon); +import {themr} from "../theme"; + import {getIcon} from "./icon"; import * as styles from "./__style__/label.css"; export type LabelStyle = Partial; +const Theme = themr("label", styles); /** Props du Label. */ export interface LabelProps { @@ -30,23 +32,25 @@ export interface LabelProps { theme?: LabelStyle; } -export function Label({comment, i18nPrefix = "focus", label, name, onTooltipClick, showTooltip, style, theme}: LabelProps) { +export function Label({comment, i18nPrefix = "focus", label, name, onTooltipClick, showTooltip, style, theme: pTheme}: LabelProps) { return ( -
    - - {comment && showTooltip ? - - : null} -
    + + {theme => +
    + + {comment && showTooltip ? + + : null} +
    + } +
    ); } - -export default themr("label", styles)(Label); diff --git a/src/components/panel/buttons.tsx b/src/components/panel/buttons.tsx index 1717b3a06..63a855e6f 100644 --- a/src/components/panel/buttons.tsx +++ b/src/components/panel/buttons.tsx @@ -53,5 +53,3 @@ export function PanelButtons({editing, i18nPrefix = "focus", loading, toggleEdit return null; } - -export default PanelButtons; diff --git a/src/components/panel/index.tsx b/src/components/panel/index.tsx index 0010dd268..4c82fc4e5 100644 --- a/src/components/panel/index.tsx +++ b/src/components/panel/index.tsx @@ -4,26 +4,25 @@ import {observable} from "mobx"; import {observer} from "mobx-react"; import * as PropTypes from "prop-types"; import * as React from "react"; -import {themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; import {ProgressBar} from "react-toolbox/lib/progress_bar"; -import {ReactComponent} from "../../config"; +import {themr} from "../../theme"; -import ButtonHelp from "../button-help"; +import {ButtonHelp} from "../button-help"; import {PanelButtons, PanelButtonsProps} from "./buttons"; export {PanelButtons}; import * as styles from "../__style__/panel.css"; - export type PanelStyle = Partial; +const Theme = themr("panel", styles); /** Props du panel. */ export interface PanelProps extends PanelButtonsProps { /** Nom du bloc pour le bouton d'aide. Par défaut : premier mot du titre. */ blockName?: string; /** Boutons à afficher dans le Panel. Par défaut : les boutons de formulaire (edit / save / cancel). */ - Buttons?: ReactComponent; + Buttons?: React.ComponentType; /** Position des boutons. Par défaut : "top". */ buttonsPosition?: "both" | "bottom" | "top" | "none"; /** Masque le panel dans le ScrollspyContainer. */ @@ -95,10 +94,10 @@ export class Panel extends React.Component { } render() { - const {blockName, Buttons = PanelButtons, buttonsPosition = "top", children, i18nPrefix, loading, title, showHelp, editing, toggleEdit, save, hideProgressBar, theme} = this.props; + const {blockName, Buttons = PanelButtons, buttonsPosition = "top", children, i18nPrefix, loading, title, showHelp, editing, toggleEdit, save, hideProgressBar} = this.props; - const buttons = ( -
    + const buttons = (theme: PanelStyle) => ( +
    ); @@ -107,36 +106,38 @@ export class Panel extends React.Component { const areButtonsDown = ["bottom", "both"].find(i => i === buttonsPosition); return ( -
    - {!hideProgressBar && loading ? : null} - {title || areButtonsTop ? -
    - {title ? -

    - {i18next.t(title)} - {showHelp ? - + + {theme => +
    + {!hideProgressBar && loading ? : null} + {title || areButtonsTop ? +
    + {title ? +

    + {i18next.t(title)} + {showHelp ? + + : null} +

    : null} -

    + {areButtonsTop ? buttons(theme) : null} +
    + : null} +
    + {children} +
    + {areButtonsDown ? +
    + {buttons(theme)} +
    : null} - {areButtonsTop ? buttons : null} -
    - : null} -
    - {children} -
    - {areButtonsDown ? -
    - {buttons}
    - : null} -
    + } + ); } } - -export default themr("panel", styles)(Panel); diff --git a/src/components/popin.tsx b/src/components/popin.tsx index 2ee1d4412..ee37e834e 100644 --- a/src/components/popin.tsx +++ b/src/components/popin.tsx @@ -1,16 +1,17 @@ import {action, observable} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; import {IconButton} from "react-toolbox/lib/button"; import Portal from "react-toolbox/lib/hoc/Portal"; +import {themr} from "../theme"; + import {getIcon} from "./icon"; import * as styles from "./__style__/popin.css"; - export type PopinStyle = Partial; +const Theme = themr("popin", styles); /** Props de la popin. */ export interface PopinProps { @@ -120,51 +121,60 @@ export class Popin extends React.Component { } /** Récupère les deux animations d'ouverture et de fermeture selon le type de popin. */ - private get animations() { - const {type = "from-right", theme} = this.props; + private animation(theme: PopinStyle) { + const {type = "from-right"} = this.props; + let open; + let close; switch (type) { case "from-right": - return { - open: theme!.slideInRight, - close: theme!.slideOutRight - }; + open = theme.slideInRight; + close = theme.slideOutRight; + break; case "from-left": - return { - open: theme!.slideInLeft, - close: theme!.slideOutLeft - }; - default: - return {open: "", close: ""}; + open = theme.slideInLeft; + close = theme.slideOutLeft; + break; + } + + if (this.willClose) { + return close; + } else if (this.willOpen) { + return open; + } else { + return ""; } } render() { - const {i18nPrefix = "focus", level = 0, children, closePopin, theme, type = "from-right", preventOverlayClick} = this.props; - const {open, close} = this.animations; + const {i18nPrefix = "focus", level = 0, children, closePopin, type = "from-right", preventOverlayClick} = this.props; return this.opened ? -
    0 ? {background: "none"} : {}} - > - {!this.willOpen ? - - : null} -
    -
    e.stopPropagation()} - > - {children} -
    + + {theme => + <> +
    0 ? {background: "none"} : {}} + > + {!this.willOpen ? + + : null} +
    +
    e.stopPropagation()} + > + {children} +
    + + } +
    :
    ; } } - -export default themr("popin", styles)(Popin); diff --git a/src/components/scrollspy-container.tsx b/src/components/scrollspy-container.tsx index c9aa81aaa..0d40bea4a 100644 --- a/src/components/scrollspy-container.tsx +++ b/src/components/scrollspy-container.tsx @@ -7,15 +7,16 @@ import {action, computed, observable, untracked} from "mobx"; import {observer} from "mobx-react"; import * as PropTypes from "prop-types"; import * as React from "react"; -import {themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; -import ButtonBackToTop from "./button-back-to-top"; +import {themr} from "../theme"; + +import {ButtonBackToTop} from "./button-back-to-top"; import {PanelDescriptor} from "./panel"; import * as styles from "./__style__/scrollspy-container.css"; - export type ScrollspyStyle = Partial; +const Theme = themr("scrollspy", styles); /** Props du ScrollspyContainer. */ export interface ScrollspyContainerProps { @@ -199,23 +200,25 @@ export class ScrollspyContainer extends React.Component } render() { - const {children, hideBackToTop, menuWidth = 250, scrollBehaviour = "smooth", theme} = this.props; + const {children, hideBackToTop, menuWidth = 250, scrollBehaviour = "smooth"} = this.props; return ( -
    - -
    - {children} -
    - {!hideBackToTop ? : null} -
    + + {theme => +
    + +
    + {children} +
    + {!hideBackToTop ? : null} +
    + } +
    ); } } - -export default themr("scrollspy", styles)(ScrollspyContainer); diff --git a/src/components/select-checkbox.tsx b/src/components/select-checkbox.tsx index 09d0b8a25..43408032f 100644 --- a/src/components/select-checkbox.tsx +++ b/src/components/select-checkbox.tsx @@ -1,11 +1,13 @@ import i18next from "i18next"; import React from "react"; -import {themr} from "react-css-themr"; + +import {themr} from "../theme"; import {Checkbox} from "./checkbox"; import * as styles from "./__style__/select-checkbox.css"; export type SelectCheckboxStyle = Partial; +const Theme = themr("selectCheckbox", styles); function clickHandlerFactory( isDisabled: boolean, @@ -23,8 +25,8 @@ function clickHandlerFactory( // is selected -> remove it onChange( value - ? (value as any).filter((val: any) => val !== optVal) - : undefined + ? (value as any).filter((val: any) => val !== optVal) + : undefined ); } else { // is not selected -> add it @@ -64,50 +66,54 @@ export function SelectCheckbox({ labelKey, name, onChange, - theme, + theme: pTheme, value, valueKey, values }: SelectCheckboxProps) { return ( -
    - {label &&
    {i18next.t(label)}
    } -
      - {values.map(option => { - const optVal = (option as any)[valueKey]; - const optLabel = (option as any)[labelKey]; + + {theme => ( +
      + {label &&
      {i18next.t(label)}
      } +
        + {values.map(option => { + const optVal = (option as any)[valueKey]; + const optLabel = (option as any)[labelKey]; - const isSelected = value - ? !!(value as any).find((val: any) => optVal === val) - : false; - const clickHandler = clickHandlerFactory( - disabled, - isSelected, - value, - optVal, - onChange - ); + const isSelected = value + ? !!(value as any).find( + (val: any) => optVal === val + ) + : false; + const clickHandler = clickHandlerFactory( + disabled, + isSelected, + value, + optVal, + onChange + ); - return ( -
      • - -
      • - ); - })} -
      - {error ?
      {error}
      : null} -
      + return ( +
    • + +
    • + ); + })} +
    + {error ?
    {error}
    : null} +
    + )} + ); } - -export default themr("selectCheckbox", styles)(SelectCheckbox); diff --git a/src/components/select-radio.tsx b/src/components/select-radio.tsx index 0268a41cd..5394eef8a 100644 --- a/src/components/select-radio.tsx +++ b/src/components/select-radio.tsx @@ -1,10 +1,12 @@ import i18next from "i18next"; import React from "react"; -import {themr} from "react-css-themr"; import {RadioButton, RadioGroup} from "react-toolbox/lib/radio"; +import {themr} from "../theme"; + import * as styles from "./__style__/select-radio.css"; export type SelectRadioStyle = Partial; +const Theme = themr("selectRadio", styles); /** Props for RadioSelect */ export interface SelectRadioProps { @@ -44,7 +46,7 @@ export function SelectRadio({ labelKey, name, onChange, - theme, + theme: pTheme, value, valueKey, values, @@ -67,31 +69,35 @@ export function SelectRadio({ } return ( -
    - {label &&
    {i18next.t(label)}
    } - - {definitiveValues.map(option => { - const optVal = (option as any)[valueKey]; - const optLabel = (option as any)[labelKey]; + + {theme => ( +
    + {label && ( +
    {i18next.t(label)}
    + )} + + {definitiveValues.map(option => { + const optVal = (option as any)[valueKey]; + const optLabel = (option as any)[labelKey]; - return ( - - ); - })} - - {error ?
    {error}
    : null} -
    + return ( + + ); + })} +
    + {error ?
    {error}
    : null} +
    + )} + ); } - -export default themr("selectRadio", styles)(SelectRadio); diff --git a/src/components/select.tsx b/src/components/select.tsx index aad074417..7d0d0585b 100644 --- a/src/components/select.tsx +++ b/src/components/select.tsx @@ -1,9 +1,11 @@ import i18next from "i18next"; import * as React from "react"; -import {themr} from "react-css-themr"; + +import {themr} from "../theme"; import * as styles from "./__style__/select.css"; export type SelectStyle = Partial; +const Theme = themr("select", styles); /** Props du Select. */ export interface SelectProps { @@ -40,7 +42,7 @@ export function Select({ labelKey, name, onChange, - theme, + theme: pTheme, value, valueKey, values, @@ -59,23 +61,25 @@ export function Select({ } return ( -
    - - {error ?
    {error}
    : null} -
    + + {theme => +
    + + {error ?
    {error}
    : null} +
    + } +
    ); } - -export default themr("select", styles)(Select); diff --git a/src/components/switch.tsx b/src/components/switch.tsx index 3cd1d2136..41c82ce72 100644 --- a/src/components/switch.tsx +++ b/src/components/switch.tsx @@ -22,5 +22,3 @@ export function Switch(props: SwitchProps) { return ; } - -export default Switch; diff --git a/src/config.ts b/src/config.ts index 6eb388ceb..2a7658822 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,3 @@ -import {ComponentClass, SFC} from "react"; - /** Config Focus de l'application */ export const config = { @@ -9,6 +7,3 @@ export const config = { /** Délai entre la saisie du texte et la recherche dans la barre de recherche. */ textSearchDelay: 500 }; - -/** Composant React avec props. */ -export type ReactComponent

    = ComponentClass

    | SFC

    ; diff --git a/src/entity/field/field.tsx b/src/entity/field/field.tsx index f810fd835..39d213b64 100644 --- a/src/entity/field/field.tsx +++ b/src/entity/field/field.tsx @@ -7,7 +7,6 @@ import {themeable, themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; import {Display, Input, Label} from "../../components"; -import {ReactComponent} from "../../config"; import {ReferenceList} from "../../reference"; import {EntityField, FieldEntry, FormEntityField} from "../types"; @@ -39,7 +38,7 @@ export interface FieldOptions { onChange?: (value: T["fieldType"]) => void; /** @internal */ /** Pour `selectFor`, composant de Select. */ - SelectComponent?: ReactComponent; + SelectComponent?: React.ComponentType; /** Affiche la tooltip de commentaire. */ showTooltip?: boolean; /** CSS. */ diff --git a/src/entity/types/entity.ts b/src/entity/types/entity.ts index 0a2ef38c6..6406b3dc6 100644 --- a/src/entity/types/entity.ts +++ b/src/entity/types/entity.ts @@ -1,5 +1,4 @@ import {DisplayProps, InputProps, LabelProps} from "../../components"; -import {ReactComponent} from "../../config"; import {Validator} from "./validation"; @@ -22,17 +21,17 @@ export interface Domain; + DisplayComponent?: React.ComponentType; /** Props pour le composant d'affichage */ displayProps?: Partial; /** Composant personnalisé pour l'entrée utilisateur. */ - InputComponent?: ReactComponent; + InputComponent?: React.ComponentType; /** Props pour le composant d'entrée utilisateur. */ inputProps?: Partial; /** Composant personnalisé pour le libellé. */ - LabelComponent?: ReactComponent; + LabelComponent?: React.ComponentType; /** Props pour le composant de libellé. */ labelProps?: Partial; } diff --git a/src/layout/index.tsx b/src/layout/index.tsx index 90b3e9bed..c9104d6e7 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -5,7 +5,7 @@ import {observable} from "mobx"; import {observer} from "mobx-react"; import * as PropTypes from "prop-types"; import * as React from "react"; -import {ThemeProvider, themr, TReactCSSThemrTheme} from "react-css-themr"; +import {ThemeProvider as LegacyThemeProvider, themr, TReactCSSThemrTheme} from "react-css-themr"; import {ButtonTheme} from "react-toolbox/lib/button"; import {CheckboxTheme} from "react-toolbox/lib/checkbox"; @@ -19,6 +19,7 @@ import {AutocompleteStyle, BooleanRadioStyle, ButtonBackToTopStyle, DisplayStyle import {FieldStyle, FormStyle} from "../entity"; import {MessageCenter} from "../message"; import {LoadingBarStyle} from "../network"; +import {ThemeProvider} from "../theme"; import ErrorCenter, {ErrorCenterStyle} from "./error-center"; import {HeaderStyle} from "./header"; @@ -121,10 +122,12 @@ export interface LayoutStyleProviderProps { */ export function Layout(props: LayoutProps & {appTheme?: LayoutStyleProviderProps}) { return ( - - - {props.children} - + + + + {props.children} + + ); } diff --git a/src/layout/readme.md b/src/layout/readme.md index b9263eee6..d4e691a31 100644 --- a/src/layout/readme.md +++ b/src/layout/readme.md @@ -35,52 +35,3 @@ Le `Header` se construit à partir de tous ses éléments. Un `Header` complet s ``` et libre à vous de le personnaliser à votre convenance en ajoutant ou supprimant des éléments, et on contrôlant sont contenu en fonction de la page courante. - -## Gestion du CSS -Gérer le style de composants est un problème compliqué à résoudre correctement. Bien sûr, il est totalement trivial d'écrire des classes en CSS et de les appliquer à des balises HTML, mais il faut beaucoup de bonne volonté et de rigueur pour le faire de façon propre et maintenable. Etant bien souvent victime de négligence de la part des développeurs, on se retrouve rapidement en enfer. - -Il existe des tonnes de librairies et de pratiques différentes pour tenter de mettre de l'ordre dans notre CSS. La solution mise en oeuvre dans Focus V4 se base sur différentes idées piochées un peu partout : - -### Modules CSS -Un [module CSS](https://github.com/css-modules/css-modules) est un fichier CSS que l'on importe directement dans un fichier de composant. Le contenu de l'import est un object contenant les noms de toutes les classes CSS définies dans le fichier, que l'on peut utiliser directement en tant que `className` sur un tag HTML. L'intérêt de cet usage est que l'on peut demander à Webpack de **brouiller le nom de classes** à la compilation pour ainsi effectivement "scoper" le CSS aux composants qui l'ont importé. Ainsi, on obtient une isolation du CSS que l'on écrit et on peut simplifier beaucoup les noms de classes, quasiment supprimer le nesting et se forcer à regrouper les styles avec les composants qui les utilisent car ils sont liés par le code. - -Il est également possible de générer automatiquement des types pour ces imports CSS (un fichier *.d.ts contenant le nom de toutes les classes globalement) avec [`typed-css-modules`](https://github.com/Quramy/typed-css-modules), ce qui permet de contrôler le nom des classes que l'on importe et de planter à la compilation si on se trompe/on supprime/on refactore du CSS. C'est quasiment gratuit (une commande `tcm` à lancer régulièrement) et c'est pratique. - -### Injection de classes CSS -Le scoping des classes est une fonctionnalité à double tranchant, car elle va nous empêcher de surcharger directement le CSS des composants de la librairie. Pour résoudre ce problème, on va utilise la librairie [`react-css-themr`](https://github.com/javivelasco/react-css-themr) qui permet de fournir un combo *Provider*/*Injector* (`ThemeProvider` et `themr`) autour d'une propriété `theme` qui contient les classes CSS à utiliser dans un composant. L'injecteur va permettre de fusionner les classes issues du Provider (le vôtre), du style par défaut (celui du framework) et celui passé en Props. - -Par exemple, le header est défini ainsi: - -```tsx -import * as styles from "./style/header.css"; -export type HeaderStyle = Partial; - -export const Header = themr("header", styles)( - ({classNames}: {classNames?: HeaderStyle}) => { - return ( - - /* Suite du composant... */ -``` - - -Le `Layout` inclus déjà le `ThemeProvider`, donc our surcharger le CSS du header, il suffit donc d'ajouter vos propres classes dans la propriété `injectedStyle` : - -```tsx -import {Layout} from "focus4/application"; -import {deployed, scrolling} from "./styles.css"; - -ReactDOM.render( - - {/* Votre appli */} - -); -``` \ No newline at end of file diff --git a/src/readme.md b/src/readme.md index c6256b0fe..632a1c77a 100644 --- a/src/readme.md +++ b/src/readme.md @@ -84,6 +84,8 @@ Cette fonction permet de transformer une liste classique en une liste utilisable ## [Module `router`](router) +## [Module `theme`](theme) + ## Module `user` Ce module propose une base de store utilisateur, pour y stocker les données de la session. La seule fonctionnalité prévue est la gestion des rôles / permissions (avec `roles` et `hasRole()`), et c'est à chaque application d'y rajouter leurs informations métiers pertinentes. diff --git a/src/theme/context.ts b/src/theme/context.ts new file mode 100644 index 000000000..9832101fb --- /dev/null +++ b/src/theme/context.ts @@ -0,0 +1,4 @@ +import * as React from "react"; + +export const ThemeContext = React.createContext>(); +export const ThemeProvider = ThemeContext.Provider; diff --git a/src/theme/index.ts b/src/theme/index.ts new file mode 100644 index 000000000..fd38cbc2f --- /dev/null +++ b/src/theme/index.ts @@ -0,0 +1,2 @@ +export {ThemeProvider} from "./context"; +export {themr} from "./themr"; diff --git a/src/theme/readme.md b/src/theme/readme.md new file mode 100644 index 000000000..a9543e00a --- /dev/null +++ b/src/theme/readme.md @@ -0,0 +1,58 @@ +# Module `theme` + +## Gestion du CSS +Gérer le style de composants est un problème compliqué à résoudre correctement. Bien sûr, il est totalement trivial d'écrire des classes en CSS et de les appliquer à des balises HTML, mais il faut beaucoup de bonne volonté et de rigueur pour le faire de façon propre et maintenable. Etant bien souvent victime de négligence de la part des développeurs, on se retrouve rapidement en enfer. + +Il existe des tonnes de librairies et de pratiques différentes pour tenter de mettre de l'ordre dans notre CSS. La solution mise en oeuvre dans Focus V4 se base sur différentes idées piochées un peu partout : + +### Modules CSS +Un [module CSS](https://github.com/css-modules/css-modules) est un fichier CSS que l'on importe directement dans un fichier de composant. Le contenu de l'import est un object contenant les noms de toutes les classes CSS définies dans le fichier, que l'on peut utiliser directement en tant que `className` sur un tag HTML. L'intérêt de cet usage est que l'on peut demander à Webpack de **brouiller le nom de classes** à la compilation pour ainsi effectivement "scoper" le CSS aux composants qui l'ont importé. Ainsi, on obtient une isolation du CSS que l'on écrit et on peut simplifier beaucoup les noms de classes, quasiment supprimer le nesting et se forcer à regrouper les styles avec les composants qui les utilisent car ils sont liés par le code. + +Il est également possible de générer automatiquement des types pour ces imports CSS (un fichier *.d.ts contenant le nom de toutes les classes globalement) avec [`typed-css-modules`](https://github.com/Quramy/typed-css-modules), ce qui permet de contrôler le nom des classes que l'on importe et de planter à la compilation si on se trompe/on supprime/on refactore du CSS. C'est quasiment gratuit (une commande `tcm` à lancer régulièrement) et c'est pratique. + +### Injection de classes CSS +Le scoping des classes est une fonctionnalité à double tranchant, car elle va nous empêcher de surcharger directement le CSS des composants de la librairie. Pour résoudre ce problème, on va utilise la librairie [`react-css-themr`](https://github.com/javivelasco/react-css-themr), fournie avec React Toolbox, qui permet de faire de la fusion de modules CSS. Avec l'aide de l'API de Context de React (16), on va construire un combo *Provider*/*Consumer* (`ThemeProvider` et `themr()`) autour d'une propriété `theme` qui contient les classes CSS à utiliser dans un composant. La fonction `themr` va créer un composant qui va permettre de fusionner les classes issues du style par défaut (celui du framework), celui passé dans le `ThemeProvider` (le vôtre) et celui passé en Props. + +Par exemple, le `Display` est défini ainsi: + +```tsx +// Imports +import * as styles from "./__style__/display.css"; +export type DisplayStyle = Partial; +const Theme = themr("display", styles); + +/* bla bla */ + + // Render + render() { + /* bla bla */ + return ( + + {theme => +

    + {formatter && formatter(displayed) || displayed} +
    + } + + ); + } +``` +Le composant `Theme` ainsi créé prend une fonction de rendu comme `children`, comme le `Context.Consumer` qu'il pose, à laquelle le `theme` fusionné sera passé. + + +Le `Layout` inclus déjà le `ThemeProvider`, donc pour surcharger du CSS de manière globale il suffit donc d'ajouter vos propres classes dans la propriété `appTheme` : + +```tsx +import {Layout} from "focus4/layout"; +import {deployed, scrolling} from "./styles.css"; + +ReactDOM.render( + + {/* Votre appli */} + +); +``` diff --git a/src/theme/themr.tsx b/src/theme/themr.tsx new file mode 100644 index 000000000..6660fc5b1 --- /dev/null +++ b/src/theme/themr.tsx @@ -0,0 +1,15 @@ +import {Observer} from "mobx-react"; +import * as React from "react"; +import {themeable} from "react-css-themr"; + +import {ThemeContext} from "./context"; + +export function themr(name: string, localTheme?: T) { + return function ThemeConsumer({children, theme}: {children: (theme: T) => React.ReactElement, theme?: Partial}) { + return ( + + {context => {() => children(themeable(localTheme || {}, context && context[name] || {}, theme as any || {}) as any)}} + + ); + }; +} diff --git a/tslint.json b/tslint.json index 0b943cb99..0921b32da 100644 --- a/tslint.json +++ b/tslint.json @@ -65,6 +65,7 @@ ], "no-construct": true, "no-debugger": true, + "no-default-export": true, "no-duplicate-imports": true, "no-duplicate-switch-case": true, "no-duplicate-variable": true, From 3f5c5949d2e6833df62f6eed07d2ea1cf544a006 Mon Sep 17 00:00:00 2001 From: Damien Frikha Date: Sun, 22 Apr 2018 20:15:31 +0200 Subject: [PATCH 48/89] Fin migration theme HoC => render prop. --- package.json | 4 +- src/application/header.tsx | 2 +- src/application/index.ts | 2 +- .../components/list/drag-layer.tsx | 44 ++++++------ .../components/search/results/group.tsx | 2 + src/entity/field/field.tsx | 67 ++++++++++-------- src/entity/field/utils.tsx | 12 ++-- src/entity/form/form.tsx | 33 +++++---- src/entity/form/index.ts | 2 +- src/layout/content.tsx | 18 +++-- src/layout/error-center.tsx | 70 ++++++++++--------- src/layout/footer.tsx | 2 - src/layout/header/actions.tsx | 64 +++++++++-------- src/layout/header/content.tsx | 19 ++--- src/layout/header/index.ts | 8 +-- src/layout/header/scrolling.tsx | 21 +++--- src/layout/header/top-row/bar-left.tsx | 18 +++-- src/layout/header/top-row/bar-right.tsx | 18 +++-- src/layout/header/top-row/index.tsx | 29 ++++---- src/layout/header/top-row/summary.tsx | 18 +++-- src/layout/index.tsx | 41 +++++------ src/layout/menu/index.tsx | 67 +++++++++--------- src/layout/menu/item.tsx | 48 +++++++------ src/layout/menu/list.tsx | 36 +++++----- src/layout/menu/panel.tsx | 26 ++++--- src/layout/types.ts | 6 +- src/message/index.ts | 2 +- src/message/message-center.tsx | 2 - src/network/index.ts | 2 +- src/network/loading-bar.tsx | 34 +++++---- src/react-css-themr.d.ts | 8 --- src/theme/context.ts | 4 -- src/theme/index.ts | 2 - src/theme/index.tsx | 34 +++++++++ src/theme/readme.md | 2 +- src/theme/themr.tsx | 15 ---- 36 files changed, 418 insertions(+), 364 deletions(-) delete mode 100644 src/react-css-themr.d.ts delete mode 100644 src/theme/context.ts delete mode 100644 src/theme/index.ts create mode 100644 src/theme/index.tsx delete mode 100644 src/theme/themr.tsx diff --git a/package.json b/package.json index a96264027..cb0fd7ac0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "focus4", - "version": "9.5.0-test", + "version": "9.5.0-preview", "description": "Focus v4 (Typescript + MobX)", "main": "index.js", "repository": { @@ -14,7 +14,7 @@ "clean": "node scripts/clean.js", "prepublishOnly": "node scripts/clean.js && node scripts/tcm.js && tsc --outDir ./ && node scripts/copy-css.js ./", "postinstall": "node scripts/patch-types-react.js", - "postpublish": "rm -rf config.js config.d.ts index.js index.d.ts ioc.js ioc.d.ts reference.js reference.d.ts user.js user.d.ts util.js util.d.ts && rm -rf application collections components entity layout message network router style testing translation", + "postpublish": "rm -rf config.js config.d.ts index.js index.d.ts ioc.js ioc.d.ts reference.js reference.d.ts user.js user.d.ts util.js util.d.ts && rm -rf application collections components entity layout message network router style testing translation theme", "start": "tsc && node scripts/copy-css.js dist", "stylelint": "stylelint src/**/*.css", "tcm": "node scripts/tcm.js", diff --git a/src/application/header.tsx b/src/application/header.tsx index 7573ed941..b96d8a7be 100644 --- a/src/application/header.tsx +++ b/src/application/header.tsx @@ -29,4 +29,4 @@ export const Header = observer(() => ( )); -export default Header; +(Header as any).displayName = "Header"; diff --git a/src/application/index.ts b/src/application/index.ts index 5a9c1eb73..0bffeded9 100644 --- a/src/application/index.ts +++ b/src/application/index.ts @@ -1,2 +1,2 @@ -export {default as Header} from "./header"; +export {Header} from "./header"; export {applicationStore} from "./store"; diff --git a/src/collections/components/list/drag-layer.tsx b/src/collections/components/list/drag-layer.tsx index f15451782..10b7a3a69 100644 --- a/src/collections/components/list/drag-layer.tsx +++ b/src/collections/components/list/drag-layer.tsx @@ -27,28 +27,32 @@ export const DndDragLayer = DragLayer(monitor => ({ currentOffset: monitor.getClientOffset(), isDragging: monitor.isDragging(), item: monitor.getItem() -}))(({currentOffset, i18nPrefix = "focus", isDragging, item, theme: pTheme}: DndDragLayerProps) => { +}))(class DndDragLayerClass extends React.Component { - if (!isDragging || !item || !item.dragged) { - return
    ; - } + render() { + const {currentOffset, i18nPrefix = "focus", isDragging, item, theme: pTheme} = this.props; + + if (!isDragging || !item || !item.dragged) { + return
    ; + } - return ( - - {theme => -
    -
    - drag_handle -
    - {item.dragged.length} + return ( + + {theme => +
    +
    + drag_handle +
    + {item.dragged.length} +
    +
    {i18next.t(`${i18nPrefix}.dragLayer.item${item.dragged.length !== 1 ? "s" : ""}`)}
    -
    {i18next.t(`${i18nPrefix}.dragLayer.item${item.dragged.length !== 1 ? "s" : ""}`)}
    -
    - } - - ); + } + + ); + } }); diff --git a/src/collections/components/search/results/group.tsx b/src/collections/components/search/results/group.tsx index 5063d65c5..17117d2f6 100644 --- a/src/collections/components/search/results/group.tsx +++ b/src/collections/components/search/results/group.tsx @@ -157,6 +157,8 @@ export const GroupLoadingBar = observer(({i18nPrefix = "focus", store}: {i18nPre :
    ); +(GroupLoadingBar as any).displayName = "GroupLoadingBar"; + export function DefaultGroupHeader({group}: {group: GroupResult}) { return {`${i18next.t(group.label)} (${group.totalCount})`}; } diff --git a/src/entity/field/field.tsx b/src/entity/field/field.tsx index 39d213b64..df316d419 100644 --- a/src/entity/field/field.tsx +++ b/src/entity/field/field.tsx @@ -1,20 +1,20 @@ import i18next from "i18next"; import {action, computed, observable} from "mobx"; -import {observer} from "mobx-react"; import PropTypes from "prop-types"; import * as React from "react"; -import {themeable, themr} from "react-css-themr"; +import {themeable} from "react-css-themr"; import {findDOMNode} from "react-dom"; import {Display, Input, Label} from "../../components"; import {ReferenceList} from "../../reference"; +import {themr} from "../../theme"; import {EntityField, FieldEntry, FormEntityField} from "../types"; import {documentHelper} from "./document-helper"; import * as styles from "./__style__/field.css"; - export type FieldStyle = Partial; +const Theme = themr("field", styles); /** Options pour un champ défini à partir de `fieldFor` et consorts. */ export interface FieldOptions { @@ -39,6 +39,9 @@ export interface FieldOptions { /** @internal */ /** Pour `selectFor`, composant de Select. */ SelectComponent?: React.ComponentType; + /** @internal */ + /** Pour `selectFor`, props du composant de Select. */ + selectProps?: SProps; /** Affiche la tooltip de commentaire. */ showTooltip?: boolean; /** CSS. */ @@ -51,7 +54,6 @@ export interface FieldOptions { } /** Composant de champ, gérant des composants de libellé, d'affichage et/ou d'entrée utilisateur. */ -@observer export class Field extends React.Component & {field: EntityField}> { // On récupère le forceErrorDisplay du form depuis le contexte. @@ -108,7 +110,7 @@ export class Field extends React.Component extends React.Component x), inputProps = {}}}} = field as FormEntityField; let props: any = { - ...inputProps as {}, value: inputFormatter(value), error: this.showError && error || undefined, name, id: name, onChange: this.onChange, - theme: themeable(inputProps.theme || {}, theme!.input || {}) + theme: themeable(inputProps.theme || {}, theme && theme.input || {}) }; if (keyResolver) { @@ -137,6 +138,7 @@ export class Field extends React.Component extends React.Component ); } else { - return ; + return ; } } render() { - const {disableInlineSizing, hasLabel = true, labelRatio = 33, field, showTooltip, i18nPrefix = "focus", theme} = this.props; - const {valueRatio = 100 - (hasLabel ? labelRatio : 0)} = this.props; - const {error, isEdit, $field: {comment, label, isRequired, domain: {className = "", LabelComponent = Label}}} = field as FormEntityField; return ( -
    - {hasLabel ? - - : null} -
    - {isEdit ? this.input() : this.display()} -
    -
    + + {theme => { + const {disableInlineSizing, hasLabel = true, labelRatio = 33, field, showTooltip, i18nPrefix = "focus"} = this.props; + const {valueRatio = 100 - (hasLabel ? labelRatio : 0)} = this.props; + const {error, isEdit, $field: {comment, label, isRequired, domain: {className = "", LabelComponent = Label}}} = field as FormEntityField; + + return ( +
    + {hasLabel ? + + : null} +
    + {isEdit ? this.input() : this.display()} +
    +
    + ); + }} +
    ); } } - -export default themr("field", styles)(Field); diff --git a/src/entity/field/utils.tsx b/src/entity/field/utils.tsx index 17400deb9..43fea96bc 100644 --- a/src/entity/field/utils.tsx +++ b/src/entity/field/utils.tsx @@ -3,11 +3,11 @@ import {upperFirst} from "lodash"; import {action} from "mobx"; import * as React from "react"; -import {Select, SelectProps} from "../../components"; +import {Select} from "../../components"; import {ReferenceList} from "../../reference"; import {EntityField, FieldEntry} from "../types"; -import Field, {FieldOptions} from "./field"; +import {Field, FieldOptions} from "./field"; /** * Crée un champ standard. @@ -20,18 +20,20 @@ export function fieldFor(field: EntityField, options: P return ; } +export type Props = T extends React.Component ? P1 : T extends (props: infer P2) => any ? P2 : never; + /** * Crée un champ avec résolution de référence. * @param field La définition de champ. * @param values La liste de référence. * @param options Les options du champ. */ -export function selectFor>( +export function selectFor( field: EntityField, values: ReferenceList, - options: Partial> = {} + options: Partial>> & {SelectComponent?: SComponent, selectProps?: Partial>} = {} ) { - options.SelectComponent = options.SelectComponent as any || Select; + options.SelectComponent = options.SelectComponent || Select as any; options.values = values; return fieldFor(field, options as Partial>); } diff --git a/src/entity/form/form.tsx b/src/entity/form/form.tsx index 9922a8422..22d3aebba 100644 --- a/src/entity/form/form.tsx +++ b/src/entity/form/form.tsx @@ -1,9 +1,11 @@ import * as PropTypes from "prop-types"; import * as React from "react"; -import {themr} from "react-css-themr"; + +import {themr} from "../../theme"; import * as styles from "./__style__/form.css"; export type FormStyle = Partial; +const Theme = themr("form", styles); /** Options additionnelles du Form. */ export interface FormProps { @@ -11,8 +13,8 @@ export interface FormProps { clean: () => void; /** Voir `FormActions` */ formContext: {forceErrorDisplay: boolean}; - /** Par défaut: true */ - hasForm?: boolean; + /** Retire le formulaire HTML */ + noForm?: boolean; /** Voir `FormActions` */ load: () => void; /** Voir `FormActions` */ @@ -38,21 +40,22 @@ export class Form extends React.Component { } render() { - const {hasForm = true, theme} = this.props; - if (hasForm) { + if (this.props.noForm) { return ( -
    { e.preventDefault(); this.props.save(); }} - > -
    {this.props.children}
    -
    + + {theme => +
    { e.preventDefault(); this.props.save(); }} + > +
    {this.props.children}
    +
    + } +
    ); } else { - return
    {this.props.children}
    ; + return this.props.children; } } } - -export default themr("form", styles)(Form); diff --git a/src/entity/form/index.ts b/src/entity/form/index.ts index cce038093..10828713f 100644 --- a/src/entity/form/index.ts +++ b/src/entity/form/index.ts @@ -1,3 +1,3 @@ export {ActionConfig, makeFormActions} from "./actions"; -export {default as Form, FormStyle} from "./form"; +export {Form, FormStyle} from "./form"; export {makeFormNode} from "./node"; diff --git a/src/layout/content.tsx b/src/layout/content.tsx index 9ac6fa786..ed91d8f35 100644 --- a/src/layout/content.tsx +++ b/src/layout/content.tsx @@ -1,13 +1,10 @@ -import {observer} from "mobx-react"; import * as PropTypes from "prop-types"; import * as React from "react"; -import {themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; -import {LayoutProps, styles} from "./types"; +import {LayoutProps, Theme} from "./types"; /** Contenu du Layout. */ -@observer export class LayoutContent extends React.Component { static contextTypes = {layout: PropTypes.object}; @@ -24,13 +21,14 @@ export class LayoutContent extends React.Component { } render() { - const {children, theme} = this.props; return ( -
    - {children} -
    + + {theme => +
    + {this.props.children} +
    + } +
    ); } } - -export default themr("layout", styles)(LayoutContent); diff --git a/src/layout/error-center.tsx b/src/layout/error-center.tsx index 0dda8150d..534c964e7 100644 --- a/src/layout/error-center.tsx +++ b/src/layout/error-center.tsx @@ -1,15 +1,15 @@ import {action, observable} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {IconButton} from "react-toolbox/lib/button"; import {FontIcon} from "react-toolbox/lib/font_icon"; import {getIcon} from "../components"; +import {themr} from "../theme"; import * as styles from "./__style__/error-center.css"; - export type ErrorCenterStyle = Partial; +const Theme = themr("errorCenter", styles); export interface ErrorCenterProps { /** Déploie le centre d'erreur à l'initialisation. */ @@ -44,38 +44,42 @@ export class ErrorCenter extends React.Component { } renderErrors() { - const {numberDisplayed = 3, i18nPrefix = "focus", theme} = this.props; + const {numberDisplayed = 3, i18nPrefix = "focus"} = this.props; const errorLength = this.errors.length; return ( -
    -
    - {getIcon(`${i18nPrefix}.icons.errorCenter.error`)}{errorLength} -
    -
    - { window.location.reload(); }} - theme={{icon: theme!.icon, toggle: theme!.toggle}} - /> - - this.errors = []} - theme={{icon: theme!.icon, toggle: theme!.toggle}} - /> -
    -
      - {this.areErrorsVisible ? - this.errors - .slice(errorLength - numberDisplayed, errorLength) - .map((e, i) =>
    • {e}
    • ) - : null} -
    -
    + + {theme => +
    +
    + {getIcon(`${i18nPrefix}.icons.errorCenter.error`)}{errorLength} +
    +
    + { window.location.reload(); }} + theme={{icon: theme.icon, toggle: theme.toggle}} + /> + + this.errors = []} + theme={{icon: theme.icon, toggle: theme.toggle}} + /> +
    +
      + {this.areErrorsVisible ? + this.errors + .slice(errorLength - numberDisplayed, errorLength) + .map((e, i) =>
    • {e}
    • ) + : null} +
    +
    + } +
    ); } @@ -83,5 +87,3 @@ export class ErrorCenter extends React.Component { return this.errors.length > 0 ? this.renderErrors() : null; } } - -export default themr("errorCenter", styles)(ErrorCenter); diff --git a/src/layout/footer.tsx b/src/layout/footer.tsx index ca398edf1..d38c15bdc 100644 --- a/src/layout/footer.tsx +++ b/src/layout/footer.tsx @@ -17,5 +17,3 @@ export class LayoutFooter extends React.Component { ); } } - -export default LayoutFooter; diff --git a/src/layout/header/actions.tsx b/src/layout/header/actions.tsx index 90e113e7a..7141614f6 100644 --- a/src/layout/header/actions.tsx +++ b/src/layout/header/actions.tsx @@ -1,13 +1,13 @@ -import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; import {Button, ButtonProps} from "react-toolbox/lib/button"; import {MenuItemProps} from "react-toolbox/lib/menu"; import Tooltip, {TooltipProps} from "react-toolbox/lib/tooltip"; import {ButtonMenu, getIcon, MenuItem} from "../../components"; +import {themr} from "../../theme"; import * as styles from "./__style__/header.css"; +const Theme = themr("header", styles); const TooltipButton = Tooltip(Button); @@ -38,31 +38,35 @@ export interface HeaderActionsProps { } /** Barre d'actions du header. */ -export const HeaderActions = observer(({i18nPrefix = "focus", primary = [], secondary = [], theme}: HeaderActionsProps) => ( -
    - {primary.map((action, i) => { - const FinalButton = action.tooltip ? TooltipButton : Button; - return ( - - ); - })} - {secondary.length > 0 ? - - {secondary.map((action, i) => )} - - : null} -
    -)); - -export default themr("header", styles)(HeaderActions); +export function HeaderActions({i18nPrefix = "focus", primary = [], secondary = [], theme: pTheme}: HeaderActionsProps) { + return ( + + {theme => +
    + {primary.map((action, i) => { + const FinalButton = action.tooltip ? TooltipButton : Button; + return ( + + ); + })} + {secondary.length > 0 ? + + {secondary.map((action, i) => )} + + : null} +
    + } +
    + ); +} diff --git a/src/layout/header/content.tsx b/src/layout/header/content.tsx index ed0c28464..77abd5ef6 100644 --- a/src/layout/header/content.tsx +++ b/src/layout/header/content.tsx @@ -1,9 +1,10 @@ -import {observer} from "mobx-react"; import * as PropTypes from "prop-types"; import * as React from "react"; -import {themr} from "react-css-themr"; + +import {themr} from "../../theme"; import * as styles from "./__style__/header.css"; +const Theme = themr("header", styles); /** Props du HeaderContent. */ export interface HeaderContentProps { @@ -14,20 +15,20 @@ export interface HeaderContentProps { } /** Contenu du header. n'est affiché que si le header est déplié. */ -@observer export class HeaderContent extends React.Component { static contextTypes = {layout: PropTypes.object}; context!: {layout: {menuWidth: number}}; render() { - const {children, theme} = this.props; return ( -
    - {children} -
    + + {theme => +
    + {this.props.children} +
    + } +
    ); } } - -export default themr("header", styles)(HeaderContent); diff --git a/src/layout/header/index.ts b/src/layout/header/index.ts index 71c71fd64..6ce6e2935 100644 --- a/src/layout/header/index.ts +++ b/src/layout/header/index.ts @@ -1,4 +1,4 @@ -export {default as HeaderActions , PrimaryAction, SecondaryAction} from "./actions"; -export {default as HeaderContent} from "./content"; -export {default as HeaderScrolling, HeaderStyle} from "./scrolling"; -export {default as HeaderTopRow, HeaderBarLeft, HeaderBarRight, HeaderSummary} from "./top-row"; +export {HeaderActions , PrimaryAction, SecondaryAction} from "./actions"; +export {HeaderContent} from "./content"; +export {HeaderScrolling, HeaderStyle} from "./scrolling"; +export {HeaderTopRow, HeaderBarLeft, HeaderBarRight, HeaderSummary} from "./top-row"; diff --git a/src/layout/header/scrolling.tsx b/src/layout/header/scrolling.tsx index f746deed2..0eb924f21 100644 --- a/src/layout/header/scrolling.tsx +++ b/src/layout/header/scrolling.tsx @@ -1,12 +1,11 @@ import {action, observable} from "mobx"; -import {observer} from "mobx-react"; import PropTypes from "prop-types"; import * as React from "react"; -import {themr} from "react-css-themr"; +import {themr} from "../../theme"; import * as styles from "./__style__/header.css"; - export type HeaderStyle = Partial; +const Theme = themr("header", styles); /** Props du conteneur de header. */ export interface HeaderScrollingProps { @@ -25,7 +24,6 @@ export interface HeaderScrollingProps { } /** Conteneur du header, gérant en particulier le dépliement et le repliement. */ -@observer export class HeaderScrolling extends React.Component { static contextTypes = { @@ -102,14 +100,15 @@ export class HeaderScrolling extends React.Component { } render() { - const {canDeploy, theme} = this.props; return ( -
    this.header = header} className={`${theme!.scrolling} ${this.isDeployed ? theme!.deployed : theme!.undeployed}`}> - {this.props.children} - {!this.isDeployed ?
    : null} -
    + + {theme => +
    this.header = header} className={`${theme.scrolling} ${this.isDeployed ? theme.deployed : theme.undeployed}`}> + {this.props.children} + {!this.isDeployed ?
    : null} +
    + } +
    ); } } - -export default themr("header", styles)(HeaderScrolling); diff --git a/src/layout/header/top-row/bar-left.tsx b/src/layout/header/top-row/bar-left.tsx index a6628b572..c0cc08db8 100644 --- a/src/layout/header/top-row/bar-left.tsx +++ b/src/layout/header/top-row/bar-left.tsx @@ -1,7 +1,9 @@ import * as React from "react"; -import {themr} from "react-css-themr"; + +import {themr} from "../../../theme"; import * as styles from "../__style__/header.css"; +const Theme = themr("header", styles); /** Props du HeaderBarLeft. */ export interface HeaderBarLeftProps { @@ -13,12 +15,14 @@ export interface HeaderBarLeftProps { } /** Barre du haut à gauche, doit être affiché dans `HeaderTopRow`. */ -export function HeaderBarLeft({children, theme}: HeaderBarLeftProps) { +export function HeaderBarLeft({children, theme: pTheme}: HeaderBarLeftProps) { return ( -
    - {children} -
    + + {theme => +
    + {children} +
    + } +
    ); } - -export default themr("header", styles)(HeaderBarLeft); diff --git a/src/layout/header/top-row/bar-right.tsx b/src/layout/header/top-row/bar-right.tsx index d08ea1bd6..d8b80e34e 100644 --- a/src/layout/header/top-row/bar-right.tsx +++ b/src/layout/header/top-row/bar-right.tsx @@ -1,7 +1,9 @@ import * as React from "react"; -import {themr} from "react-css-themr"; + +import {themr} from "../../../theme"; import * as styles from "../__style__/header.css"; +const Theme = themr("header", styles); /** Props du HeaderBarRight. */ export interface HeaderBarRightProps { @@ -13,12 +15,14 @@ export interface HeaderBarRightProps { } /** Barre du haut à droite, doit être affiché dans `HeaderTopRow`. */ -export function HeaderBarRight({children, theme}: HeaderBarRightProps) { +export function HeaderBarRight({children, theme: pTheme}: HeaderBarRightProps) { return ( -
    - {children} -
    + + {theme => +
    + {children} +
    + } +
    ); } - -export default themr("header", styles)(HeaderBarRight); diff --git a/src/layout/header/top-row/index.tsx b/src/layout/header/top-row/index.tsx index 83b826624..b0969a9ef 100644 --- a/src/layout/header/top-row/index.tsx +++ b/src/layout/header/top-row/index.tsx @@ -1,14 +1,15 @@ -import {observer} from "mobx-react"; import * as PropTypes from "prop-types"; import * as React from "react"; -import {themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; +import {themr} from "../../../theme"; + import * as styles from "../__style__/header.css"; +const Theme = themr("header", styles); -export {default as HeaderBarLeft} from "./bar-left"; -export {default as HeaderBarRight} from "./bar-right"; -export {default as HeaderSummary} from "./summary"; +export {HeaderBarLeft} from "./bar-left"; +export {HeaderBarRight} from "./bar-right"; +export {HeaderSummary} from "./summary"; /** Props du HeaderBarRight. */ export interface HeaderTopRowProps { @@ -19,7 +20,6 @@ export interface HeaderTopRowProps { } /** Barre du haut dans le header. */ -@observer export class HeaderTopRow extends React.Component { static contextTypes = { @@ -41,15 +41,16 @@ export class HeaderTopRow extends React.Component { } render() { - const {children, theme} = this.props; return ( -
    -
    - {children} -
    -
    + + {theme => +
    +
    + {this.props.children} +
    +
    + } +
    ); } } - -export default themr("header", styles)(HeaderTopRow); diff --git a/src/layout/header/top-row/summary.tsx b/src/layout/header/top-row/summary.tsx index b28bb32bc..bb3681b65 100644 --- a/src/layout/header/top-row/summary.tsx +++ b/src/layout/header/top-row/summary.tsx @@ -1,7 +1,9 @@ import * as React from "react"; -import {themr} from "react-css-themr"; + +import {themr} from "../../../theme"; import * as styles from "../__style__/header.css"; +const Theme = themr("header", styles); /** Props du HeaderSummary. */ export interface HeaderSummaryProps { @@ -13,12 +15,14 @@ export interface HeaderSummaryProps { } /** Summary, doit être affiché dans `HeaderTopRow` et est masqué si le header est replié. */ -export function HeaderSummary({children, theme}: HeaderSummaryProps) { +export function HeaderSummary({children, theme: pTheme}: HeaderSummaryProps) { return ( -
    - {children} -
    + + {theme => +
    + {children} +
    + } +
    ); } - -export default themr("header", styles)(HeaderSummary); diff --git a/src/layout/index.tsx b/src/layout/index.tsx index c9104d6e7..556bf82f2 100644 --- a/src/layout/index.tsx +++ b/src/layout/index.tsx @@ -2,10 +2,9 @@ import "!style-loader!css-loader!material-design-icons-iconfont/dist/material-de import {omit} from "lodash"; import {observable} from "mobx"; -import {observer} from "mobx-react"; import * as PropTypes from "prop-types"; import * as React from "react"; -import {ThemeProvider as LegacyThemeProvider, themr, TReactCSSThemrTheme} from "react-css-themr"; +import {ThemeProvider, TReactCSSThemrTheme} from "react-css-themr"; import {ButtonTheme} from "react-toolbox/lib/button"; import {CheckboxTheme} from "react-toolbox/lib/checkbox"; @@ -19,21 +18,18 @@ import {AutocompleteStyle, BooleanRadioStyle, ButtonBackToTopStyle, DisplayStyle import {FieldStyle, FormStyle} from "../entity"; import {MessageCenter} from "../message"; import {LoadingBarStyle} from "../network"; -import {ThemeProvider} from "../theme"; -import ErrorCenter, {ErrorCenterStyle} from "./error-center"; +import {ErrorCenter, ErrorCenterStyle} from "./error-center"; import {HeaderStyle} from "./header"; import {MainMenuStyle} from "./menu"; -import {LayoutProps, LayoutStyle, styles} from "./types"; +import {LayoutProps, LayoutStyle, Theme} from "./types"; -export {default as LayoutContent} from "./content"; -export {default as LayoutFooter} from "./footer"; +export {LayoutContent} from "./content"; +export {LayoutFooter} from "./footer"; export {HeaderActions, HeaderBarLeft, HeaderBarRight, HeaderContent, HeaderScrolling, HeaderSummary, HeaderTopRow, PrimaryAction, SecondaryAction} from "./header"; -export {default as MainMenu, MainMenuItem} from "./menu"; +export {MainMenu, MainMenuItem} from "./menu"; /** Composant de Layout sans le provider de style. */ -@themr("layout", styles) -@observer class LayoutBase extends React.Component { // On utilise le contexte React pour partager la taille du menu et du header. @@ -62,13 +58,16 @@ class LayoutBase extends React.Component { } render() { - const {children, theme} = this.props; return ( -
    - - - {children} -
    + + {theme => +
    + + + {this.props.children} +
    + } +
    ); } } @@ -122,12 +121,10 @@ export interface LayoutStyleProviderProps { */ export function Layout(props: LayoutProps & {appTheme?: LayoutStyleProviderProps}) { return ( - - - - {props.children} - - + + + {props.children} + ); } diff --git a/src/layout/menu/index.tsx b/src/layout/menu/index.tsx index e290fa631..d5fda17b4 100644 --- a/src/layout/menu/index.tsx +++ b/src/layout/menu/index.tsx @@ -1,20 +1,20 @@ import {action, computed, observable} from "mobx"; -import {observer} from "mobx-react"; import PropTypes from "prop-types"; import * as React from "react"; -import {themr} from "react-css-themr"; import {findDOMNode} from "react-dom"; import {IconButtonTheme} from "react-toolbox/lib/button"; -import MainMenuItem, {MainMenuItemProps} from "./item"; -import MainMenuList, {MainMenuListStyle} from "./list"; -import MainMenuPanel, {MainMenuPanelStyle} from "./panel"; +import {themr} from "../../theme"; + +import {MainMenuItem, MainMenuItemProps} from "./item"; +import {MainMenuList, MainMenuListStyle} from "./list"; +import {MainMenuPanel, MainMenuPanelStyle} from "./panel"; export {MainMenuItem}; import * as styles from "./__style__/menu.css"; - export type MainMenuStyle = Partial & IconButtonTheme; +const Theme = themr("mainMenu", styles); /** Props du Menu. */ export interface MainMenuProps { @@ -23,7 +23,6 @@ export interface MainMenuProps { } /** Composant de menu, à instancier soi-même avec les items que l'on veut dedans. */ -@observer export class MainMenu extends React.Component { static contextTypes = { @@ -72,33 +71,35 @@ export class MainMenu extends React.Component { } render() { - const {activeRoute, theme} = this.props; + const {activeRoute, children} = this.props; return ( - + + {theme => + + } + ); } } - -export default themr("mainMenu", styles)(MainMenu); diff --git a/src/layout/menu/item.tsx b/src/layout/menu/item.tsx index 05b300755..fbdfe9e94 100644 --- a/src/layout/menu/item.tsx +++ b/src/layout/menu/item.tsx @@ -1,10 +1,10 @@ -import {observer} from "mobx-react"; import * as React from "react"; -import {themr} from "react-css-themr"; - import {Button, ButtonProps, IconButton, IconButtonTheme} from "react-toolbox/lib/button"; +import {themr} from "../../theme"; + import * as styles from "./__style__/menu.css"; +const Theme = themr("mainMenu", styles); /** Props du MenuItem. */ export interface MainMenuItemProps extends ButtonProps { @@ -15,28 +15,34 @@ export interface MainMenuItemProps extends ButtonProps { } /** Elément de menu. */ -export const MainMenuItem = observer((props: MainMenuItemProps) => { - const {label, icon, onClick, route, theme, children, ...otherProps} = props; +export function MainMenuItem(props: MainMenuItemProps) { + const {label, icon, onClick, route, children, ...otherProps} = props; if (label) { return ( -
    ); } @@ -205,7 +215,7 @@ export class ActionBar extends React.Component> { @classReaction>(that => () => { // tslint:disable-next-line:no-shadowed-variable const {hasFacetBox, store} = that.props; - return hasFacetBox && isSearch(store) && store.facets.length && store.facets[0] || false; + return (hasFacetBox && isSearch(store) && store.facets.length && store.facets[0]) || false; }) protected closeFacetBox() { const {store, showSingleValuedFacets} = this.props; @@ -216,7 +226,11 @@ export class ActionBar extends React.Component> { } // On ferme la FacetBox si on se rend compte qu'on va afficher une FacetBox vide. - if (this.displayFacetBox && isSearch(store) && store.facets.every(facet => !shouldDisplayFacet(facet, store.selectedFacets, showSingleValuedFacets))) { + if ( + this.displayFacetBox && + isSearch(store) && + store.facets.every(facet => !shouldDisplayFacet(facet, store.selectedFacets, showSingleValuedFacets)) + ) { this.displayFacetBox = false; } } @@ -237,39 +251,59 @@ export class ActionBar extends React.Component> { } render() { - const {group, hasFacetBox, i18nPrefix = "focus", nbDefaultDataListFacet = 6, operationList, showSingleValuedFacets, store} = this.props; + const { + group, + hasFacetBox, + i18nPrefix = "focus", + nbDefaultDataListFacet = 6, + operationList, + showSingleValuedFacets, + store + } = this.props; return ( - {theme => + {theme => (
    {/* ActionBar en tant que telle. */}
    {this.selectionButton(theme)} - {group ? - {`${i18next.t(group.label)} (${group.totalCount})`} - : null} - {store.selectedItems.size ? - {`${store.selectedItems.size} ${i18next.t(`${i18nPrefix}.search.action.selectedItem${store.selectedItems.size > 1 ? "s" : ""}`)}`} - : null} + {group ? {`${i18next.t(group.label)} (${group.totalCount})`} : null} + {store.selectedItems.size ? ( + {`${store.selectedItems.size} ${i18next.t( + `${i18nPrefix}.search.action.selectedItem${ + store.selectedItems.size > 1 ? "s" : "" + }` + )}`} + ) : null} {this.filterButton(theme)} {this.sortButton(theme)} {this.groupButton(theme)} {this.searchBar(theme)}
    - {store.selectedItems.size && operationList && operationList.length ? - - : null} + {store.selectedItems.size && operationList && operationList.length ? ( + + ) : null}
    {/* FacetBox */} - {hasFacetBox && isSearch(store) ? + {hasFacetBox && isSearch(store) ? (
    - + {(style: {marginTop?: number}) => ( -
    this.facetBox = i}> +
    (this.facetBox = i)}> this.displayFacetBox = false} + onClick={() => (this.displayFacetBox = false)} /> extends React.Component> { )}
    - : null} + ) : null}
    - } + )} ); } diff --git a/src/collections/components/list/contextual-actions.tsx b/src/collections/components/list/contextual-actions.tsx index 56c52d3d5..799ed0e91 100644 --- a/src/collections/components/list/contextual-actions.tsx +++ b/src/collections/components/list/contextual-actions.tsx @@ -21,18 +21,20 @@ export interface OperationListItemComponentProps { } /** Description d'une action sur un ou plusieurs éléments de liste. */ -export type OperationListItem = { - /** L'action à effectuer. */ - action: (data: T) => void; - /** Le libellé du bouton. */ - label?: string; - /** L'icône du bouton */ - icon?: React.ReactNode; - /** Précise si l'action est secondaire (sera affichée dans une dropdown au lieu de son propre bouton) */ - isSecondary?: boolean; - /** Force l'affichage de l'icône en vue liste (elle est toujours visible en mosaïque) */ - showIcon?: boolean; -} | React.ComponentType>; +export type OperationListItem = + | { + /** L'action à effectuer. */ + action: (data: T) => void; + /** Le libellé du bouton. */ + label?: string; + /** L'icône du bouton */ + icon?: React.ReactNode; + /** Précise si l'action est secondaire (sera affichée dans une dropdown au lieu de son propre bouton) */ + isSecondary?: boolean; + /** Force l'affichage de l'icône en vue liste (elle est toujours visible en mosaïque) */ + showIcon?: boolean; + } + | React.ComponentType>; /** Props du composant d'actions contextuelles. */ export interface ContextualActionsProps { @@ -54,7 +56,6 @@ export interface ContextualActionsProps { /** Affiche une liste d'actions contextuelles. */ export class ContextualActions extends React.Component { - /** * Exécute une action * @param key L'index de l'action dans la liste. @@ -73,38 +74,45 @@ export class ContextualActions extends React.Component { render() { const {data, operationList, i18nPrefix = "focus", isMosaic, onClickMenu, onHideMenu} = this.props; - const lists = operationList.reduce((actionLists, Operation, key) => { - const {customComponents, primaryActions, secondaryActions} = actionLists; - if (isComponent(Operation)) { - customComponents.push(); - } else if (!Operation.isSecondary) { - primaryActions.push( -
    ); } else { @@ -136,7 +139,7 @@ export abstract class ListBase> extends React.Comp private scrollListener() { const el = findDOMNode(this) as HTMLElement; const scrollTop = window.pageYOffset; - if (topOfElement(el) + el.offsetHeight - scrollTop - (window.innerHeight) < (this.props.offset || 250)) { + if (topOfElement(el) + el.offsetHeight - scrollTop - window.innerHeight < (this.props.offset || 250)) { this.handleShowMore(); } } @@ -150,5 +153,5 @@ function topOfElement(element: HTMLElement): number { if (!element) { return 0; } - return element.offsetTop + topOfElement((element.offsetParent as HTMLElement)); + return element.offsetTop + topOfElement(element.offsetParent as HTMLElement); } diff --git a/src/collections/components/list/list-wrapper.tsx b/src/collections/components/list/list-wrapper.tsx index db498ffa0..956198826 100644 --- a/src/collections/components/list/list-wrapper.tsx +++ b/src/collections/components/list/list-wrapper.tsx @@ -39,26 +39,28 @@ export interface ListWrapperProps { /** Wrapper de liste permettant de partager le mode d'affichage de toutes les listes qu'il contient. */ @observer export class ListWrapper extends React.Component { - // On utilise le contexte React pour partager le mode entre les listes. static childContextTypes = { listWrapper: PropTypes.object }; /** Objet passé en contexte pour les listes contenues dans le wrapper. */ - childContext = observable({ - /** Handler au clic sur le bouton "Ajouter". */ - addItemHandler: this.props.addItemHandler, - /** Taile des mosaïques. */ - mosaic: { - width: this.props.mosaicWidth || 200, - height: this.props.mosaicHeight || 200 + childContext = observable( + { + /** Handler au clic sur le bouton "Ajouter". */ + addItemHandler: this.props.addItemHandler, + /** Taile des mosaïques. */ + mosaic: { + width: this.props.mosaicWidth || 200, + height: this.props.mosaicHeight || 200 + }, + /** Mode des listes. */ + mode: this.props.mode || "list" }, - /** Mode des listes. */ - mode: this.props.mode || "list" - }, { - addItemHandler: observable.ref - }); + { + addItemHandler: observable.ref + } + ); // On met à jour l'objet passé en contexte à chaque fois qu'on change les props du composant. @action @@ -86,36 +88,36 @@ export class ListWrapper extends React.Component { const {mode, addItemHandler} = this.childContext; return ( - {theme => + {theme => (
    - {canChangeMode ? + {canChangeMode ? ( this.childContext.mode = "list"} + onClick={() => (this.childContext.mode = "list")} icon={getIcon(`${i18nPrefix}.icons.listWrapper.list`)} tooltip={i18next.t(`${i18nPrefix}.list.mode.list`)} /> - : null} - {canChangeMode ? + ) : null} + {canChangeMode ? ( this.childContext.mode = "mosaic"} + onClick={() => (this.childContext.mode = "mosaic")} icon={getIcon(`${i18nPrefix}.icons.listWrapper.mosaic`)} tooltip={i18next.t(`${i18nPrefix}.list.mode.mosaic`)} /> - : null} - {!hideAddItemHandler && addItemHandler && mode === "list" ? + ) : null} + {!hideAddItemHandler && addItemHandler && mode === "list" ? (
    {children}
    - } + )}
    ); } diff --git a/src/collections/components/list/list.tsx b/src/collections/components/list/list.tsx index a8a0b46ef..ad061e8f0 100644 --- a/src/collections/components/list/list.tsx +++ b/src/collections/components/list/list.tsx @@ -65,7 +65,7 @@ export interface ListProps extends ListBaseProps { /** Mode des listes dans le wrapper. Par défaut : celui du composant fourni, ou "list". */ mode?: "list" | "mosaic"; /** Taille de la mosaïque. */ - mosaic?: {width: number, height: number}; + mosaic?: {width: number; height: number}; /** Composant de mosaïque. */ MosaicComponent?: React.ComponentType>; /** La liste des actions sur chaque élément de la liste. */ @@ -78,8 +78,8 @@ export interface LineItem

    { key: string; /** Description du composant, avec ses props. */ data?: { - Component: React.ComponentType

    , - props?: P + Component: React.ComponentType

    ; + props?: P; }; /** Style interpolé (ou pas) par react-motion. */ style: Style; @@ -88,7 +88,6 @@ export interface LineItem

    { /** Composant de liste standard */ @observer export class List = ListProps & {data: T[]}> extends ListBase { - // On récupère les infos du ListWrapper dans le contexte. static contextTypes = { listWrapper: PropTypes.object @@ -100,9 +99,9 @@ export class List = ListProps & {data: T[]}> extend mosaic: { width: number; height: number; - }, + }; mode: "list" | "mosaic"; - } + }; }; /** Nombre de mosaïque par ligne, déterminé à la volée. */ @@ -114,7 +113,9 @@ export class List = ListProps & {data: T[]}> extend private readonly draggedItems = observable([]); /** LineWrapper avec la DragSource, pour une liste avec drag and drop. */ - private readonly DraggableLineWrapper = this.props.hasDragAndDrop ? addDragSource(this.props.dragItemType || "item", LineWrapper) : undefined; + private readonly DraggableLineWrapper = this.props.hasDragAndDrop + ? addDragSource(this.props.dragItemType || "item", LineWrapper) + : undefined; // Tuyauterie pour maintenir `byLine` à jour. componentDidMount() { @@ -138,19 +139,19 @@ export class List = ListProps & {data: T[]}> extend if (node) { this.byLine = this.mode === "mosaic" ? Math.floor(node.clientWidth / (this.mosaic.width + 10)) : 1; } - } + }; /** Réaction pour fermer le détail si la liste change. */ @classReaction((that: List) => () => that.displayedData.length) protected readonly closeDetail = () => { this.displayedIdx = undefined; - } + }; /** Handler d'ajout d'élément (fusion contexte / props). */ @computed protected get addItemHandler() { const {listWrapper} = this.context; - return this.props.addItemHandler || listWrapper && listWrapper.addItemHandler; + return this.props.addItemHandler || (listWrapper && listWrapper.addItemHandler); } /** Mode (fusion contexte / props). */ @@ -158,14 +159,14 @@ export class List = ListProps & {data: T[]}> extend protected get mode() { const {mode, MosaicComponent, LineComponent} = this.props; const {listWrapper} = this.context; - return mode || listWrapper && listWrapper.mode || MosaicComponent && !LineComponent && "mosaic" || "list"; + return mode || (listWrapper && listWrapper.mode) || (MosaicComponent && !LineComponent && "mosaic") || "list"; } /** Taille de la mosaïque (fusion contexte / props). */ @computed protected get mosaic() { const {listWrapper} = this.context; - return this.props.mosaic || listWrapper && listWrapper.mosaic || {width: 200, height: 200}; + return this.props.mosaic || (listWrapper && listWrapper.mosaic) || {width: 200, height: 200}; } /** Les données. */ @@ -198,25 +199,25 @@ export class List = ListProps & {data: T[]}> extend const {canOpenDetail = () => true, i18nPrefix, itemKey, lineTheme, operationList, hasDragAndDrop} = this.props; return this.displayedData.map((item, idx) => ({ - // On essaie de couvrir toutes les possibilités pour la clé, en tenant compte du faite qu'on a potentiellement une liste de StoreNode. - key: `${itemKey && item[itemKey] && (item[itemKey] as any).value || itemKey && item[itemKey] || idx}`, - data: { - Component: this.DraggableLineWrapper || LineWrapper, - props: { - data: item, - disableDragAnimation: this.disableDragAnimation, - draggedItems: hasDragAndDrop ? this.draggedItems : undefined, - i18nPrefix, - mosaic: this.mode === "mosaic" ? this.mosaic : undefined, - LineComponent: Component, - openDetail: canOpenDetail(item) ? () => this.onLineClick(idx) : undefined, - operationList, - theme: lineTheme - } - }, - // Masque l'élément s'il est en train d'être déplacé par le drag and drop. - style: {opacity: this.draggedItems.find(i => i === item) ? 0 : 1} - })); + // On essaie de couvrir toutes les possibilités pour la clé, en tenant compte du faite qu'on a potentiellement une liste de StoreNode. + key: `${(itemKey && item[itemKey] && (item[itemKey] as any).value) || (itemKey && item[itemKey]) || idx}`, + data: { + Component: this.DraggableLineWrapper || LineWrapper, + props: { + data: item, + disableDragAnimation: this.disableDragAnimation, + draggedItems: hasDragAndDrop ? this.draggedItems : undefined, + i18nPrefix, + mosaic: this.mode === "mosaic" ? this.mosaic : undefined, + LineComponent: Component, + openDetail: canOpenDetail(item) ? () => this.onLineClick(idx) : undefined, + operationList, + theme: lineTheme + } + }, + // Masque l'élément s'il est en train d'être déplacé par le drag and drop. + style: {opacity: this.draggedItems.find(i => i === item) ? 0 : 1} + })); } /** @@ -263,7 +264,7 @@ export class List = ListProps & {data: T[]}> extend data: { Component: ({style: {height}}: {style: {height: number}}) => ( - {theme => + {theme => (

  • = ListProps & {data: T[]}> extend }} > {/* Le calcul de la position du triangle en mosaïque n'est pas forcément évident... et il suppose qu'on ne touche pas au marges par défaut entre les mosaïques. */} -
    +
  • - } + )} ) }, @@ -293,7 +310,7 @@ export class List = ListProps & {data: T[]}> extend data: { Component: () => ( - {theme => + {theme => (
    = ListProps & {data: T[]}> extend {getIcon(`${i18nPrefix}.icons.list.add`)} {i18next.t(`${i18nPrefix}.list.add`)}
    - } + )}
    ) }, @@ -315,21 +332,24 @@ export class List = ListProps & {data: T[]}> extend render() { const {dragLayerTheme, EmptyComponent, hasDragAndDrop, hideAdditionalItems, i18nPrefix = "focus"} = this.props; - return !hideAdditionalItems && !this.displayedData.length && EmptyComponent ? + return !hideAdditionalItems && !this.displayedData.length && EmptyComponent ? ( - : !hideAdditionalItems && !this.displayedData.length ? + ) : !hideAdditionalItems && !this.displayedData.length ? (
    {i18next.t(`${i18nPrefix}.list.empty`)}
    - : ( + ) : ( - {theme => + {theme => ( <> - {!navigator.userAgent.match(/Trident/) && hasDragAndDrop ? : null} + {!navigator.userAgent.match(/Trident/) && hasDragAndDrop ? ( + + ) : null}
    ({height: 0, opacity: 1})} willLeave={({style}: {style: Style}) => { // Est appelé au retrait d'un élément de la liste. - if (style.height) { // `height` n'existe que pour le détail + if (style.height) { + // `height` n'existe que pour le détail return {height: spring(0)}; // On ajoute l'animation de fermeture. } return undefined; // Pour les autres éléments, on les retire immédiatement. @@ -338,14 +358,16 @@ export class List = ListProps & {data: T[]}> extend > {(items: LineItem[]) => (
      - {items.map(({key, style, data: {Component = {} as any, props = {}} = {}}) => )} + {items.map(({key, style, data: {Component = {} as any, props = {}} = {}}) => ( + + ))}
    )}
    {this.renderBottomRow(theme)}
    - } + )}
    ); } diff --git a/src/collections/components/list/store-list.tsx b/src/collections/components/list/store-list.tsx index 97b718449..e169cf162 100644 --- a/src/collections/components/list/store-list.tsx +++ b/src/collections/components/list/store-list.tsx @@ -20,7 +20,6 @@ export interface StoreListProps extends ListProps { /** Composant de liste lié à un store, qui permet la sélection de ses éléments. */ @observer export class StoreList extends List> { - /** Les données. */ @computed protected get data() { @@ -36,7 +35,7 @@ export class StoreList extends List> { /** Correspond aux données chargées mais non affichées. */ @computed private get hasMoreHidden() { - return this.displayedCount && this.data.length > this.displayedCount || false; + return (this.displayedCount && this.data.length > this.displayedCount) || false; } /** Correpond aux données non chargées. */ @@ -59,7 +58,9 @@ export class StoreList extends List> { if (isSearch(store)) { return i18next.t(`${i18nPrefix}.list.show.more`); } else { - return `${i18next.t(`${i18nPrefix}.list.show.more`)} (${this.displayedData.length} / ${this.data.length} ${i18next.t(`${i18nPrefix}.list.show.displayed`)})`; + return `${i18next.t(`${i18nPrefix}.list.show.more`)} (${this.displayedData.length} / ${ + this.data.length + } ${i18next.t(`${i18nPrefix}.list.show.displayed`)})`; } } @@ -69,19 +70,18 @@ export class StoreList extends List> { */ protected getItems(Component: React.ComponentType>) { const {hasSelection = false, store} = this.props; - return super.getItems(Component) - .map(({key, data, style}) => ({ - key, - data: { - Component: data!.Component, - props: { - ...data!.props, - hasSelection, - store - } - }, - style - })) as LineItem>[]; + return super.getItems(Component).map(({key, data, style}) => ({ + key, + data: { + Component: data!.Component, + props: { + ...data!.props, + hasSelection, + store + } + }, + style + })) as LineItem>[]; } /** `handleShowMore` peut aussi appeler le serveur pour récupérer les résultats suivants, si c'est un SearchStore. */ diff --git a/src/collections/components/list/store-table.tsx b/src/collections/components/list/store-table.tsx index 78f894520..6d9046e8e 100644 --- a/src/collections/components/list/store-table.tsx +++ b/src/collections/components/list/store-table.tsx @@ -22,7 +22,6 @@ export interface StoreTableProps extends TableProps { /** Composant de tableau lié à un store, qui permet le tri de ses colonnes. */ @observer export class StoreTable extends Table> { - /** Les données. */ @computed protected get data() { @@ -38,7 +37,7 @@ export class StoreTable extends Table> { /** Correspond aux données chargées mais non affichées. */ @computed private get hasMoreHidden() { - return this.displayedCount && this.data.length > this.displayedCount || false; + return (this.displayedCount && this.data.length > this.displayedCount) || false; } /** Correpond aux données non chargées. */ @@ -61,38 +60,50 @@ export class StoreTable extends Table> { if (isSearch(store)) { return i18next.t(`${i18nPrefix}.list.show.more`); } else { - return `${i18next.t(`${i18nPrefix}.list.show.more`)} (${this.displayedData.length} / ${this.data.length} ${i18next.t(`${i18nPrefix}.list.show.displayed`)})`; + return `${i18next.t(`${i18nPrefix}.list.show.more`)} (${this.displayedData.length} / ${ + this.data.length + } ${i18next.t(`${i18nPrefix}.list.show.displayed`)})`; } } /** On modifie le header pour y ajouter les boutons de tri. */ protected renderTableHeader() { - const {columns, i18nPrefix = "focus", sortableColumns = [], store: {sortAsc, sortBy}} = this.props; + const { + columns, + i18nPrefix = "focus", + sortableColumns = [], + store: {sortAsc, sortBy} + } = this.props; return ( - {Object.keys(columns) - .map(col => ( - -
    c === col) ? -3 : 0}}> -
    {i18next.t(columns[col])}
    - {sortableColumns.find(c => c === col) ? -
    - this.sort(col, true)} - icon={getIcon(`${i18nPrefix}.icons.table.sortAsc`)} - /> - this.sort(col, false)} - icon={getIcon(`${i18nPrefix}.icons.table.sortDesc`)} - /> -
    - : null} -
    - - ))} + {Object.keys(columns).map(col => ( + +
    c === col) ? -3 : 0 + }} + > +
    {i18next.t(columns[col])}
    + {sortableColumns.find(c => c === col) ? ( +
    + this.sort(col, true)} + icon={getIcon(`${i18nPrefix}.icons.table.sortAsc`)} + /> + this.sort(col, false)} + icon={getIcon(`${i18nPrefix}.icons.table.sortDesc`)} + /> +
    + ) : null} +
    + + ))} ); diff --git a/src/collections/components/list/table.tsx b/src/collections/components/list/table.tsx index 0e9acd939..98db0cbdc 100644 --- a/src/collections/components/list/table.tsx +++ b/src/collections/components/list/table.tsx @@ -22,7 +22,6 @@ export interface TableProps extends ListBaseProps { /** Tableau standard */ @observer export class Table = TableProps & {data: T[]}> extends ListBase { - /** Les données. */ protected get data() { return (this.props as any).data || []; @@ -32,13 +31,7 @@ export class Table = TableProps & {data: T[]}> ext protected renderTableHeader() { return ( - - {values(this.props.columns) - .map(col => ( - {i18next.t(col)} - )) - } - + {values(this.props.columns).map(col => {i18next.t(col)})} ); } @@ -46,13 +39,17 @@ export class Table = TableProps & {data: T[]}> ext /** Affiche le corps du tableau. */ private renderTableBody() { const {lineTheme, itemKey, RowComponent} = this.props; - const Line = LineWrapper as new() => LineWrapper; + const Line = LineWrapper as new () => LineWrapper; return ( {this.displayedData.map((item, idx) => ( = TableProps & {data: T[]}> ext render() { return ( - {theme => + {theme => ( <> {this.renderTableHeader()} @@ -74,7 +71,7 @@ export class Table = TableProps & {data: T[]}> ext
    {this.renderBottomRow(theme)} - } + )}
    ); } diff --git a/src/collections/components/list/timeline.tsx b/src/collections/components/list/timeline.tsx index ce28fad4e..339416424 100644 --- a/src/collections/components/list/timeline.tsx +++ b/src/collections/components/list/timeline.tsx @@ -23,34 +23,33 @@ export interface TimelineProps extends ListBaseProps { /** Composant affichant une liste sous forme de Timeline. */ @observer export class Timeline extends ListBase> { - get data() { return this.props.data; } private renderLines() { const {lineTheme, itemKey, TimelineComponent, dateSelector} = this.props; - return this.displayedData.map((item, idx) => + return this.displayedData.map((item, idx) => ( - ); + )); } render() { return ( - {theme => + {theme => (
      {this.renderLines()} {this.renderBottomRow(theme)}
    - } + )}
    ); } diff --git a/src/collections/components/search/advanced-search.tsx b/src/collections/components/search/advanced-search.tsx index 017e89cba..b223433bb 100644 --- a/src/collections/components/search/advanced-search.tsx +++ b/src/collections/components/search/advanced-search.tsx @@ -5,7 +5,18 @@ import {ButtonBackToTop} from "../../../components"; import {themr} from "../../../theme"; import {GroupResult, SearchStore} from "../../store"; -import {ActionBar, ActionBarStyle, DetailProps, DragLayerStyle, EmptyProps, LineProps, LineStyle, ListStyle, ListWrapper, OperationListItem} from "../list"; +import { + ActionBar, + ActionBarStyle, + DetailProps, + DragLayerStyle, + EmptyProps, + LineProps, + LineStyle, + ListStyle, + ListWrapper, + OperationListItem +} from "../list"; import {FacetBox, FacetBoxStyle} from "./facet-box"; import {GroupStyle, Results} from "./results"; import {Summary, SummaryStyle} from "./summary"; @@ -97,7 +108,7 @@ export interface AdvancedSearchProps { /** La liste des actions globales. */ operationList?: OperationListItem[]; /** Liste des colonnes sur lesquels on peut trier. */ - orderableColumnList?: {key: string, label: string, order: boolean}[]; + orderableColumnList?: {key: string; label: string; order: boolean}[]; /** Placeholder pour la barre de recherche de l'ActionBar. */ searchBarPlaceholder?: string; /** Lance la recherche à la construction du composant. Par défaut: true. */ @@ -117,7 +128,6 @@ export interface AdvancedSearchProps { /** Composant tout intégré pour une recherche avancée, avec ActionBar, FacetBox, Summary, ListWrapper et Results. */ @observer export class AdvancedSearch extends React.Component> { - componentWillMount() { const {searchOnMount = true, store} = this.props; if (searchOnMount) { @@ -126,11 +136,18 @@ export class AdvancedSearch extends React.Component> { } protected renderFacetBox(theme: AdvancedSearchStyle) { - const {facetBoxPosition = "left", facetBoxTheme, i18nPrefix, nbDefaultDataListFacet, showSingleValuedFacets, store} = this.props; + const { + facetBoxPosition = "left", + facetBoxTheme, + i18nPrefix, + nbDefaultDataListFacet, + showSingleValuedFacets, + store + } = this.props; if (facetBoxPosition === "left") { return ( -
    +
    extends React.Component> { } protected renderListSummary() { - const {canRemoveSort, hideSummaryCriteria, hideSummaryFacets, hideSummaryGroup, hideSummarySort, i18nPrefix, orderableColumnList, store, summaryTheme} = this.props; + const { + canRemoveSort, + hideSummaryCriteria, + hideSummaryFacets, + hideSummaryGroup, + hideSummarySort, + i18nPrefix, + orderableColumnList, + store, + summaryTheme + } = this.props; return ( extends React.Component> { } protected renderActionBar() { - const {actionBarTheme, facetBoxPosition = "left", hasGrouping, hasSearchBar, hasSelection, i18nPrefix, operationList, orderableColumnList, nbDefaultDataListFacet, showSingleValuedFacets, searchBarPlaceholder, store, useGroupActionBars} = this.props; + const { + actionBarTheme, + facetBoxPosition = "left", + hasGrouping, + hasSearchBar, + hasSelection, + i18nPrefix, + operationList, + orderableColumnList, + nbDefaultDataListFacet, + showSingleValuedFacets, + searchBarPlaceholder, + store, + useGroupActionBars + } = this.props; if (store.groups.length && useGroupActionBars) { return null; @@ -188,7 +229,33 @@ export class AdvancedSearch extends React.Component> { } protected renderResults() { - const {groupTheme, GroupHeader, listTheme, lineTheme, groupOperationList, groupPageSize, hasSelection, disableDragAnimThreshold, i18nPrefix, isManualFetch, itemKey, LineComponent, lineOperationList, listPageSize, MosaicComponent, offset, store, EmptyComponent, DetailComponent, detailHeight, canOpenDetail, hasDragAndDrop, dragItemType, dragLayerTheme, useGroupActionBars} = this.props; + const { + groupTheme, + GroupHeader, + listTheme, + lineTheme, + groupOperationList, + groupPageSize, + hasSelection, + disableDragAnimThreshold, + i18nPrefix, + isManualFetch, + itemKey, + LineComponent, + lineOperationList, + listPageSize, + MosaicComponent, + offset, + store, + EmptyComponent, + DetailComponent, + detailHeight, + canOpenDetail, + hasDragAndDrop, + dragItemType, + dragLayerTheme, + useGroupActionBars + } = this.props; return ( extends React.Component> { } render() { - const {addItemHandler, i18nPrefix, LineComponent, MosaicComponent, mode, mosaicHeight, mosaicWidth, hasBackToTop = true} = this.props; + const { + addItemHandler, + i18nPrefix, + LineComponent, + MosaicComponent, + mode, + mosaicHeight, + mosaicWidth, + hasBackToTop = true + } = this.props; return ( - {theme => + {theme => ( <> {this.renderFacetBox(theme)}
    @@ -232,7 +308,7 @@ export class AdvancedSearch extends React.Component> { addItemHandler={addItemHandler} canChangeMode={!!(LineComponent && MosaicComponent)} i18nPrefix={i18nPrefix} - mode={mode || MosaicComponent && !LineComponent ? "mosaic" : "list"} + mode={mode || (MosaicComponent && !LineComponent) ? "mosaic" : "list"} mosaicHeight={mosaicHeight} mosaicWidth={mosaicWidth} > @@ -243,7 +319,7 @@ export class AdvancedSearch extends React.Component> {
    {hasBackToTop ? : null} - } + )}
    ); } diff --git a/src/collections/components/search/facet-box/facet.tsx b/src/collections/components/search/facet-box/facet.tsx index 4f0e5b94e..20d9748f8 100644 --- a/src/collections/components/search/facet-box/facet.tsx +++ b/src/collections/components/search/facet-box/facet.tsx @@ -31,7 +31,6 @@ export interface FacetProps { /** Composant affichant le détail d'une facette avec ses valeurs. */ @observer export class Facet extends React.Component { - @observable protected isShowAll = false; protected renderFacetDataList(theme: FacetStyle) { @@ -62,7 +61,9 @@ export class Facet extends React.Component { }; return (
  • - {facet.isMultiSelectable ? : null} + {facet.isMultiSelectable ? ( + + ) : null}
    {i18next.t(sfv.label)}
    {sfv.count}
  • @@ -77,7 +78,7 @@ export class Facet extends React.Component { const {facet, i18nPrefix = "focus", nbDefaultDataList} = this.props; if (facet.values.length > nbDefaultDataList) { return ( -
    this.isShowAll = !this.isShowAll}> +
    (this.isShowAll = !this.isShowAll)}> {i18next.t(this.isShowAll ? `${i18nPrefix}.list.show.less` : `${i18nPrefix}.list.show.all`)}
    ); @@ -90,13 +91,13 @@ export class Facet extends React.Component { const {facet} = this.props; return ( - {theme => + {theme => (

    {i18next.t(facet.label)}

    {this.renderFacetDataList(theme)} {this.renderShowAllDataList(theme)}
    - } + )}
    ); } diff --git a/src/collections/components/search/facet-box/index.tsx b/src/collections/components/search/facet-box/index.tsx index 485f1c256..13d317047 100644 --- a/src/collections/components/search/facet-box/index.tsx +++ b/src/collections/components/search/facet-box/index.tsx @@ -30,15 +30,15 @@ export interface FacetBoxProps { /** Composant contenant la liste des facettes retournées par une recherche. */ @observer export class FacetBox extends React.Component> { - render() { const {i18nPrefix = "focus", nbDefaultDataList = 6, showSingleValuedFacets, store} = this.props; return ( - {theme => + {theme => (

    {i18next.t(`${i18nPrefix}.search.facets.title`)}

    - {store.facets.filter(facet => shouldDisplayFacet(facet, store.selectedFacets, showSingleValuedFacets)) + {store.facets + .filter(facet => shouldDisplayFacet(facet, store.selectedFacets, showSingleValuedFacets)) .map(facet => { if (store.selectedFacets[facet.code] || Object.keys(facet).length > 1) { return ( @@ -53,10 +53,9 @@ export class FacetBox extends React.Component> { } else { return null; } - }) - } + })}
    - } + )}
    ); } diff --git a/src/collections/components/search/facet-box/utils.ts b/src/collections/components/search/facet-box/utils.ts index 074cf0882..d14778823 100644 --- a/src/collections/components/search/facet-box/utils.ts +++ b/src/collections/components/search/facet-box/utils.ts @@ -3,24 +3,42 @@ import {FacetOutput, SearchStore} from "../../../store"; /** Ajoute une valeur de facette pour la facette donnée. */ export function addFacetValue(store: SearchStore, facetKey: string, facetValue: string) { - if (store.selectedFacets[facetKey]) { // Liste existante : on ajoute la valeur à la liste (en vérifiant qu'elle n'est pas déjà présente) - store.selectedFacets = {...store.selectedFacets, [facetKey]: uniq(store.selectedFacets[facetKey].concat(facetValue))}; - } else { // Liste manquante : on crée la liste. + if (store.selectedFacets[facetKey]) { + // Liste existante : on ajoute la valeur à la liste (en vérifiant qu'elle n'est pas déjà présente) + store.selectedFacets = { + ...store.selectedFacets, + [facetKey]: uniq(store.selectedFacets[facetKey].concat(facetValue)) + }; + } else { + // Liste manquante : on crée la liste. store.selectedFacets = {...store.selectedFacets, [facetKey]: [facetValue]}; } } /** Retire une valeur de facette pour la facette donnée. */ export function removeFacetValue(store: SearchStore, facetKey: string, facetValue: string) { - if (store.selectedFacets[facetKey].length === 1) { // Une seule valeur sélectionnée : on retire la facette entière. + if (store.selectedFacets[facetKey].length === 1) { + // Une seule valeur sélectionnée : on retire la facette entière. store.selectedFacets = omit(store.selectedFacets, facetKey); - } else { // Sinon, on retire simplement la valeur de la liste. - store.selectedFacets = {...store.selectedFacets, [facetKey]: store.selectedFacets[facetKey].filter(value => value !== facetValue)}; + } else { + // Sinon, on retire simplement la valeur de la liste. + store.selectedFacets = { + ...store.selectedFacets, + [facetKey]: store.selectedFacets[facetKey].filter(value => value !== facetValue) + }; } } /** Détermine si on doit affiche une facette dans la FacetBox ou non, pour prévoir combien on va avoir de facettes à afficher au final. */ -export function shouldDisplayFacet(facet: FacetOutput, selectedFacets: {[key: string]: string[]}, showSingleValuedFacets?: boolean) { - return !(!facet.values.length || !showSingleValuedFacets && facet.values.length === 1 && !values(selectedFacets) - .find(vs => !!vs.find(v => facet.values[0].code === v))); +export function shouldDisplayFacet( + facet: FacetOutput, + selectedFacets: {[key: string]: string[]}, + showSingleValuedFacets?: boolean +) { + return !( + !facet.values.length || + (!showSingleValuedFacets && + facet.values.length === 1 && + !values(selectedFacets).find(vs => !!vs.find(v => facet.values[0].code === v))) + ); } diff --git a/src/collections/components/search/results/group.tsx b/src/collections/components/search/results/group.tsx index 17117d2f6..97adc9065 100644 --- a/src/collections/components/search/results/group.tsx +++ b/src/collections/components/search/results/group.tsx @@ -11,7 +11,17 @@ import {getIcon} from "../../../../components"; import {themr} from "../../../../theme"; import {GroupResult, ListStoreBase, SearchStore} from "../../../store"; -import {ActionBar, DetailProps, DragLayerStyle, EmptyProps, LineProps, LineStyle, ListStyle, OperationListItem, StoreList} from "../../list"; +import { + ActionBar, + DetailProps, + DragLayerStyle, + EmptyProps, + LineProps, + LineStyle, + ListStyle, + OperationListItem, + StoreList +} from "../../list"; import * as styles from "./__style__/group.css"; export type GroupStyle = Partial; @@ -70,7 +80,6 @@ export interface GroupProps { /** Composant de groupe, affiche une ActionBar (si plusieurs groupes) et une StoreList. */ @observer export class Group extends React.Component> { - @computed protected get store(): ListStoreBase { const {group, store} = this.props; @@ -92,30 +101,53 @@ export class Group extends React.Component> { } render() { - const {canOpenDetail, DetailComponent, detailHeight, disableDragAnimThreshold, dragItemType, dragLayerTheme, EmptyComponent, group, GroupHeader = DefaultGroupHeader, groupOperationList, hasDragAndDrop, hasSelection, i18nPrefix = "focus", itemKey, LineComponent, lineOperationList, lineTheme, listTheme, MosaicComponent, perPage = 5, store, useGroupActionBars} = this.props; + const { + canOpenDetail, + DetailComponent, + detailHeight, + disableDragAnimThreshold, + dragItemType, + dragLayerTheme, + EmptyComponent, + group, + GroupHeader = DefaultGroupHeader, + groupOperationList, + hasDragAndDrop, + hasSelection, + i18nPrefix = "focus", + itemKey, + LineComponent, + lineOperationList, + lineTheme, + listTheme, + MosaicComponent, + perPage = 5, + store, + useGroupActionBars + } = this.props; return ( - {theme => + {theme => (
    - {useGroupActionBars ? + {useGroupActionBars ? ( - : + ) : (
    - {hasSelection ? + {hasSelection ? ( - : null} + ) : null}
    - } + )} extends React.Component> { />
    - } + )}
    ); } } /** "Barre" de chargement pour les résultats. */ -export const GroupLoadingBar = observer(({i18nPrefix = "focus", store}: {i18nPrefix?: string, store: SearchStore}) => - store.isLoading ? -
    - {i18next.t(`${i18nPrefix}.search.loading`)} -
    - :
    +export const GroupLoadingBar = observer( + ({i18nPrefix = "focus", store}: {i18nPrefix?: string; store: SearchStore}) => + store.isLoading ?
    {i18next.t(`${i18nPrefix}.search.loading`)}
    :
    ); (GroupLoadingBar as any).displayName = "GroupLoadingBar"; diff --git a/src/collections/components/search/results/index.tsx b/src/collections/components/search/results/index.tsx index 72f276f64..dad925c16 100644 --- a/src/collections/components/search/results/index.tsx +++ b/src/collections/components/search/results/index.tsx @@ -2,7 +2,16 @@ import {computed} from "mobx"; import {observer} from "mobx-react"; import * as React from "react"; -import {DetailProps, DragLayerStyle, EmptyProps, LineProps, LineStyle, ListStyle, OperationListItem, StoreList} from "../../list"; +import { + DetailProps, + DragLayerStyle, + EmptyProps, + LineProps, + LineStyle, + ListStyle, + OperationListItem, + StoreList +} from "../../list"; import {GroupResult, SearchStore} from "../../../store"; import {Group, GroupLoadingBar, GroupStyle} from "./group"; @@ -65,11 +74,27 @@ export interface ResultsProps { /** Composants affichant les résultats de recherche, avec affiche par groupe. */ @observer export class Results extends React.Component> { - /** Props communes entre le composant de liste et ceux de groupes. */ @computed private get commonListProps() { - const {canOpenDetail, detailHeight, DetailComponent, disableDragAnimThreshold, dragItemType, dragLayerTheme, EmptyComponent, hasDragAndDrop, hasSelection, i18nPrefix, isManualFetch, itemKey, LineComponent, lineTheme, MosaicComponent, store} = this.props; + const { + canOpenDetail, + detailHeight, + DetailComponent, + disableDragAnimThreshold, + dragItemType, + dragLayerTheme, + EmptyComponent, + hasDragAndDrop, + hasSelection, + i18nPrefix, + isManualFetch, + itemKey, + LineComponent, + lineTheme, + MosaicComponent, + store + } = this.props; return { canOpenDetail, detailHeight, @@ -91,7 +116,19 @@ export class Results extends React.Component> { } render() { - const {GroupHeader, groupOperationList, groupPageSize, groupTheme, i18nPrefix, lineOperationList, listPageSize, listTheme, offset, store, useGroupActionBars} = this.props; + const { + GroupHeader, + groupOperationList, + groupPageSize, + groupTheme, + i18nPrefix, + lineOperationList, + listPageSize, + listTheme, + offset, + store, + useGroupActionBars + } = this.props; const filteredGroups = store.groups.filter(group => group.totalCount !== 0); if (filteredGroups.length) { diff --git a/src/collections/components/search/search-bar.tsx b/src/collections/components/search/search-bar.tsx index 570205a52..ea392c723 100644 --- a/src/collections/components/search/search-bar.tsx +++ b/src/collections/components/search/search-bar.tsx @@ -42,7 +42,6 @@ export interface SearchBarProps { /** Barre de recherche permettant de contrôle le texte et les critères personnalisés de recherche. */ @observer export class SearchBar extends React.Component> { - /** L'input HTML. */ protected input?: HTMLInputElement | null; @@ -65,9 +64,7 @@ export class SearchBar extends React.Component this.flatCriteria.map(([c, _]) => c) - .find(c => c === crit)); + return this.criteriaList.filter(crit => this.flatCriteria.map(([c, _]) => c).find(c => c === crit)); } /** Texte de la SearchBar. */ @@ -79,11 +76,14 @@ export class SearchBar extends React.Component c[0]), this.criteria)) + const criteria = this.criteria + .concat(difference(this.flatCriteria.map(c => c[0]), this.criteria)) .map(c => [c, this.flatCriteria.find(i => i[0] === c) && this.flatCriteria.find(i => i[0] === c)![1]]) .filter(([_, value]) => value) .map(([key, value]) => `${key}:${value}`); - return `${criteria.join(" ")}${criteria.length && (store.query && store.query.trim()) ? " " : ""}${store.query}`; + return `${criteria.join(" ")}${criteria.length && (store.query && store.query.trim()) ? " " : ""}${ + store.query + }`; } } @@ -121,17 +121,15 @@ export class SearchBar extends React.Component u === crit)) { ((store.criteria as any)[crit] as FormEntityField).value = value; @@ -144,12 +142,12 @@ export class SearchBar extends React.Component ((store.criteria as any)[crit] as FormEntityField).value = undefined); + difference(Object.keys(toFlatValues(store.criteria)), this.criteriaList).forEach( + crit => (((store.criteria as any)[crit] as FormEntityField).value = undefined) + ); // Et on reconstruit le reste de la query avec ce qu'il reste. - store.query = `${tokens.slice(skip) - .join(" ")}${currentTarget.value.match(/\s*$/)![0]}`; // La regex sert à garder les espaces en plus à la fin. + store.query = `${tokens.slice(skip).join(" ")}${currentTarget.value.match(/\s*$/)![0]}`; // La regex sert à garder les espaces en plus à la fin. } } @@ -174,7 +172,8 @@ export class SearchBar extends React.Component extends React.Component - {theme => + {theme => (
    - {this.showCriteriaComponent ?
    : null} + {this.showCriteriaComponent ? ( +
    + ) : null}
    - {scopes && store.criteria && scopeKey ? + {scopes && store.criteria && scopeKey ? ( ({value: code, label}))]} + value={ + ((store.criteria as any)[scopeKey] as FormEntityField).value as string | number + } + source={[ + {value: undefined, label: ""}, + ...scopes.map(({code, label}) => ({value: code, label})) + ]} theme={{dropdown: theme.dropdown, values: theme.scopes, valueKey: ""}} /> - : null} + ) : null}
    - {getIcon(`${i18nPrefix}.icons.searchBar.search`)} + + {getIcon(`${i18nPrefix}.icons.searchBar.search`)} + this.input = input} + ref={input => (this.input = input)} value={this.text} />
    - {this.text && !this.showCriteriaComponent ? : null} - {store.criteria && criteriaComponent && !this.showCriteriaComponent ? - - : null} + {this.text && !this.showCriteriaComponent ? ( + + ) : null} + {store.criteria && criteriaComponent && !this.showCriteriaComponent ? ( + + ) : null}
    - {!this.showCriteriaComponent && this.error ? - - {this.error} - - : null} - {this.showCriteriaComponent ? + {!this.showCriteriaComponent && this.error ? ( + {this.error} + ) : null} + {this.showCriteriaComponent ? (
    - - {fieldFor(makeField(store.query, {label: `${i18nPrefix}.search.bar.query`}), {onChange: query => store.query = query})} + + {fieldFor(makeField(store.query, {label: `${i18nPrefix}.search.bar.query`}), { + onChange: query => (store.query = query) + })} {criteriaComponent}
    -
    - : null} + ) : null}
    - } + )} ); } diff --git a/src/collections/components/search/summary.tsx b/src/collections/components/search/summary.tsx index eb5950e38..4928bbc3f 100644 --- a/src/collections/components/search/summary.tsx +++ b/src/collections/components/search/summary.tsx @@ -34,7 +34,7 @@ export interface ListSummaryProps { /** Préfixe i18n pour les libellés. Par défaut : "focus". */ i18nPrefix?: string; /** Liste des colonnes sur lesquels on peut trier. */ - orderableColumnList?: {key: string, label: string, order: boolean}[]; + orderableColumnList?: {key: string; label: string; order: boolean}[]; /** Store associé. */ store: SearchStore; /** CSS. */ @@ -44,13 +44,12 @@ export interface ListSummaryProps { /** Affiche le nombre de résultats et les filtres dans la recherche avancée. */ @observer export class Summary extends React.Component> { - /** Liste des filtres à afficher. */ @computed.struct protected get filterList() { const {hideCriteria, hideFacets, store} = this.props; - const topicList: {key: string, label: string, onDeleteClick: () => void}[] = []; + const topicList: {key: string; label: string; onDeleteClick: () => void}[] = []; // On ajoute la liste des critères. if (!hideCriteria && store.criteria) { @@ -59,8 +58,13 @@ export class Summary extends React.Component> { const value = (store.flatCriteria as any)[criteriaKey]; topicList.push({ key: criteriaKey, - label: `${i18next.t(label)} : ${domain && domain.displayFormatter && domain.displayFormatter(value) || value}`, - onDeleteClick: () => { (store.criteria![criteriaKey] as FormEntityField).value = undefined; } + label: `${i18next.t(label)} : ${(domain && + domain.displayFormatter && + domain.displayFormatter(value)) || + value}`, + onDeleteClick: () => { + (store.criteria![criteriaKey] as FormEntityField).value = undefined; + } }); } } @@ -71,14 +75,15 @@ export class Summary extends React.Component> { const facetValues = store.selectedFacets[facetKey] || []; const facetOutput = store.facets.find(facet => facetKey === facet.code); if (facetOutput) { - facetOutput.values - .filter(value => !!facetValues.find(v => v === value.code)) - .forEach(facetItem => - topicList.push({ - key: `${facetKey}-${facetItem.code}`, - label: `${i18next.t(facetOutput && facetOutput.label || facetKey)} : ${i18next.t(facetItem.label || facetItem.code)}`, - onDeleteClick: () => removeFacetValue(store, facetKey, facetItem.code) - })); + facetOutput.values.filter(value => !!facetValues.find(v => v === value.code)).forEach(facetItem => + topicList.push({ + key: `${facetKey}-${facetItem.code}`, + label: `${i18next.t((facetOutput && facetOutput.label) || facetKey)} : ${i18next.t( + facetItem.label || facetItem.code + )}`, + onDeleteClick: () => removeFacetValue(store, facetKey, facetItem.code) + }) + ); } } } @@ -105,54 +110,63 @@ export class Summary extends React.Component> { return ( - {theme => + {theme => (
    - {/* Nombre de résultats. */} - {totalCount} {i18next.t(`${i18nPrefix}.search.summary.result${plural}`)} + {totalCount}  + {i18next.t(`${i18nPrefix}.search.summary.result${plural}`)} {/* Texte de recherche. */} - {query && query.trim().length > 0 ? - {`${i18next.t(`${i18nPrefix}.search.summary.for`)} "${query}"`} - : null} + {query && query.trim().length > 0 ? ( + + {" "} + {`${i18next.t(`${i18nPrefix}.search.summary.for`)} "${query}"`} + + ) : null} {/* Liste des filtres (scope + facettes + critères) */} - {this.filterList.length ? + {this.filterList.length ? (
    {i18next.t(`${i18nPrefix}.search.summary.by`)} - {this.filterList.map(chip => {chip.label})} + {this.filterList.map(chip => ( + + {chip.label} + + ))}
    - : null} + ) : null} {/* Groupe. */} - {groupingKey && !hideGroup ? + {groupingKey && !hideGroup ? (
    - {i18next.t(`${i18nPrefix}.search.summary.group${plural}`)} - store.groupingKey = undefined} - > + + {i18next.t(`${i18nPrefix}.search.summary.group${plural}`)} + + (store.groupingKey = undefined)}> {i18next.t(store.groupingLabel!)}
    - : null} + ) : null} {/* Tri. */} - {this.currentSort && !hideSort && !groupingKey && totalCount > 1 ? + {this.currentSort && !hideSort && !groupingKey && totalCount > 1 ? (
    - {i18next.t(`${i18nPrefix}.search.summary.sortBy`)} + + {i18next.t(`${i18nPrefix}.search.summary.sortBy`)} + store.sortBy = undefined : undefined} + onDeleteClick={canRemoveSort ? () => (store.sortBy = undefined) : undefined} > - {i18next.t(this.currentSort.label)} + {i18next.t(this.currentSort.label)} +
    - : null} + ) : null} {/* Action d'export. */} - {exportAction ? + {exportAction ? (
    - : null} + ) : null}
    - } + )}
    ); } diff --git a/src/collections/index.ts b/src/collections/index.ts index e1b9f31cb..475400469 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -23,7 +23,6 @@ export { tableFor, Timeline, timelineFor, - AdvancedSearch, advancedSearchFor, AdvancedSearchStyle, diff --git a/src/collections/readme.md b/src/collections/readme.md index 0ae062182..313f56793 100644 --- a/src/collections/readme.md +++ b/src/collections/readme.md @@ -7,12 +7,14 @@ Le module `collections` contient les composants et les stores permettant de gér Ces composants permettent respectivement d'afficher une liste, un tableau ou une timeline. Ils partagent tous la même base qui leur permet de gérer de la pagination (par défaut en "scroll infini"). Leur usage minimal est très simple, il suffit de renseigner la liste en question dans la prop `data` et le composant de ligne `LineComponent`/`MosaicComponent` (resp. `RowComponent` et `TimelineComponent`). ### La liste + Si les composants de tableau et de timeline sont assez simples, la liste possède quelques fonctionnalités supplémentaires : -- On peut passer et afficher des actions sur chaque élement (`operationList`). -- Elle peut gérer d'un mode liste et d'un mode mosaïque, avec deux composants séparés. (`LineComponent` et `MosaicComponent`) -- Elle peut gérer un détail d'élément, dont l'affichage se fait par accordéon. (`DetailComponent` et la prop supplémentaire `openDetail` passée aux lignes.) -- Les lignes de la liste peuvent être des sources de drag and drop. -- On peut ajouter un handler d'ajout d'élément (affiché uniquement en mosaïque). (`addItemHandler`) + +* On peut passer et afficher des actions sur chaque élement (`operationList`). +* Elle peut gérer d'un mode liste et d'un mode mosaïque, avec deux composants séparés. (`LineComponent` et `MosaicComponent`) +* Elle peut gérer un détail d'élément, dont l'affichage se fait par accordéon. (`DetailComponent` et la prop supplémentaire `openDetail` passée aux lignes.) +* Les lignes de la liste peuvent être des sources de drag and drop. +* On peut ajouter un handler d'ajout d'élément (affiché uniquement en mosaïque). (`addItemHandler`) Un composant transverse **`ListWrapper`** permet de centraliser les paramètres de mode, de taille de mosaïque et d'handler d'ajout d'élément pour partager cet état entre plusieurs listes (ce qui est utilisé nativement par la recherche groupée). Il suffit de poser toutes les listes dans un `ListWrapper` et elles récupéreront l'état via le contexte. @@ -23,23 +25,27 @@ Les composants présentés sont suffisants pour un grand nombre de cas simples, Les deux stores partagent la même base qui leur permet de gérer de la **sélection** d'élements, et qui définit certains éléments de l'API commune (compteurs, **tri**, **filtrage**). ### `ListStore` + Le `ListStore` est le store le plus simple : on lui affecte une liste pré-chargée dans la propriété `list` et il offre les possibilités de l'API commune précisées au-dessus (le tri et le filtrage sont réalisés à la volée donc). ### `SearchStore` + Le `SearchStore` est prévu pour être associé à un service de recherche, la plupart du temps connecté à un serveur ElasticSearch. Il contient à la fois les différents critères de recherche ainsi que les résultats, en plus de contenir les fonctionnalités communes comme la sélection. Chaque changement de critère va relancer la recherche. Son usage standard est très simple puisqu'il sera intégralement piloté et affiché par les composants de recherche, mais il est également possible de manipuler les différents critères à la main, qui sont : -* `query` : le champ texte -* `groupingKey` : le champ sur lequel grouper. -* `selectedFacets` : les facettes sélectionnées. -* `sortAsc` : le sens du tri. -* `sortBy`: le champ sur lequel trier. -* `top` : le nombre de résultats à retourner par requête. + +* `query` : le champ texte +* `groupingKey` : le champ sur lequel grouper. +* `selectedFacets` : les facettes sélectionnées. +* `sortAsc` : le sens du tri. +* `sortBy`: le champ sur lequel trier. +* `top` : le nombre de résultats à retourner par requête. A ces critères-là, on peut ajouter un objet de critère `criteria` personnalisé pour ajouter d'autres champs à utiliser pour la recherche. Cet objet sera stocké sous la forme d'un `StoreNode` pour pouvoir construire des champs, avec de la validation, de manière immédiate (par exemple pour des champs de date, de montant...). Ou bien, simplement pour ajouter des critères simples comme un scope ou un ID d'objet pour restreindre la recherche. Le constructeur prend jusqu'à 3 paramètres : -* `searchService` (obligatoire) : le service de recherche, qui soit respecter impérativement l'API de recherche prévue : `(query: QueryInput) => Promise>` -* `initialQuery` (facultatif) : les valeurs des critères par défaut à la création du store. -* `criteria` (facultatif) : la description du critère personnalisé. Doit être de la forme `[{} as MyObjectNode, MyObjectEntity]` + +* `searchService` (obligatoire) : le service de recherche, qui soit respecter impérativement l'API de recherche prévue : `(query: QueryInput) => Promise>` +* `initialQuery` (facultatif) : les valeurs des critères par défaut à la création du store. +* `criteria` (facultatif) : la description du critère personnalisé. Doit être de la forme `[{} as MyObjectNode, MyObjectEntity]` _(Note : les deux derniers paramètres sont interchangeables)_ @@ -48,47 +54,56 @@ _(Note : les deux derniers paramètres sont interchangeables)_ S'il est possible d'utiliser les composants de base avec un store, il existe toute une suite de composants destinés à un usage avec un store. ### Listes -- Le composant de liste `listFor` devient `storeListFor`, et il propose en plus : - + L'affichage des cases de sélection sur chaque ligne - + La pagination serveur en plus de la pagination locale (les deux peuvent être utilisées en même temps) -- Le composant de tableau `tableFor` devient `storeTableFor`, et il propose en plus : - + Le tri au niveau des colonnes - + La pagination serveur (idem `storeListFor`) + +* Le composant de liste `listFor` devient `storeListFor`, et il propose en plus : + * L'affichage des cases de sélection sur chaque ligne + * La pagination serveur en plus de la pagination locale (les deux peuvent être utilisées en même temps) +* Le composant de tableau `tableFor` devient `storeTableFor`, et il propose en plus : + * Le tri au niveau des colonnes + * La pagination serveur (idem `storeListFor`) ### `ActionBar` + C'est le composant principal pour piloter un store (liste ou recherche). On y retrouve, dans l'ordre : -* La case à cocher de sélection (si `hasSelection = true`). -* Le menu de tri (si `orderableColumnList` a été renseigné). -* Le bouton d'ouverture des filtres (si `hasFacetBox = true`). -* Le menu de groupe (si `hasGroup = true`). -* La barre de recherche (si `hasSearchBar = true`) -* Les actions de sélection (si au moins un élément est sélectionné et que `operationList` a été renseigné). + +* La case à cocher de sélection (si `hasSelection = true`). +* Le menu de tri (si `orderableColumnList` a été renseigné). +* Le bouton d'ouverture des filtres (si `hasFacetBox = true`). +* Le menu de groupe (si `hasGroup = true`). +* La barre de recherche (si `hasSearchBar = true`) +* Les actions de sélection (si au moins un élément est sélectionné et que `operationList` a été renseigné). Lorsqu'un élément au moins a été sélectionné, toutes les autres actions disparaissent pour afficher le nombre d'éléments sélectionnés à la place. Ces mêmes actions sont absentes de l'ActionBar d'un groupe et le nom du groupe est affiché à la place. ### `AdvancedSearch` + L'`AdvancedSearch` est l'assemblage des 4 composants qui constituent la recherche : `Results` (+ un `ListWrapper`), `ActionBar`, `ListSummary` et `FacetBox`. L'intégralité des props de ces composants se retrouve dans ses props, souvent avec le même nom ou parfois avec un nom un peu différent (exemple : `hideFacets` dans le `ListSummary`, `hideSummaryFacets` dans l'`AdvancedSearch`). ### `Results` + Ce composant permet d'afficher les résultats de la recherche, sous la forme d'une liste unique ou bien de groupes si on souhaite en afficher. Chaque groupe est muni d'un header qui peut être soit un header simple avec une case de sélection et le nom du groupe, soit une `ActionBar` si on veut gérer des actions spécifiques au niveau du groupe (prop `useGroupActionBars`). Toutes les listes sont des `storeListFor` et peuvent donc utiliser toutes leurs fonctionnalités. ### `ListSummary` + Ce composant est affiché en premier, au-dessus de l'`ActionBar` et du `Results`. Il sert à afficher le résumé de la recherche en cours en listant, dans l'ordre : -* Le nombre de résultats -* Le champ de recherche textuel -* Les critères (masquables) -* Les facettes (masquables) -* Le groupe (masquable) -* Le tri (masquable) + +* Le nombre de résultats +* Le champ de recherche textuel +* Les critères (masquables) +* Les facettes (masquables) +* Le groupe (masquable) +* Le tri (masquable) _(Note : le tri et le groupe ne sont jamais effectifs en même temps)_ ### `FacetBox` + Ce composant affiche le résultats des facettes issues du serveur et permet de les sélectionner. Le composant peut être affiché tel quel (à priori sur la gauche des résultats), ou bien à l'intérieur de l'ActionBar pour des écrans où on n'a pas la place de les afficher sur la gauche. Par défaut, les facettes n'ayant qu'une seule valeur ne sont pas affichées ; il est possible de forcer leur affichage avec la prop `showSingleValuedFacets`. ### `SearchBar` + La `SearchBar` est un composant indépendant de l'`AdvancedSearch` que l'on peut donc poser séparament (par exemple dans le `Header`) pour gérer la partie textuelle de la recherche. Le composant agit naturellement sur le champ `query`, mais également sur les critères personnalisés `criteria`, qu'il va par défaut ajouter dans le champ texte pour une saisie manuelle (du genre `criteriaName:criteriaValue` ; ce comportement est désactivable via la prop `disableInputCriteria`). Il est possible également de lui passer un composant personnalisé de saisie des critères qu'il va pouvoir afficher à la demande pour saisir de manière plus précise les différents critères. Enfin, il dispose d'un sélecteur de "scope" que l'on peut activer en précisant `scopeKey` et `scopes`, respectivement le nom de la propriété de `criteria` qui correspond au scope et la liste de ses valeurs possibles. diff --git a/src/collections/store/base.ts b/src/collections/store/base.ts index 51efdcbc8..ac1a2d4db 100644 --- a/src/collections/store/base.ts +++ b/src/collections/store/base.ts @@ -5,7 +5,6 @@ export type SelectionStatus = "none" | "partial" | "selected"; /** Socle commun entre le store de liste et de recherche. */ export abstract class ListStoreBase { - /** Filtre texte. */ @observable query = ""; /** Tri par ordre croissant. */ @@ -28,9 +27,12 @@ export abstract class ListStoreBase { @computed get selectionStatus(): SelectionStatus { switch (this.selectedItems.size) { - case 0: return "none"; - case this.selectionnableList.length: return "selected"; - default: return "partial"; + case 0: + return "none"; + case this.selectionnableList.length: + return "selected"; + default: + return "partial"; } } diff --git a/src/collections/store/list.ts b/src/collections/store/list.ts index d8f663746..ba4f17495 100644 --- a/src/collections/store/list.ts +++ b/src/collections/store/list.ts @@ -11,7 +11,6 @@ import {ListStoreBase} from "./base"; * S'utilise sur une liste pré-chargée */ export class ListStore extends ListStoreBase { - /** Liste brute (non triée, non filtrée) des données. */ readonly innerList: IObservableArray = observable([]); @@ -34,22 +33,27 @@ export class ListStore extends ListStoreBase { // Tri. if (this.sortBy) { - list = orderBy(this.innerList, item => `${(item as any)[this.sortBy!]}`.toLowerCase(), this.sortAsc ? "asc" : "desc"); + list = orderBy( + this.innerList, + item => `${(item as any)[this.sortBy!]}`.toLowerCase(), + this.sortAsc ? "asc" : "desc" + ); } else { list = this.innerList; } // Filtrage simple, sur les champs choisis. if (this.filterFields) { - list = list.filter(item => this.filterFields!.some(filter => { - const field = item[filter]; - if (isString(field)) { - return field.toLowerCase() - .includes(this.query.toLowerCase()); // Pour faire simple, on compare tout en minuscule. - } else { - return false; - } - })); + list = list.filter(item => + this.filterFields!.some(filter => { + const field = item[filter]; + if (isString(field)) { + return field.toLowerCase().includes(this.query.toLowerCase()); // Pour faire simple, on compare tout en minuscule. + } else { + return false; + } + }) + ); } return list; diff --git a/src/collections/store/search.ts b/src/collections/store/search.ts index 26610aab8..9c8935ec5 100644 --- a/src/collections/store/search.ts +++ b/src/collections/store/search.ts @@ -28,7 +28,6 @@ export interface SearchProperties { /** Store de recherche. Contient les critères/facettes ainsi que les résultats, et s'occupe des recherches. */ export class SearchStore extends ListStoreBase implements SearchProperties { - /** Bloque la recherche (la recherche s'effectuera lorsque elle repassera à false) */ @observable blockSearch = false; @@ -63,15 +62,27 @@ export class SearchStore extends ListStoreBase< * @param criteria La description du critère de recherche personnalisé. * @param initialQuery Les paramètres de recherche à l'initilisation. */ - constructor(service: SearchService, criteria?: C, initialQuery?: SearchProperties & {debounceCriteria?: boolean}) + constructor( + service: SearchService, + criteria?: C, + initialQuery?: SearchProperties & {debounceCriteria?: boolean} + ); /** * Crée un nouveau store de recherche. * @param initialQuery Les paramètres de recherche à l'initilisation. * @param service Le service de recherche. * @param criteria La description du critère de recherche personnalisé. */ - constructor(service: SearchService, initialQuery?: SearchProperties & {debounceCriteria?: boolean}, criteria?: C) - constructor(service: SearchService, secondParam?: SearchProperties & {debounceCriteria?: boolean} | C, thirdParam?: SearchProperties & {debounceCriteria?: boolean} | C) { + constructor( + service: SearchService, + initialQuery?: SearchProperties & {debounceCriteria?: boolean}, + criteria?: C + ); + constructor( + service: SearchService, + secondParam?: SearchProperties & {debounceCriteria?: boolean} | C, + thirdParam?: SearchProperties & {debounceCriteria?: boolean} | C + ) { super(); this.service = service; @@ -98,20 +109,26 @@ export class SearchStore extends ListStoreBase< } // Relance la recherche à chaque modification de propriété. - reaction(() => [ - this.blockSearch, - this.groupingKey, - this.selectedFacets, - !initialQuery || !initialQuery.debounceCriteria ? this.flatCriteria : undefined, // On peut choisir de debouncer ou non les critères personnalisés, par défaut ils ne le sont pas. - this.sortAsc, - this.sortBy - ], () => this.search()); + reaction( + () => [ + this.blockSearch, + this.groupingKey, + this.selectedFacets, + !initialQuery || !initialQuery.debounceCriteria ? this.flatCriteria : undefined, // On peut choisir de debouncer ou non les critères personnalisés, par défaut ils ne le sont pas. + this.sortAsc, + this.sortBy + ], + () => this.search() + ); // Pour les champs texte, on utilise la recherche "debouncée" pour ne pas surcharger le serveur. - reaction(() => [ - initialQuery && initialQuery.debounceCriteria ? this.flatCriteria : undefined, // Par exemple, si les critères sont entrés comme du texte ça peut être utile. - this.query - ], debounce(() => this.search(), config.textSearchDelay)); + reaction( + () => [ + initialQuery && initialQuery.debounceCriteria ? this.flatCriteria : undefined, // Par exemple, si les critères sont entrés comme du texte ça peut être utile. + this.query + ], + debounce(() => this.search(), config.textSearchDelay) + ); } /** Store en chargement. */ @@ -140,7 +157,7 @@ export class SearchStore extends ListStoreBase< @computed get groupingLabel() { const group = this.facets.find(facet => facet.code === this.groupingKey); - return group && group.label || this.groupingKey; + return (group && group.label) || this.groupingKey; } /** Nombre total de résultats de la recherche (pas forcément récupérés). */ @@ -162,7 +179,7 @@ export class SearchStore extends ListStoreBase< const {criteria = {}} = this; for (const key in criteria) { if (key !== "set" && key !== "clear") { - const entry = ((criteria as any)[key] as FormEntityField); + const entry = (criteria as any)[key] as FormEntityField; if (entry.error) { errors[key] = true; continue; @@ -176,9 +193,8 @@ export class SearchStore extends ListStoreBase< /** Récupère l'objet de critères personnalisé à plat (sans le StoreNode) */ @computed.struct get flatCriteria() { - const criteria = this.criteria && toFlatValues(this.criteria) as {}; + const criteria = this.criteria && (toFlatValues(this.criteria) as {}); if (criteria) { - // On enlève les critères en erreur. for (const error in this.criteriaErrors) { if (this.criteriaErrors[error]) { @@ -227,7 +243,7 @@ export class SearchStore extends ListStoreBase< criteria: {...this.flatCriteria, query} as QueryInput["criteria"], facets: selectedFacets || {}, group: groupingKey || "", - skip: isScroll && list.length || 0, // On skip les résultats qu'on a déjà si `isScroll = true` + skip: (isScroll && list.length) || 0, // On skip les résultats qu'on a déjà si `isScroll = true` sortDesc: sortAsc === undefined ? false : !sortAsc, sortFieldName: sortBy, top @@ -269,7 +285,7 @@ export class SearchStore extends ListStoreBase< this.groupingKey = props.hasOwnProperty("groupingKey") ? props.groupingKey : this.groupingKey; this.selectedFacets = props.selectedFacets || this.selectedFacets; this.sortAsc = props.sortAsc !== undefined ? props.sortAsc : this.sortAsc; - this.sortBy = props.hasOwnProperty("sortBy") ? props.sortBy as keyof T : this.sortBy; + this.sortBy = props.hasOwnProperty("sortBy") ? (props.sortBy as keyof T) : this.sortBy; this.query = props.query || this.query; this.top = props.top || this.top; } @@ -281,63 +297,65 @@ export class SearchStore extends ListStoreBase< getSearchGroupStore(groupCode: string): ListStoreBase { // tslint:disable-next-line:no-this-assignment const store = this; - return observable({ - - get currentCount() { - return store.groups.find(result => result.code === groupCode)!.totalCount || 0; - }, - - get totalCount() { - return store.groups.find(result => result.code === groupCode)!.totalCount || 0; - }, - - isItemSelectionnable: store.isItemSelectionnable, - - get list() { - const resultGroup = store.groups.find(result => result.code === groupCode); - return resultGroup && resultGroup.list || []; - }, - - get selectionnableList(): T[] { - return this.list.filter(store.isItemSelectionnable); - }, + return observable( + { + get currentCount() { + return store.groups.find(result => result.code === groupCode)!.totalCount || 0; + }, + + get totalCount() { + return store.groups.find(result => result.code === groupCode)!.totalCount || 0; + }, + + isItemSelectionnable: store.isItemSelectionnable, + + get list() { + const resultGroup = store.groups.find(result => result.code === groupCode); + return (resultGroup && resultGroup.list) || []; + }, + + get selectionnableList(): T[] { + return this.list.filter(store.isItemSelectionnable); + }, + + get selectedItems() { + return new Set(store.selectedList.filter(item => this.list.find((i: T) => i === item))); + }, + + get selectionStatus() { + if (this.selectedItems.size === 0) { + return "none"; + } else if (this.selectedItems.size === this.selectionnableList.length) { + return "selected"; + } else { + return "partial"; + } + }, - get selectedItems() { - return new Set(store.selectedList.filter(item => this.list.find((i: T) => i === item))); - }, + toggle(item: T) { + store.toggle(item); + }, - get selectionStatus() { - if (this.selectedItems.size === 0) { - return "none"; - } else if (this.selectedItems.size === this.selectionnableList.length) { - return "selected"; - } else { - return "partial"; - } - }, + // Non immédiat car le set de sélection contient tous les résultats alors que le toggleAll ne doit agir que sur le groupe. + toggleAll() { + const areAllItemsIn = this.selectionnableList.every(item => store.selectedItems.has(item)); - toggle(item: T) { - store.toggle(item); - }, - - // Non immédiat car le set de sélection contient tous les résultats alors que le toggleAll ne doit agir que sur le groupe. - toggleAll() { - const areAllItemsIn = this.selectionnableList.every(item => store.selectedItems.has(item)); + this.list.forEach(item => { + if (store.selectedItems.has(item)) { + store.selectedList.remove(item); + } + }); - this.list.forEach(item => { - if (store.selectedItems.has(item)) { - store.selectedList.remove(item); + if (!areAllItemsIn) { + store.selectedList.push(...this.selectionnableList); } - }); - - if (!areAllItemsIn) { - store.selectedList.push(...this.selectionnableList); } + }, + { + toggle: action.bound, + toggleAll: action.bound } - }, { - toggle: action.bound, - toggleAll: action.bound - }) as any; + ) as any; } } diff --git a/src/components/__style__/input-date.css b/src/components/__style__/input-date.css index 09f8cddc6..87253e6a2 100644 --- a/src/components/__style__/input-date.css +++ b/src/components/__style__/input-date.css @@ -39,4 +39,3 @@ .down { top: 60px; } - diff --git a/src/components/__style__/scrollspy-container.css b/src/components/__style__/scrollspy-container.css index 25dfa9782..566f95bbe 100644 --- a/src/components/__style__/scrollspy-container.css +++ b/src/components/__style__/scrollspy-container.css @@ -27,4 +27,5 @@ } /* stylelint-disable */ -.content {} +.content { +} diff --git a/src/components/autocomplete.tsx b/src/components/autocomplete.tsx index f700bf523..927a06048 100644 --- a/src/components/autocomplete.tsx +++ b/src/components/autocomplete.tsx @@ -6,7 +6,11 @@ import * as React from "react"; import {findDOMNode} from "react-dom"; export {ObservableMap}; -import {Autocomplete as RTAutocomplete, AutocompleteProps as RTAutocompleteProps, AutocompleteTheme} from "react-toolbox/lib/autocomplete"; +import { + Autocomplete as RTAutocomplete, + AutocompleteProps as RTAutocompleteProps, + AutocompleteTheme +} from "react-toolbox/lib/autocomplete"; import {InputTheme} from "react-toolbox/lib/input"; import {ProgressBar} from "react-toolbox/lib/progress_bar"; @@ -42,7 +46,6 @@ export interface AutocompleteProps extends RTAutocompleteProps { /** Surtouche de l'Autocomplete React-Toolbox pour utilisation des services de recherche serveur. */ @observer export class Autocomplete extends React.Component { - private inputElement!: HTMLInputElement | null; /** Composant en chargement. */ @@ -58,13 +61,12 @@ export class Autocomplete extends React.Component { async componentWillMount() { const {value, keyResolver, isQuickSearch} = this.props; if (value && !isQuickSearch && keyResolver) { - this.query = i18next.t(await keyResolver(value) || "") || value; + this.query = i18next.t((await keyResolver(value)) || "") || value; } } componentDidMount() { - this.inputElement = (findDOMNode(this) as Element) - .querySelector("input"); + this.inputElement = (findDOMNode(this) as Element).querySelector("input"); } focus() { @@ -130,7 +132,12 @@ export class Autocomplete extends React.Component { this.isLoading = true; const result = await this.props.querySearcher(encodeURIComponent(query.trim())); runInAction("replaceResults", () => { - this.values.replace(result && result.data && result.data.reduce((acc, next) => ({...acc, [next.key]: i18next.t(next.label)}), {}) || {}); + this.values.replace( + (result && + result.data && + result.data.reduce((acc, next) => ({...acc, [next.key]: i18next.t(next.label)}), {})) || + {} + ); this.isLoading = false; }); } @@ -145,7 +152,7 @@ export class Autocomplete extends React.Component { const {keyResolver, querySearcher, theme: pTheme, ...props} = this.props; return ( - {theme => + {theme => (
    { suggestionMatch="disabled" theme={theme} /> - {this.isLoading ? + {this.isLoading ? ( - : null} + ) : null}
    - } + )}
    ); } diff --git a/src/components/button-back-to-top.tsx b/src/components/button-back-to-top.tsx index 5dcdefee9..0f3c9efce 100644 --- a/src/components/button-back-to-top.tsx +++ b/src/components/button-back-to-top.tsx @@ -25,7 +25,6 @@ export interface ButtonBackToTopProps { /** Bouton de retour en haut de page. */ @observer export class ButtonBackToTop extends React.Component { - @observable isVisible = false; componentDidMount() { @@ -56,20 +55,14 @@ export class ButtonBackToTop extends React.Component { } render() { - return this.isVisible ? + return this.isVisible ? ( - {theme => + {theme => (
    -
    - } + )}
    - : null; + ) : null; } } diff --git a/src/components/button-help.tsx b/src/components/button-help.tsx index 0e7e0a7f5..597761154 100644 --- a/src/components/button-help.tsx +++ b/src/components/button-help.tsx @@ -8,13 +8,15 @@ import {getIcon} from "./icon"; const Button = Tooltip(IconButton); /** Affiche un bouton pour ouvrir le centre d'aide. */ -export function ButtonHelp({blockName, i18nPrefix = "focus"}: {blockName: string, i18nPrefix?: string}) { +export function ButtonHelp({blockName, i18nPrefix = "focus"}: {blockName: string; i18nPrefix?: string}) { const {hash, pathname} = window.location; - const url = hash && hash.replace("#", "") || pathname; + const url = (hash && hash.replace("#", "")) || pathname; const {openHelpCenter} = window as any; if (typeof openHelpCenter !== "function") { - console.warn("La fonction \"window.openHelpCenter\" n'est pas définie. Merci de placer quelque part dans l'application une \"DraggableIframe\" avec \"openHelpCenter\" comme \"toggleFunctionName\""); + console.warn( + `La fonction "window.openHelpCenter" n'est pas définie. Merci de placer quelque part dans l'application une "DraggableIframe" avec "openHelpCenter" comme "toggleFunctionName"` + ); } return ( diff --git a/src/components/button-menu.tsx b/src/components/button-menu.tsx index b84fd8b5c..39fdcaa7e 100644 --- a/src/components/button-menu.tsx +++ b/src/components/button-menu.tsx @@ -19,7 +19,6 @@ export interface ButtonMenuProps extends MenuProps { /** Menu React-Toolbox avec un bouton personnalisable (non icône). */ @observer export class ButtonMenu extends React.Component { - /** Menu ouvert. */ @observable isOpened = false; /** Hauteur du bouton, pour placer le menu. */ @@ -74,10 +73,19 @@ export class ButtonMenu extends React.Component { } render() { - const {button: {icon, openedIcon, ...buttonProps}, position = "topLeft", ...menuProps} = this.props; + const { + button: {icon, openedIcon, ...buttonProps}, + position = "topLeft", + ...menuProps + } = this.props; return (
    -