DoorDash’s Item Modal, one of the most complex components of our app and web frontends, shows customers information about items they can order. This component includes nested pages, dynamic rendering of input fields, and client-side validation requirements.
Notre récente migration vers une architecture microservices nous a donné l'occasion de repenser la façon dont nous gérons la modale Item sur notre frontend web basé sur React. Travaillant sous la contrainte de ne pas vouloir ajouter un paquetage tiers, nous avons adopté ce que nous appelons le modèle de classe, une torsion sur le modèle de gestion d'état React vanille, prêt à l'emploi.
La réingénierie de notre modale d'élément à l'aide du modèle de classe a permis d'accroître la fiabilité, d'étendre la testabilité et de cartographier notre modèle mental de ce composant.
Trouver le meilleur modèle de gestion d'état React à adopter
La première chose que fait tout ingénieur React après avoir tapé npx create-react-app my-app
is almost always googling something like “how to manage react state”, followed by “best react state management libraries”.
Ces recherches Google remplissent l'écran d'un grand nombre d'articles différents sur d'innombrables frameworks de gestion d'état React, tels que React Redux, Le reculet MobX, that it‚Äôs difficult to decide which one to buy into. The rapidly-changing landscape can mean that choosing one ${insert hype state management framework}
aujourd'hui nécessitera une mise à niveau vers un autre ${insert even more hype state management framework}
demain.
Début 2019, nous avons reconstruit notre application web, en basant la pile technologique sur TypeScript, Apollo/GraphQL et React. Avec de nombreuses équipes différentes travaillant sur les mêmes pages, chaque page avait sa propre façon unique de gérer l'état.
La complexité de la construction de l'élément modal nous a obligés à repenser la façon dont nous gérons l'état de nos composants complexes. Nous avons utilisé le modèle de classe pour organiser l'état dans la fenêtre modale de l'élément de manière à ce que la logique métier et la logique d'affichage soient facilement différenciées. La reconstruction de la fenêtre modale de l'élément sur la base du modèle de classe a permis non seulement d'accroître sa disponibilité pour les clients, mais aussi de servir de modèle pour les autres états de notre application.
Présentation de notre modèle de gestion d'état React : Le modèle de classe
Avant de discuter de la façon dont nous avons utilisé un pattern de gestion d'état react, expliquons ce qu'est le pattern de classe et comment nous avons été amenés à l'utiliser. Lorsque nous avons été chargés de reconstruire la modale Item, la structure de données renvoyée par le backend était une structure arborescente JSON imbriquée. Nous avons rapidement réalisé que les données pouvaient également être représentées sous la forme d'un arbre N-aire avec trois types de nœuds distincts :
ItemNode
OptionListNode
OptionNode.
Avec le modèle de classe, nous avons rendu le rendu et l'état très faciles à comprendre et nous avons permis à la logique commerciale de vivre directement sur les nœuds de la modale d'élément.
At DoorDash, we haven‚Äôt standardized on any state management framework, and most commonly use useState/useReducer combinée à l'extraction de données GraphQL. Pour l'itération précédente de notre Item Modal, nous avons utilisé deux useReducers : ItemReducer
to manage GraphQL’s data fetching and ItemControllerReducer
to manage the Item Modal’s state from UI interactions.
La nature dynamique du modèle des éléments nécessite de nombreux types de fonctions différentes appelées avec chaque action du réducteur. Par exemple, la création d'une instance du modèle d'élément pour un client envoie une action d'initiation pour gérer la réponse aux données dans ItemReducer. Ensuite, l'Item Modal lance une action d'initiation avec la fonction ItemReducer‚Äôs
de l'État à ItemControllerReducer
où nous préparons l'état et effectuons une validation récursive.
It was easy to write integration tests by running our reducer and dispatching actions, then checking the end result. For example, we could dispatch a building Item Modal action with mock data and check to see if the state on ItemReducer and ItemControllerReducer was correct. However, Item Modal’s smaller moving parts and business logic were more difficult to test.
Nous voulions rendre l'exécution des tests unitaires sur les nouvelles fonctionnalités d'Item Modal plus rapide et plus facile. De plus, en rendant toutes nos fonctionnalités actuelles testables à l'unité, nous pouvions facilement tester chaque nouvelle fonctionnalité et éviter les régressions.
La création du modèle de classe a simplifié à l'extrême les tests de la logique commerciale, ne nécessitant aucun paquetage tiers et n'obligeant pas à maintenir notre flux de données unidirectionnel avec useReducer.
To introduce this pattern, we’ve built a simple to-do list example:
En commençant par un simple exemple de liste de tâches
L'extraction de la logique métier du réducteur utilisé dans useReducer vers une classe ES6, TodoState, facilite la création d'un test unitaire sans configuration.
Mise en œuvre de TodoState
In TodoState, we utilize TypeScript’s private and public functions and variables to set a clear delineation of what is internal to TodoState and what should be externally exposed. Private functions should only be called by handleTodoAction, which is described in the paragraph below, or by other internal functions. Public functions consist of handTodoAction and any selector functions that expose the state to any consumers of TodoState.
handleTodoAction
devrait sembler très familier à un exemple de réducteur accepter une action et déterminer les fonctions internes à appeler. Dans handleTodoAction, une TodoAction correspond à un cas dans l'instruction switch et déclenche un appel à l'une des méthodes privées de TodoState. Par exemple, setTodoDone ou addTodo modifient l'état, mais ne peuvent être appelées que par handleTodoAction.
public handleTodoAction = (todoAction: TodoAction) => {
switch (todoAction.type) {
case TodoActionType.ADD_TODO: {
this.addTodos(todoAction.todo);
return;
}
case TodoActionType.SET_TODO_DONE: {
this.setTodoDone(todoAction.todoIndex, todoAction.done);
return;
}
}
};
todos est stocké dans TodoState en tant que variable privée qui peut être récupérée à l'aide de la méthode publique getTodos. getTodos
est l'autre méthode publique et agit de la même manière qu'une méthode sélecteur d'autres frameworks de gestion d'état, tels que Redux.
public getTodos = () => {
return this.todos;
};
Depuis getTodos
est une méthode publique, elle peut appeler n'importe quelle méthode privée mais ce serait un anti-modèle car les autres méthodes publiques autres que handleTodoAction
ne doit sélectionner que l'État.
Construire un crochet useTodo useReducer personnalisé
Nous créons un useTodo
qui englobe le useReducer en n'exposant que ce que le consommateur de la useTodo
les besoins en crochets : les todos et les actions addTodo
et setTodoDone
.
export const useTodo = () => {
const [todoState, dispatch] = useReducer(TodoReducer, new TodoState());
const addTodo = (todo: Todo) => {
dispatch({ type: TodoActionType.ADD_TODO, todo });
};
const setTodoDone = (todoIndex: number, done: boolean) => {
dispatch({ type: TodoActionType.SET_TODO_DONE, todoIndex, done });
};
const todos = todoState.getTodos();
return { todos, addTodo, setTodoDone };
};
Cliquez ici pour voir l'intégralité de l'exemple de code dans CodeSandbox.
Nous pouvons ensuite effectuer une copie superficielle en utilisant Object.assign({}, todoState)
to prevent side effects on the previous state and preserve typing, then offload the typical reducer logic to the TodoState’s handleTodoAction function
et enfin renvoyer le newTodoState
.
const TodoReducer: Reducer<TodoState, TodoAction> = (todoState, todoAction) => {
const newTodoState = Object.assign({}, todoState);
newTodoState.handleTodoAction(todoAction);
return newTodoState;
};
Mise en œuvre des tests unitaires
Comme nous l'avons mentionné plus haut, nous avons conçu le modèle de classe pour faciliter les tests de logique commerciale, ce que nous pouvons démontrer avec le modèle suivant TodoState
. We’re able to test every line of TodoState
très facilement, sans aucune configuration préalable. (Bien que nous utilisions l'effet de levier CodeSandbox‚Äôs Jest setup.)
Nous testons la logique commerciale et vérifions les effets secondaires à l'aide de handleTodoAction
et les méthodes de sélection publiques (getTodos
dans le cas présent), de la même manière qu'un consommateur interagirait en fin de compte avec l'application TodoState
. We don’t even need React for these tests because TodoState
is purely decoupled and written in JavaScript. This means we don’t have to fumble with looking up how to render hooks in tests or find out that a third party package needs to be upgraded to support writing unit tests.
it("addTodo - should add todo with text: Snoopy", () => {
const todoState = new TodoState();
todoState.handleTodoAction({
type: TodoActionType.ADD_TODO,
todo: {
text: "Snoopy",
done: false
}
});
const todos = todoState.getTodos();
expect(todos.length).toBe(1);
expect(todos[0].text).toBe("Snoopy");
});
Cliquez ici pour voir les tests unitaires dans CodeSandbox.
Mettre useTodo et TodoState ensemble dans l'interface utilisateur
L'exemple de code ci-dessous est une interface utilisateur très simple qui montre à quel point les interactions avec l'état deviennent simples lorsque l'on utilise le modèle de classe.
Les TodoList
appelle le crochet useTodo pour obtenir les todos à côté de l'élément addTodo
et setTodoDone
fonctions.
const { todos, addTodo, setTodoDone } = useTodo();
Cliquez ici pour voir l'exemple de code dans CodeSandbox.
Le code que nous renvoyons est très simple, puisqu'il suffit de faire correspondre le code todos
de useTodo
. Grâce au modèle de classe, nous pouvons conserver un balisage très simple, même dans l'exemple modal plus complexe de la section suivante.
{todos.map((todo: Todo, index: number) => {
return (
<div
key={index}
style={{
display: "flex",
alignItems: "center"
}}
>
{todo.text}
<input
type="checkbox"
onChange={toggleTodoDone(index, todo)}
checked={todo.done}
/>
</div>
);
})}
Cliquez ici pour voir l'exemple de code dans CodeSandbox.
Ensuite, nous créons des gestionnaires d'événements. Nous utiliserons handleAddTodo
et toggleTodoDone
pour les actions de clic de bouton et de changement de case à cocher.
const handleAddTodo = () => {
addTodo({
text: todoInputText,
done: false
});
resetTodoInputText();
};
const toggleTodoDone = (todoIndex: number, todo: Todo) => () => {
setTodoDone(todoIndex, !todo.done);
};
En TodoList
nous joignons handleAddTodo
à la onClick
du bouton. Lorsque l'on clique sur le bouton, plusieurs choses se produisent pour rendre la nouvelle todo sur TodoList
comme le montre la figure 2 ci-dessous.
<button onClick={handleAddTodo}>Add Todo</button>
Cliquez ici pour voir l'exemple de code dans CodeSandbox.
TodoList
- Le clic d'un bouton déclenche l'alarmehandleAddTodo
handleAddTodo
- Nous utilisons la valeur actuelle de todoInputText pour créer une charge utile de données sur les tâches. Ensuite,addTodo
(exposée par le biais de lauseTodo
) est appelé avec ces données.addTodo
- envoie unAddTodo
TodoAction
à laTodoReducer
avec letodo data payload
.- TodoReducer - makes a new copy of the current state and calls TodoState’s
handleTodoAction
avec leTodoAction
. handleTodoAction
- détermine que leTodoAction
est unAddTodo
et appelle la fonction privéeaddTodo
pour ajouter les données des todos aux todos et aux retours.- TodoReducer - la nouvelle copie de l'état actuel comprend maintenant aussi la mise à jour
todos
et renvoie le nouvel état
A l'intérieur de la useTodo
hook, we use TodoState’s getTodos
pour sélectionner les todos mis à jour sur TodoState
et le renvoie au client.
Le client détecte le changement d'état et effectue un nouveau rendu pour afficher le nouvel état. todos
sur TodoState
Comment utiliser le modèle de classe dans la fenêtre modale de l'élément
As mentioned above, there are a lot of moving parts in the Item Modal. With our rebuild, we’ve consolidated the two reducers into one, TreeReducer
Il s'agit d'un système de gestion des données qui gère la recherche et la consolidation des données (demande initiale d'article et demandes d'options imbriquées) et conserve l'état de l'article, tel que la quantité, le prix courant et la validité de l'article.
La consolidation en un seul reducer rend les tests d'intégration plus importants plus simples et nous permet d'avoir toutes les actions en un seul endroit. Nous utilisons le modèle de classe ainsi que le modèle TreeReducer
pour construire un TreeState
, similaire à la TodoState
nous avons dépassé les limites.
Notre TreeState expose un handleTreeAction
fonction publique qui gère toutes les actions entrantes et déclenche une série d'appels de fonctions.
Description de TreeState
La caractéristique la plus importante de la reconstruction modale d'un élément est qu'il s'agit d'un TreeState, représenté par un arbre N-aire, comme le montre la figure 3 ci-dessous :
The recursive nature of the Item Modal and its nodes is similar to a Reddit post’s comment section. A comment can have child comments, which have child comments, and so on. But for the Item Modal, each node has a different responsibility.
Mise en œuvre de ItemNode
Un ItemNode
holds the item’s information, including name, ID, description, price, and imageUrl. An ItemNode
is always the TreeState’s root and its children are always OptionListNodes
.
export class ItemNode {
private imageUrl?: string;
private name: string;
private id: string;
private isValid: boolean;
private children: OptionListNode[];
...
}
Cliquez ici pour voir le code exemple dans CodeSandbox.
Pour l'ensemble des ItemNode’s
nous pouvons facilement écrire des tests pour l'ensemble de leur logique commerciale en ajoutant simplement OptionListNode‚Äôs
en tant qu'enfants et de tester la validation.
it("should be valid if all of its OptionListNodes are valid", () => {
const optionListNode = new OptionListNode({
id: "test-option-list",
name: "Condiments",
minNumOptions: 0,
maxNumOptions: 9,
selectionNode: SelectionNodeType.SINGLE_SELECT,
parent: itemNode
});
optionListNode.validate();
expect(itemNode.getIsValid()).toBe(true);
});
Cliquez ici pour voir le reste des tests unitaires dans CodeSandbox.
Mise en œuvre d'OptionListNode
Un OptionListNode
garde la trace de toutes les conditions limites de validation et détermine le type d'options à afficher dans la fenêtre modale de l'élément. Par exemple, si un utilisateur a sélectionné une pizza sur l'application web, OptionListNode
peut lancer une liste d'options multi-sélectionnées exigeant la sélection d'un minimum de deux et d'un maximum de quatre options de nappage. Un OptionListNode‚Äôs
les enfants sont OptionNodes
et son nœud parent peut être soit un ItemNode
(cas normal) ou un OptionNode
(cas des options imbriquées).
export class OptionListNode {
private id: string;
private name: string;
private minNumOptions: number;
private maxNumOptions: number;
private selectionNode: SelectionNodeType;
private parent?: ItemNode | OptionNode;
private children: OptionNode[];
private isValid: boolean;
...
}
Cliquez ici pour voir le code exemple dans CodeSandbox.
Les OptionListNode
handles most of the critical business logic in determining client side error states to ensure a user is submitting a valid item in the correct format. It’s validate method is more complicated than OptionNode
et ItemNode
and we need to check if the node satisfies the boundary rules. If the user does not follow the merchant’s item boundary configuration rules the OptionListNode
sera invalide et l'interface utilisateur affichera un message d'erreur.
public validate = () => {
const validSelectedOptions = this.children.filter(
(optionNode) => optionNode.getIsSelected() && optionNode.getIsValid()
).length;
this.isValid =
this.satisfiesMinNumOptions(validSelectedOptions) &&
this.satisfiesMaxNumOptions(validSelectedOptions);
this.parent?.validate();
};
Nous pouvons facilement tester cette logique de validation pour un OptionListNode qui nécessite un minNumOptions >= 1 en ajoutant des OptionNodes puis en appelant select.
describe("OptionListNode", () => {
let optionListNode: OptionListNode = new OptionListNode({
id: "test-option-list",
name: "Condiments",
minNumOptions: 1,
maxNumOptions: 9,
selectionNode: SelectionNodeType.SINGLE_SELECT
});
beforeEach(() => {
optionListNode = new OptionListNode({
id: "test-option-list",
name: "Condiments",
minNumOptions: 1,
maxNumOptions: 9,
selectionNode: SelectionNodeType.SINGLE_SELECT
});
});
describe("validate", () => {
it("should not be valid with no OptionNode children", () => {
optionListNode.validate();
expect(optionListNode.getIsValid()).toBe(false);
});
it("should be valid if one OptionNode is selected and is valid", () => {
const optionNode = new OptionNode({
parent: optionListNode,
id: "test",
name: "ketchup"
});
optionNode.select();
optionListNode.validate();
expect(optionListNode.getIsValid()).toBe(true);
});
it("should not be valid if no OptionNodes are selected", () => {
const optionNode = new OptionNode({
parent: optionListNode,
id: "test",
name: "ketchup"
});
optionListNode.validate();
expect(optionListNode.getIsValid()).toBe(false);
});
});
}
Cliquez ici pour voir le reste des tests unitaires dans CodeSandbox.
Mise en œuvre d'OptionNode
Un OptionNode
conserve la trace de son état de sélection, de son prix, de son nom et, éventuellement, d'un nextCursor. Un OptionNode
avec un nextCursor
indique qu'il y a des options imbriquées.
export class OptionNode {
private id: string;
private name: string;
private nextCursor?: string;
private children: OptionListNode[];
private isSelected: boolean;
private isValid: boolean;
private parent?: OptionListNode;
…
}
Cliquez ici pour voir le code exemple dans CodeSandbox.
Plutôt que de construire un arbre entier, nous pouvons isoler le test au niveau de l'élément OptionNode
et ses parents et enfants immédiats.
Nous pouvons tester des comportements assez compliqués, par exemple lorsque nous sélectionnons un OptionNode
il désélectionnera tous ses frères et sœurs. OptionNodes
s'il s'agit d'un SINGLE_SELECT.
describe("OptionNode", () => {
let optionNode = new OptionNode({
name: "Test",
id: "test-option"
});
beforeEach(() => {
optionNode = new OptionNode({
name: "Test",
id: "test-option"
});
});
describe("select", () => {
it("if its parent is a SINGLE_SELECTION option list all of its sibling options will be unselected when it is selected", () => {
const optionListNode = new OptionListNode({
id: "test-option-list",
name: "Condiments",
minNumOptions: 1,
maxNumOptions: 9,
selectionNode: SelectionNodeType.SINGLE_SELECT
});
const siblingOptionNode = new OptionNode({
id: "sibling",
name: "Ketchup",
parent: optionListNode
});
const testOptionNode = new OptionNode({
id: "Test",
name: "Real Ketchup",
parent: optionListNode
});
expect(siblingOptionNode.getIsSelected()).toBe(false);
expect(testOptionNode.getIsSelected()).toBe(false);
siblingOptionNode.select();
expect(siblingOptionNode.getIsSelected()).toBe(true);
expect(testOptionNode.getIsSelected()).toBe(false);
testOptionNode.select();
// should unselect the sibling option node because its parent is a single select
expect(siblingOptionNode.getIsSelected()).toBe(false);
expect(testOptionNode.getIsSelected()).toBe(true);
});
});
});
Cliquez ici pour voir le code exemple dans CodeSandbox.
Cliquez ici pour voir le reste des tests unitaires dans CodeSandbox.
État de l'arbre
TreeState garde la trace de la racine de l'arbre N-aire (un ItemNode
), un magasin de clés et de valeurs qui permet aux Accès O(1) à n'importe quel nœud de l'arbre, ainsi qu'au nœud actuel à des fins de pagination.
TreeState’s handleTreeAction
interagit directement avec le ItemNodes
, OptionListNodes
et OptionNodes
.
Visualisation du TreeState avec un exemple de commande de burrito
To better visualize these different nodes, let’s take a look at a burrito order in which a user can choose from two meats, chicken or steak, and two beans, pinto or black. We can take it further by allowing the user to select the quantity of meat via a nested option.
- Le burrito est un
ItemNode
et est également la racine deTreeState
. Il a deux enfantsOptionListNodes
Viande et burrito.- Les haricots sont un
OptionListNode
avec deux enfantsOptionNodes
Pinto et Black.- Pinto est un
OptionNode
. - Le noir est un
OptionNode
.
- Pinto est un
- Les haricots sont un
- La viande est un
OptionListNode
avec deux enfantsOptionNodes
Poulet et bifteck.- Le poulet est un
OptionNode
- Le poulet est un
- Le steak est un
OptionNode
avec un enfantOptionListNode
, Quantity, ce qui signifie qu'il s'agit d'une option imbriquée.- La quantité est un
OptionListNode
avec deux enfantsOptionNodes
, ¬Ω and 2x.- ¬Ω is an
OptionNode
- 2x est un
OptionNode
- ¬Ω is an
- La quantité est un
Mise en œuvre du modèle de classe sur l'élément modal
Nous avons construit un autre exemple d'Item Modal sur CodeSandbox pour démontrer comment nous utilisons TreeState dans l'exemple de commande de burritos décrit ci-dessus. Dans ce guide, nous nous concentrons sur un exemple simple pour montrer le modèle de classe. Cependant, nous incluons également les options imbriquées plus compliquées pour ceux qui souhaitent aller plus loin.
Cliquez ici pour voir le code exemple dans CodeSandbox.
Nous exposons un useTree
de la même manière que nous avons mis en œuvre le useTodo
dans notre TodoList
exemple ci-dessus. useTree
interagit avec TreeState
en exposant des sélecteurs pour currentNode et des fonctions de mutation pour sélectionner et désélectionner les options, et en construisant l'arbre initial.
Cliquez ici pour voir le code exemple dans CodeSandbox.
Construction de l'arbre initial
La première partie critique du rendu de l'élément modal est la construction de l'élément initial TreeState
avec les données de l'article, comme le montre la figure 5 ci-dessous.
- Item Modal - Nous initialisons le hook useTree lors du rendu initial, en exposant
buildTree
,currentNode
,selectOption
,unselectOption
,setCurrentNode
(qui n'est pas abordé dans ce guide), etaddTreeNodes
(qui n'est pas non plus abordée dans ce guide). Lorsque nous initialisons leuseTree
le crochetTreeState
est dans son état par défaut, aveccurrentNode
indéfini, racine indéfinie, etnodeMap
fixé à{}
. - Poste Modal - A
useEffect
se déclenchera et détectera quecurrentNode
est indéfini et récupère les données de l'élément et appellebuildTree
exposée à partir deuseTree
avec les données de l'élément. Cependant, dans cet exemple de modèle de classe, nous allons omettre la mise en œuvre de l'appel API et utiliser des données fictives (trouvées dans la rubrique TreeState/mockData). Item API Response
est reçu -buildTree
envoie unBUILD_TREE
qui doit être traité partreeReducer
.- treeReducer fait une copie profonde de l'arbre actuel.
TreeState
et appelle ensuiteTreeState.handleTreeAction
const TreeReducer: Reducer<TreeState, TreeAction> = (treeState, treeAction) => {
const newTreeState = cloneDeep(treeState);
newTreeState.handleTreeAction(treeAction);
return newTreeState;
};
Cliquez ici pour voir le code exemple dans CodeSandbox.
TreeState.handleTreeAction
commence à ressembler à un réducteur Redux typique avec ses déclarations switch. Dans l'instruction switch, le type d'action entrant correspond àTreeActionType.BUILD_TREE.
Ici,TreeState
crée tous les nœuds,ItemNode
,OptionListNode
etOptionNode
à partir des données utiles de l'arbre initial. Nous créonsItemNode
encreateTreeRoot
,OptionListNode
encreateOptionListNodes
etOptionNode
encreateOptionNodes
.
Cliquez ici pour voir le code exemple dans CodeSandbox.
The critical piece here is that the nodes are created with the correct pointers to their children and parents. The Burrito ItemNode’s children are Meat and Beans, which are in turn OptionListNodes with Burrito as their parent. Meat’s children are Chicken and Steak, which are also OptionNodes with Meat as their parent. Beans’ children are Pinto and Black with Beans as their parent.
TreeReducer
- La premièreTreeState
est maintenant mis à jour et construit, ce qui déclenche un nouveau rendu dans le composant de l'application web qui rend le currentNode, comme le montre la figure 6 ci-dessous :
Une fois que nous avons demandé la charge utile des données de l'élément, il y a beaucoup de pièces mobiles impliquées dans la construction de l'élément initial de l'élément. TreeState
et de nombreux endroits où les choses peuvent mal tourner. Le modèle de classe nous a permis d'écrire facilement des tests pour différents types d'éléments et de vérifier que le modèle de classe est correct. TreeState
a été construit correctement.
Dans l'exemple ci-dessous, nous écrivons une suite de tests pour notre cas d'utilisation de commande de burrito afin de nous assurer que toutes les relations et la validité initiale sont correctes entre les éléments suivants ItemNode
, OptionListNodes
et OptionNodes
. We think of these as “integration” tests as they test the entire side effects of a reducer action as opposed the “unit” tests that we wrote to test the business logic of ItemNode
, OptionListNode
et OptionNode
. En production, nous disposons de notre suite de tests unitaires pour tous les types de données utiles des éléments, tels que la réorganisation, les options par défaut et les options imbriquées.
const treeState = new TreeState();
treeState.handleTreeAction({
type: TreeActionType.BUILD_TREE,
itemNodeData,
optionListNodeDataList: optionListData,
optionNodeDataList: optionData
});
it('Burrito should be the parent of Beans and Meat and Beans and Meat should be Burrito"s children', () => {
const burritoNode = treeState.getRoot();
burritoNode?.getChildren().forEach((optionListNode) => {
expect(
optionListNode.getName() === "Meat" ||
optionListNode.getName() === "Beans"
).toBeTruthy();
expect(optionListNode.getParent()).toBe(burritoNode);
});
});
Cliquez ici pour voir le code exemple dans CodeSandbox.
Nous avons des tests pour toute la logique commerciale à chaque niveau de la modalité Item, couvrant chaque niveau de nœud et le TreeState dans le CodeSandbox ci-dessous. Après avoir construit un arbre, nous avons des tests qui s'assurent que chaque nœud est initialisé correctement.
Cliquez ici pour voir le code exemple dans CodeSandbox.
Comment l'élément modal interagit avec l'état de l'arbre (TreeState)
The TreeState is critical to every aspect of the Item Modal. It is involved in the Item’s Modal’s rendering, client-side validation, and data fetching. Every user interaction results in a change to the TreeState.
Rendu
Avec le modèle de classe et TreeState, le rendu de la modalité Item est devenu extrêmement simple, car nous avons une relation quasi univoque entre le balisage et les formes d'état. ItemNode rend un composant Item, OptionListNode rend un composant OptionList, et OptionNode rend un composant Option.
The Item Modal can be in two different states: the initial item page or on a nested option page. We won’t cover the nested option case here but we determine what type of page to render by using Type Guards.
Pour la page initiale de l'article, nous rendons un composant ItemBody, qui accepte l'élément currentNode
, selectOption
et unselectOption
en tant que propriétés. ItemBody
rend le nom de l'élément, met en correspondance ses enfants et rend OptionLists
et affiche un bouton de soumission qui ne peut être utilisé que si toutes les options répondent aux critères de validation appropriés.
{itemNode.getChildren().map((optionListNode) => (
<OptionList
key={optionListNode.getId()}
optionListNode={optionListNode}
selectOption={selectOption}
unselectOption={unselectOption}
setCurrentNode={setCurrentNode}
/>
))}
Inside ItemBody, the markup is really simple because we just render the ItemNode’s children which are OptionListNodes as OptionList components, as shown in this CodeSandbox code sample.
Les OptionList
Le composant accepte optionListNode
, selectOption
, unselectOption
et setCurrentNode
les propriétés. A la suite de ces apports, OptionList
rend son nom, détermine si le OptionListNode
est valide, et met en correspondance ses enfants, ce qui rend Options.
{optionListNode.getChildren().map((optionNode) => (
<Option
key={optionNode.getId()}
optionNode={optionNode}
selectionNode={optionListNode.getSelectionNode()}
selectOption={selectOption}
unselectOption={unselectOption}
setCurrentNode={setCurrentNode}
/>
))}
Cliquez ici pour voir le code exemple dans CodeSandbox.
Les Option
Le composant accepte optionNode
, selectionNode
, selectOption
, unselectOption
et setCurrentNode
propriétés. selectionNode
est la partie dynamique du formulaire et détermine si un bouton radio ou une case à cocher est affiché. SelectionNodeType
.SINGLE_SELECT rend un bouton radio
Cliquez ici pour voir le code exemple dans CodeSandbox.
SelectionNodeType.MULTI_SELECT rend une case à cocher.
Cliquez ici pour voir le code exemple dans CodeSandbox.
Interaction via selectOption et unselectOption
The whole point of the Item Modal is to save and validate a user’s inputs and modifications to an item. We use selectOption and unselectOption functions exposed from useTree to capture these user inputs and modifications.
Pour illustrer le cycle de vie de ce qui se passe lorsqu'un Option’s
nous allons voir ce qui se passe lorsqu'un utilisateur clique sur la case à cocher Pinto Beans de notre exemple. Le cycle de vie pour arriver à la case à cocher TreeState.handleTreeAction
est exactement la même que la construction de l'arbre initial.
- Haricots Pinto Clicked -
handleMultiSelectOptionTouch
est déclenché suronChange
événement. Le rappel vérifie si l'événementOptionNode
est déjà sélectionné. S'il est déjà sélectionné, il appellera alorsunselectOption
avec son ID. Sinon, il appelleraselectOption
Dans cet exemple, il appelleselectOption
. selectOption
- envoie une action TreeActionType.SELECT_OPTION avec un identifiant d'option.
const selectOption = (optionId: string) => {
dispatch({ type: TreeActionType.SELECT_OPTION, optionId });
};
Cliquez ici pour voir le code exemple dans CodeSandbox.
treeReducer
-deep clone l'état actuel de l'arbre et appelleTreeState.handleTreeAction
.handleTreeAction
- nous utilisonsgetNode
pour récupérer le nœud dans la base de donnéesnodeMap
avec unoptionID
.
case TreeActionType.SELECT_OPTION: {
const optionNode = this.getNode(treeAction.optionId);
if (!(optionNode instanceof OptionNode))
throw new Error("This is not a valid option node");
optionNode.select();
if (optionNode.getNextCursor() !== undefined)
this.currentNode = optionNode;
return;
}
Cliquez ici pour voir le code exemple dans CodeSandbox.
OptionNode.select
- tourne leisSelected
à vrai et appelleOptionNode.validate
.
public validate = () => {
this.isValid = this.children.every((optionListNode) =>
optionListNode.getIsValid()
);
this.parent?.validate();
};
Cliquez ici pour voir le code exemple dans CodeSandbox.
OptionListNode.validate
, we need to validate the user’s input and determine whether it satisfies the boundary rules set by itsminNumOptions
etmaxNumOptions
. Après avoir vérifié les règles de délimitation, leOptionListNode
’s parent validate is called, which is onItemNode
.
public validate = () => {
const validSelectedOptions = this.children.filter(
(optionNode) => optionNode.getIsSelected() && optionNode.getIsValid()
).length;
this.isValid =
this.satisfiesMinNumOptions(validSelectedOptions) &&
this.satisfiesMaxNumOptions(validSelectedOptions);
this.parent?.validate();
};
Cliquez ici pour voir le code exemple dans CodeSandbox.
ItemNode.validate
- La validation est similaire à unOptionNode‚Äôs
la validation. Il vérifie si tous ses enfants sont valides afin de déterminer si l'élémentItemNode
is valid, but it doesn’t call its parent to validate as it is the root of the tree.
public validate = () => {
this.isValid = this.children.every((optionListNode) =>
optionListNode.getIsValid()
);
};
Cliquez ici pour voir le code exemple dans CodeSandbox.
- Burrito - non valide, haricots - valide, haricots pinto - non valide - Notre TreeState est mis à jour avec l'option Haricots pinto sélectionnée, et ses nœuds parent, Haricots, et grand-parent, Burrito, ont été validés. Ce changement d'état déclenche un nouveau rendu et l'option Pinto Beans apparaît sélectionnée, tandis que Beans passe de non valide à valide.
Le fait de cliquer sur les haricots Pinto fonctionne de la même manière que la construction de l'arbre initial. Lorsqu'un utilisateur clique sur une option, nous devons nous assurer que l'élément TreeState
est mis à jour et tous nos ItemNodes
, OptionListNodes
et OptionNodes
sont correctement définies comme invalides ou valides. Nous pouvons faire la même chose avec l'action de construction initiale de l'arbre et initialiser un fichier TreeState
Lancez l'action de sélection de l'option, puis vérifiez tous les nœuds pour vous assurer que tout est correct.
Cliquez ici pour voir le code exemple dans CodeSandbox.
Brève explication de la validation
For any user interaction, we need to recursively climb up the tree from where the user interaction initiates, as this interaction can result in that node becoming valid and affecting its parent’s validity, and its parent’s parent’s validity, and so on.
Dans l'exemple des haricots Pinto, nous devons valider à partir du nœud Haricots Pinto. Nous voyons d'abord qu'il n'a pas d'enfant, donc, en tant que noeud feuille, il est immédiatement valide. Ensuite, nous appelons validate sur Beans parce que nous devons vérifier si un nœud isSelected = true
Le nœud Pinto Beans peut remplir nos conditions limites Beans. Dans cet exemple, c'est le cas, nous marquons donc Beans comme valide et enfin nous appelons validate sur Burrito. Sur Burrito, nous voyons qu'il a deux nœuds OptionListNodes
comme les enfants et Beans est maintenant valide. Cependant, Meat n'est pas valide, ce qui signifie que Burrito n'est pas valide, comme le montre la figure 9 ci-dessous :
Comment utiliser le terrain de jeu des options imbriquées
We haven’t gone through the nested options example in this code walkthrough, but the code is available in the CodeSandbox we created. To unlock the nested options example and play around with the code, please uncomment out here and here.
Conclusion
State management can get exponentially more complicated as an application’s complexity grows. As we began the migration process to microservices, we re-evaluated how data flows through our application and gave some thought on how to improve it and better serve us in the future.
Le modèle de classe nous permet de séparer clairement notre couche de données et d'état de notre couche de visualisation et d'écrire facilement une suite robuste de tests unitaires pour garantir la fiabilité. Après la reconstruction, nous avons utilisé le modèle de classe pour construire et déployer la fonctionnalité de réorganisation à la fin de l'année 2020. Nous avons ainsi pu facilement écrire des tests unitaires pour couvrir notre nouveau cas d'utilisation, faisant de l'une de nos fonctionnalités les plus compliquées sur le web l'une des plus fiables.
Auparavant, il était facile de tester l'ensemble de la sortie d'une action et ses effets secondaires sur l'ensemble de l'état, mais nous n'étions pas en mesure d'obtenir facilement la granularité de toutes les parties mobiles (validation des nœuds individuels, messages d'erreur, etc. Cette granularité du modèle de classe et la nouvelle facilité de test ont renforcé notre confiance dans la fonctionnalité modale de l'élément et nous avons pu construire de nouvelles fonctionnalités par-dessus sans régression.
It’s important as a team to find a pattern or library to commit to in order for everyone to be on the same page. For teams coping with a similar situation, where state is getting more complicated and the team is not married to a specific library, we hope this guide can spark some inspiration in building a pattern to manage the application’s state.