Saltar a contenido

Frontend

Overview

La arquitectura de software que se explicara en este apartado es una adaptación de Clean Architecture, por lo que ciertos puntos no van a ser seguidos a cabalidad, pero es una definición clara de responsabilidades para saber cómo trabajar en el Frontend en Andreani.

Arquitectura "Suficientemente CLEAN"

Para el desarrollo de una web se desarrollo el siguiente árbol de directorios.

 📁.github
 📁.vscode
 📁public
 📁src
   ├───📁app
   |    └───📁person
   |         ├───📃use-crud-person.tsx
   |         └───📃index.ts
   |
   ├────📁adapters
   |    └────📃person.ts
   |
   | //shared, would be nested
   ├────📁constants
   |    └────📃person.ts
   |
   ├────📁hooks
   |    └────📃person.ts
   |
   ├────📁components
   |    ├────📁PaidButton
   |    |     ├─📃PaidButton.module.scss
   |    |     └─📃index.tsx
   |    |    
   |    └───📃Example.tsx
   |
   ├────📁utils
   |    └────📃person.ts   
   |
   |
   ├────📁assets  (si es necesario, por lo general para svgs en CRA)
   |    └────📃logo.svg
   |
   ├───📁pages
   |     └────📁person
   |          ├───📃person.module.scss
   |          └───📃index.tsx
   |
   ├────📁layout
   |     └────📁Main
   |          ├───📃Main.module.scss
   |          └───📃index.tsx
   |
   ├────📁skeletons
   |     └────📁Main
   |          ├───📃Main.module.scss
   |          └───📃index.tsx
   |
   ├────📁components (if needed)
   |     └────📁ButtonCustom
   |           ├───📃ButtonCustom.module.scss
   |           └───📃index.tsx
   |
   ├────📁routes (if needed)
   |     ├───📃ProtectedRoute.tsx
   |     └───📃index.tsx
   |
   ├────📁domain
   |     └───📃Person.ts
   |
   ├────📁store
   |     ├────📁features
   |     |      └───📁person
   |     |           ├───📃asyncActions.ts
   |     |           └───📃index.ts
   |     ├───📃hooks.tsx
   |     ├───📃reducers.tsx
   |     └───📃store.tsx
   |
   ├────📁services
   |     └────📃PersonService.ts
   ├─── 📁constants
   |     └───📃AUTH.ts
   ├───📁__tests__
   |    └───📁app
   |          └───📃person.test.tsx
   ├─── 📁test-utils
   |     ├─── 📁mocks
   |     |     └───📃handlers.ts
   |     └───📃index.tsx // test utils customRender
   ├─── 📃App.tsx
   ├─── 📃msalInstance.ts
   ├─── 📃monitor.ts
   ├─── 📃index.tsx
   ├─── 📃elastic__apm-rum-react.d.ts
   └─── 📃react-app-env.d.ts
 .dockerignore
 .env.example
 📃.eslintrc.js
 .gitignore
 📃Dockerfile
 📃package-lock.json
 📃package.json
 📃stylelint.config.js
 📃tsconfig.json

Dominio

  └────📁domain
     └───📃IPerson.ts
La capa de dominio, tiene la responsabilidad de almacenar todas las interfaces y/o tipos comunes de nuestra aplicación.

Application - app

Aca es dónde esta nuestra llamada a la capa de servicios e interacción con el store. Sería lo más similar en nuestro caso a los casos de uso.

Si se necesitan adapters o algo intermedio para guardar en el store en este punto debería hacerse. La intención es que la logica quedé aca, así como las funciones necesarias con respecto a las entidades.

También van aca abstracciones de las llamadas al store, como hooks que sirven para no llamar al store directamente en los componentes. cómo por ejemplo

  ├──📁app
  |  └───📁person
  |        ├───📃use-crud-person.tsx // hook
  |        └───📃index.ts // exports

Por ejemplo

app/person/index.ts
import { IPerson, PersonId } from "domain/Person";
import PersonService from "services/PersonService";

export async function getAll(signal?: AbortSignal) {
  const { data } = await PersonService.getUsers({ signal });

  return data;
}
export async function add(user: IPerson, signal?: AbortSignal) {
  const { data } = await PersonService.addUser(user, { signal });

  return data?.id;
}
export async function edit(user: Partial<IPerson>, signal?: AbortSignal) {
  const { data } = await PersonService.editUser(user, { signal });

  return data?.id;
}
export async function remove(id: PersonId, signal?: AbortSignal) {
  const { data } = await PersonService.removeuser(id, { signal });

  return data?.id;
}

Creamos hooks para llamar y hacer lo necesario para consumir la información por ejemplo:

app/person/use-person.tsx
import { selectPerson } from "store/features/person";
import { getUsers } from "store/features/person/asyncActions";
import { useAppDispatch, useAppSelector } from "store/hooks";
import { useEffect } from "react";

export default function usePerson() {
  const dispatch = useAppDispatch();
  const { data, isLoading } = useAppSelector(selectPerson);

  useEffect(() => {
    //Acá se podría colocar lógica para pedirlos cada n tiempo o si está vacío por ejemplo
    const promise = dispatch(getUsers());

    return () => {
      promise.abort();
    }
  }, [dispatch]);

  return { data, isLoading };
}

Infraestructure - Services

  └───📁services
        └────📃PersonService.ts
La capa de Infraestructure tiene la responsabilidad de tener la vinculación con las apis o servicios externos, en lo habitual con API's REST, pero no se debe limitar a. (Por ejemplo uso de GraphqlApis). Se puede contener interfaces relacionadas a los servicios dentro de la carpeta (de ser necesario), así como tambien la responsabilidad si está autenticado o no, la noción del endpoint al que le tenemos que pegar, si necesita información especifica o extra.

por ejemplo:

services/PersonService.ts
import type { IEditPerson, IPerson, PersonId } from "domain/Person";
import { ServiceBase, type ICommonOptions } from "@architecture-it/core";
import env from "@architecture-it/react-env";
import { msalInstance } from "msalInstance";
import axios from "axios";
import { addResponseInterceptorRefreshToken } from "@architecture-it/azure-b2c";

const BASE_URL = env("API") + "person";

class _PersonService extends ServiceBase {
  constructor() {
    super(BASE_URL);

    addResponseInterceptorRefreshToken(this.client, msalInstance, axios);
  }

  getPerson = ({ signal }: ICommonOptions) => this.client.get<IPerson[]>("/", { signal });

  getById = (id: PersonId, { signal }: ICommonOptions) =>
    this.client.get<IPerson>(`/${id}`, { signal });

  create = (user: IPerson, { signal }: ICommonOptions) =>
    this.client.post<IPerson>("/", user, { signal });

  edit = (user: IEditPerson, { signal }: ICommonOptions) =>
    this.client.put<IPerson>(`/${user.id}`, user, { signal });

  remove = (id: PersonId, { signal }: ICommonOptions) =>
    this.client.delete<IPerson>(`/${id}`, { signal });
}

const PersonService = new _PersonService();

//Se exporta la instancia
export default PersonService;

Domain

Esta capa contiene las interfaces core de la aplicación, así como funciones para validar que algo es de un tipo especifico

domain/Person.ts
export interface IPerson {
  id: string;
  name: string;
  lastName: string;
}

export interface IEditPerson extends Partial<IPerson> {
  id: string;
}

// Un alias del tipo, valido para no hardcodear que se necesita string si es el id de la persona, si este tipo cambia afecta a todas las funciones que utilicen este alias
export type PersonId = IPerson["id"];

UI - (Pages - Layout - Components - Skeletons)

La responsabilidad de esta capa debería ser solamente mostrar la información nos debe llamar la atención si un componente ejecuta muchas cosas, y debemos extraer siempre como buena practica todo lo que pueda vivir fuera del componente (Esto mejora inclusive el performance)

  ├────📁pages
  |     └────📁person
  |           ├────📁components (refered to a page)
  |           |     └────📁PersonAvatar
  |           |           ├───📃PersonAvatar.module.scss
  |           |           └───📃index.tsx
  |           ├───📃person.module.scss
  |           └───📃index.tsx
  ├────📁layout
  |     └────📁Main
  |          ├───📃Main.module.scss
  |          └───📃index.tsx
  |
  ├────📁components (shared)
  |     └────📁ButtonCustom
  |           ├───📃ButtonCustom.module.scss
  |           └───📃index.tsx
  ├────📁skeletons (User feedback, loadings)
  |     └────📁PrincipalSkeleton
  |           ├───📃PrincipalSkeleton.module.scss
  |           └───📃index.tsx
  |
Esta capa, contiene todo lo referido a componentes especificamente, lo suficientemente modular, con estilos separados del archivo javascript con CSS Modules.


Routing - Si se necesita

Contiene las rutas de la aplicación y si se necesita la segurización de las rutas, así como los componentes que evitan que se accedan a ciertos componentes. Ejemplo:

routes/index.tsx
import { lazy, Suspense } from "react";
import { Route, Routes } from "react-router-dom";

import PrincipalSkeleton from "../skeletons/Principal";

const Home = lazy(() => import("../pages/Home"));
const Person = lazy(() => import("../pages/Person"));

import ProtectedRoute from "./ProtectedRoute";

export default function AppRoutes() {
  return (
    <Suspense fallback={<PrincipalSkeleton />}>
      <Routes>
        <Route element={<ProtectedRoute />}>
          {/* Rutas Autenticadas */}
          <Route element={<Person />} path="person" />
        </Route>
        <Route index element={<Home />} />
      </Routes>
    </Suspense>
  );
}

Este es un ejemplo con Azure B2C, pero podría ser custom o interno según la necesidad de la aplicación

routes/ProtectedRoute.tsx
import { InteractionStatus } from "@azure/msal-browser";
import { useIsAuthenticated, useMsal } from "@azure/msal-react";
import { Navigate, Outlet } from "react-router-dom";
import PrincipalSkeleton from "skeletons/Principal";

const ProtectedRoute = () => {
  const isAuth = useIsAuthenticated();

  const { inProgress } = useMsal();

  if (inProgress !== InteractionStatus.None) {
    // Cuando inicia la aplicación verifica si existe alguna cuenta y está loggeado. Este proceso es asíncrono
    return <PrincipalSkeleton />;
  }

  if (!isAuth) {
    return <Navigate to="/" />;
  }

  return <Outlet />;
};

export default ProtectedRoute;

Shared - Si se necesita

Son toda la serie de carpetas que pueden anidarse de ser necesarias

   ├────📁constants
   |    └────📃person.ts
   |
   ├────📁hooks
   |    └────📃use-position.ts
   |
   ├────📁components
   |    ├────📁PaidButton
   |    |     ├─📃PaidButton.module.scss
   |    |     └─📃index.tsx
   |    |    
   |    └───📃Example.tsx
   |
   ├────📁utils
   |    └────📃person.ts   
   |
   ├────📁assets  (si es necesario, por lo general para svgs en CRA)
   |    └────📃logo.svg
   |

Store

Contiene todo lo relacionado con el store de la aplicación. En la mayoría de los casos como en el template está relacionado con el estado global manejado por Redux, utilidades, hooks y reducers.

Para ver información específica de Redux ver Buenas Practicas Redux


Testing

Utilidades y Providers relacionados con el testing Recomendamos agregar dentro de la carpeta una estructura similar a la de la aplicación a medida que se agreguen componentes.

   ├───📁__tests__
   |    └───📁app
   |          └───📃person.test.tsx
   ├─── 📁test-utils
   |     ├─── 📁mocks
   |     |     └───📃handlers.ts
   |     └───📃index.tsx // test utils customRender (tiene todos los providers)

Como hacer una petición al backend

Como estándar usamos axios por la fácilidad que nos provee al establecer interceptores (de requests y de responses), y de los defaults que contiene en adapters y cómo maneja los status code, aparte del tipado que actualmente ofrece. Como ultimo argumento opcional debemos mandarle la configuración de axios, esto para poder interactual con la capacidad de cancelar las peticiones cuando las acciones asincronas se cancelen (especialmente util en componentes que se pueden renderizar/desrenderizar rápidamente)

PersonService/index.ts
import { ServiceBase, type ICommonOptions } from "@architecture-it/core";
import type { IPerson } from "domain/Person";

class Service extends ServiceBase {
   constructor() {
     super("users/");
   }

  // Generamos el método que necesitemos
  getUsers = ({ signal }: ICommonOptions) => this.client.get<IPerson[]>("", config);
 }

const PersonService = new Service();

// exportamos la instancia, es decir como singleton
export default PersonService;
Utilizamos el servicio en la acción asincrona correspondiente.

store/features/person/asyncActions.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
import * as useCases from "app/person";
import { IPerson } from "domain/Person";
import { isAxiosError } from "@architecture-it/core";
import { RootState } from "store";

export const addPerson = createAsyncThunk<IPerson, IPerson>(
  "person/add",
  async (user, { rejectWithValue, signal }) => {
    try {
      const id = await useCases.add(user, signal);

      if (typeof id === "undefined") {
        throw new Error("useCase Add result on undefined");
      }

      return {
        ...user,
        id,
      };
    } catch (err) {
      let errorMessage = err;

      if (isAxiosError(err)) {
        errorMessage = err.response?.data ?? "";
      }

      return rejectWithValue(errorMessage);
    }
  },
  {
    //condición para permitir o no la acción asíncrona.\
    // ver más en https://redux-toolkit.js.org/api/createAsyncThunk#cancellation
    condition: (_, { getState }) => {
      const { person } = getState() as RootState;

      return !person.isLoading;
    },
  }
);

export const getAllPerson = createAsyncThunk<IPerson[], undefined>(
  "person/getAll",
  async (_, { rejectWithValue, signal }) => {
    try {
      const data = await useCases.getAll(signal);

      return data;
    } catch (err) {
      let errorMessage = err;

      if (isAxiosError(err)) {
        errorMessage = err.response?.data ?? "";
      }

      return rejectWithValue(errorMessage);
    }
  },
  {
    condition: (_, { getState }) => {
      const { person } = getState() as RootState;

      return !person.isLoading;
    },
  }
);

export const editPerson = createAsyncThunk<IPerson, IPerson>(
  "person/edit",
  async (user, { rejectWithValue, signal }) => {
    try {
      const id = await useCases.add(user, signal);

      if (typeof id === "undefined") {
        throw new Error("useCase Add result on undefined");
      }

      return {
        ...user,
        id,
      };
    } catch (err) {
      let errorMessage = err;

      if (isAxiosError(err)) {
        errorMessage = err.response?.data ?? "";
      }

      return rejectWithValue(errorMessage);
    }
  },
  {
    condition: (_, { getState }) => {
      const { person } = getState() as RootState;

      return !person.isLoading;
    },
  }
);

export const removePerson = createAsyncThunk<IPerson, IPerson>(
  "person/remove",
  async (user, { rejectWithValue, signal }) => {
    try {
      const id = await useCases.add(user, signal);

      if (typeof id === "undefined") {
        throw new Error("useCase Add result on undefined");
      }

      return {
        ...user,
        id,
      };
    } catch (err) {
      let errorMessage = err;

      if (isAxiosError(err)) {
        errorMessage = err.response?.data ?? "";
      }

      return rejectWithValue(errorMessage);
    }
  },
  {
    condition: (_, { getState }) => {
      const { person } = getState() as RootState;

      return !person.isLoading;
    },
  }
);

Por lo tanto nuestra implementación del slice o porción del estado global sería similar a

app/person/slice.ts
import { createSlice } from "@reduxjs/toolkit";
import {
  type IState,
  pendingSimpleCallbackCase,
  fullfiledSimpleCallbackCase,
  rejectCallbackCase,
} from "@architecture-it/core";
import { IPerson } from "domain/Person";
import type { RootState } from "store";

import { addPerson, getAllPerson } from "./asyncActions";

interface IPersonState extends IState<IPerson[]> {}

export const initialState: IPersonState = {
  data: [],
  isLoading: false,
  error: null,
};

const personSlice = createSlice({
  name: "person",
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    //get
    builder.addCase(getAllPerson.pending, (state) => pendingSimpleCallbackCase(state));
    builder.addCase(getAllPerson.rejected, (state, action) => rejectCallbackCase(state, action));
    builder.addCase(getAllPerson.fulfilled, (state, { payload }) => {
      state.data = payload;
      fullfiledSimpleCallbackCase(state);
    });

    //add
    builder.addCase(addPerson.pending, (state) => pendingSimpleCallbackCase(state));
    builder.addCase(addPerson.rejected, (state, action) => rejectCallbackCase(state, action));
    builder.addCase(addPerson.fulfilled, (state, { payload }) => {
      state.data = [...state.data, payload];
      fullfiledSimpleCallbackCase(state);
    });
  },
});

export const selectPerson = (state: RootState) => state.person;

export default personSlice.reducer;

Como se usa en los componentes

Básicamente para utilizarlo en nuestros componentes parte de dos puntos:

  • Utilizar el hook que concentra lo necesario para el componente
  • Evitar al máximo colocar lógica de negocio en los componentes, y de ser necesario colocarla en las funciones en las peticiones, o en la capa de aplication como hooks e importarlas aca
pages/person/index.tsx
import { Skeleton } from "@mui/material";
import React, { useEffect } from "react";
import usePerson from "app/person/use-person";

export default function Person() {
  const { data, isLoading } = usePerson();

  if (isLoading) {
    return <Skeleton />;
  }

  return (
    <table>
      <thead>
        <tr>
          <td>Id</td>
          <td>Name</td>
          <td>lastName</td>
        </tr>
      </thead>
      <tbody>
        {data.map(({ id, name, lastName }) => (
          <tr key={`person-${id}`}>
            <td>{id}</td>
            <td>{name}</td>
            <td>{lastName}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Principios fundamentales dentro de las aplicaciones frontend.

Para mantener la escalabilidad y mantenimiento del código se establece seguir los siguientes principios:

Nomenclatura

  • Componentes: pascal case. Contributors.tsx, Contributors.tsx (Es necesario y limitante dentro de React)
  • Variables: camel case isAuthorized
  • constantes: snake case. USER_DEFAULT_ROLE

Debe contener x al final si posee algo de código jsx

  • Componentes: pascal case Contributors.tsx
  • Hooks: kebak case use-permissions.tsx

Estilos

  • Evitar a toda costa estilos en línea a menos que sea necesario. Preferir siempre estilos a través de CSS Modules (o Scss Modules), para garantizar el scoping de los estilos y separación de estilos del código.
// No hacer Evitar en la mayoría de lo posible
<div sx={{ width: 35 }}></div>
<div style={{ width: 35 }}></div>

// Buena práctica
import styles from "Component.module.scss";

<div className={styles.root}></div>
  • Utilizar las variables de CSS disponibles en el stylesystem.

Recordatorio

recuerda que para ver el StyleSystem necesitas estar en la VPN.

Esto nos provee la capacidad de mantenernos uniformes y estandares según el manual de marca y el design system.

Component.module.scss
.container {
  color: var(--primary); // rojo andreani
}

Para aprovechar al máximo esta funcionalidad ver cómo configurar el autocomplete de las variables de css en la siguiente sección

Principio de responsabilidad Única

  • Todo lo que pueda vivir fuera del componente, es decir, que no posea ninguna dependencia con el ciclo de vida de React, debe vivir fuera del componente. Con esto logramos separación de responsabilidades y mejoras con respecto a performance.
Component.tsx
// No hacer
export default function Component() {

//esta funcion no tiene ninguna dependencia del ciclo de vida
const expensiveFunction = () => {

}
  return (
    //app
  )
}

// Preferir
import expensiveFunction from "./utils";

export default function Component() {
  return (
    //app
  )
}