Usando Redux Toolkit y Redux Saga para crear una aplicación con React - Parte III

Este artículo forma parte de una serie de posts donde se describe la implementación de una aplicación en React para manejar un catálogo de cursos usando Redux Toolkit y Redux Saga.

En la primera parte se describió el modelo de flujo de datos que sigue React al usar Redux y Redux Saga. En la segunda parte se realizó la primera parte de la implementación del catálogo de cursos, centrado en la obtención de la información inicial.

En esta última parte se observará la implementación de la interacción del usuario al agregar cursos disponibles en el catálogo a “Mis Cursos”, que en el código se le hare referencia como “curriculum”.

La interacción se muestra en la animación a continuación.

Añadiendo cursos

El código completo utilizado en la serie reside en Gitlab: https://gitlab.com/raymundo.vr/curseca

Implementación

Comenzaremos creando un nuevo reducer que se encargará del manejo del estado del curriculum. Dentro tenemos las siguientes acciones:

  • addCourseToCurriculum: se encarga de reaccionar ante la petición de agregar un curso a “Mis Cursos”. Recibe como parámetro el identificador del curso. Esta acción está acompañada de su complemento en la saga para realizar el llamado a la API.
  • curriculumUpdated: se encarga de actualizar el estado con la nueva lista en “mis cursos” que recibe.
  • curriculumError: se encarga de indicar el estado que hubo un error al realizar una acción para actualizar el estado del catálogo.
  • fetchCurriculum: se encarga de indicar en el estado que la petición para recuperar la lista de cursos en el catálogo está en curso. También tiene su complemento en la saga.
// src/state/features/curriculum/reducers.ts

...

export const curriculumSlice = createSlice({
    name: 'curriculum',
    initialState,
    reducers: {
        addCourseToCurriculum: (state, action: PayloadAction<string>) => {
            state.isLoading = true;
        },
        curriculumUpdated: (state, action: PayloadAction<Course[]>) => {
            state.isLoading = false;
            state.hasError = false;
            state.courses = action.payload;
        },
        curriculumError: (state) => {
            state.isLoading = false;
            state.hasError = true;
        },
        fetchCurriculum: (state) => {
            state.isLoading = true;
        },
    }
});

...
export default curriculumSlice.reducer; 

Para complementar las acciones que dependen de la interacción con la API se crean las correspondientes funciones en una nueva saga.

  • addToCurriculum: realiza una petición a la API para que incluya un nuevo curso en el currículum. Recibe como parámetro el identificador de dicho curso.
  • loadCurriculum: realiza la petición para obtener la lista de cursos en el currículum.

En el watcher de la saga hemos indicado que todas las peticiones para añadir un curso serán asignadas a addToCurriculum (takeEvery) y que sólo la última petición para obtener la lista de cursos del currículum será completada con loadCurriculum (takeLatest).

// src/state/features/curriculum/reducers.ts

...

export function* addToCurriculum(action: PayloadAction<string>) {
    try {
        const result: Course[] = yield call(addToMyCourses, action.payload);
        yield put(curriculumUpdated(result));
    } catch (err) {
        yield put(curriculumError());
    }
}

export function* loadCurriculum() {
    try {
        const myCourses: Course[] = yield call(getMyCourses);
        yield put(curriculumUpdated(myCourses));
    } catch (err) {
        yield put(curriculumError());
    }
}

export default function* curriculumSaga() {
    yield takeEvery(addCourseToCurriculum.type, addToCurriculum);
    yield takeLatest(fetchCurriculum.type, loadCurriculum);
}

Ahora definiremos un test que validará el comportamiento. En esta ocasión dicho comportamiento es reflejado en varios componentes dentro de la aplicación, es por ello que crearemos un test para el component App.tsx. En el test observaremos lo siguiente:

  1. Se carga la lista de cursos en el catálogo.
  2. Se encuentra al botón para añadir al curso con identificador test-1 al currículum y se hace click en él.
  3. Se valida que el botón para añadir haya sido reemplazado por un símbolo que indique que el curso ya está en el currículum.
  4. Se valida que el conteo en “Mis cursos” muestre ahora “1”.
// src/tests/App.test.tsx
...

    it(**"Debe añadir un curso al currículum al hacer click en el botón +, deshabilitarlo y aumentar el conteo de mis cursos"**, async () => {
        const courses: Course[] = [
            {
                id: 'test-1',
                name: 'Test Course One',
                description: 'Test description'
            },
            {
                id: 'test-2',
                name: 'Test Course Two',
                description: 'Test description'
            },
        ];
        server.use(
            rest.get('/courses', (req, res, ctx) => res(ctx.json(courses))),
            rest.post('/curriculum/:courseId', (req, res, ctx) => {
                return res(ctx.json([
                    {
                        id: 'test-1',
                        name: 'Test Course One',
                        description: 'Test description'
                    }
                ]))
            }),
        );

        renderWithWrapper(<App />);
        await waitFor(() => { screen.getByRole('main'); });
fireEvent.click(screen.getByTestId('add-course-test-1'));
await waitFor(() => { screen.getByTestId('added-course-test-1'); });
        const addCourseOne = screen.queryByTestId('add-course-test-1');
        expect(addCourseOne).not.toBeInTheDocument();
const badge = screen.getByTestId('mycourses-badge');
        const count = within(badge).getByText("1");
        expect(count).toBeDefined();
});
...

Finalmente en la aplicación hacemos uso de las acciones y los reducers para implementar este comportamiento.

  • Los selectors del reducer para el currículum son usados para obtener la lista de mis cursos y saber si dicha lista está cargando.
  • La función addCourseToMyCurriculum envuelve a la ejecución de la acción addCourseToCurriculum en el reducer. Recibe como argumento el identificador del curso y simplemente lo reenvía a dicha acción.
  • Para determinar si el curso ha sido añadido simplemente tratamos de localizar dicho curso dentro de myCourses. Esto permite que cuando el curso no está en el currículum el botón para añadir se muestre, de lo contrario el ícono con la palomita.
// **src/screens/Catalogue.tsx**

...
const Catalogue = () => {
    ...
    const isCurriculumLoading = useSelector(curriculumIsLoading);
    const myCourses = useSelector(curriculumCourses);
    const dispatch = useDispatch();
    const [showCatalogue, setShowCatalogue] = useState(false);

    useEffect(() => {
        dispatch(fetchCatalogue());
        dispatch(fetchCurriculum());
    }, []);

    const addCourseToMyCurriculum = (id: string) => {
        dispatch(addCourseToCurriculum(id));
    };

    useEffect(() => {
        setShowCatalogue(!isCatalogueLoading && !isCurriculumLoading);
    }, [isCatalogueLoading, isCurriculumLoading]);

    return (
        ...

            {showCatalogue && (
                <div className="div__catalogue" role="main">
                    ...
                            {coursesInCatalogue.map((course) => (
                                <React.Fragment key={course.id}>
                                    <CourseItem
                                        alreadyInMyCurriculum={ !!myCourses.find(c => c.id === course.id) } course={course} handleAddAction={addCourseToMyCurriculum}
                                    />
                                </React.Fragment>
                            ))}
                       ...
                </div>
        ...
    );
};

export default Catalogue;

// **src/common/components/CourseItem.tsx**
...
return(
...
             <ListItemAvatar>
                {alreadyInMyCurriculum && <Check data-testid={`added-course-${course.id}`} />}
                {!alreadyInMyCurriculum && (
                    <IconButton color="primary" aria-label="add course" data-testid={`add-course-${course.id}`} disabled={isDisabled} onClick={() => {
                            setIsDisabled(true);
                            handleAddAction(course.id);
                        }}
                    >
                        <AddBoxRounded />
                    </IconButton>
                )}
            </ListItemAvatar>
...
);
... 

Finalmente para mostrar el conteo en el badge hacemos uso de un selector que nos indica cuántos cursos tenemos en el currículum.

// src/common/components/Navigation.tsx
...
const Navigation = () => {
...
    const myCoursesCount = useSelector(curriculumCoursesCount);

    return (
        ...
            <BottomNavigationAction label="Mis Cursos" icon={
                    <Badge badgeContent={myCoursesCount} color="primary" data-testid="mycourses-badge">
                    <FavoriteIcon />
                    </Badge>
                } component={Link} to='/mis-cursos'
            />
        ...
    );
};

export default Navigation; 

Conclusión

En esta pequeña serie hemos implementado una aplicación en React que permite manejar un catálogo de cursos usando Redux Toolkit y Redux Saga.

El uso de Redux Toolkit nos permite mantener un patrón uniforme en la definición del estado global dentro de una aplicación React. Para manipular las llamadas asíncronas al servidor podemos hacer uso de Redux Saga el cual estará escuchando a las acciones que la aplicación lanza y por lo cual funcionará como middleware de la store de Redux.

Para validar el comportamiento de los componentes, y de la aplicación en general, podemos hacer uso de testing-library y jest como librerías para realizar los tests. Dado que queremos verificar el comportamiento desde la misma perspectiva del usuario utilizamos Mock Service Worker para emular las respuestas obtenidas del servidor y obtener así los resultados correspondientes en pantalla.

Todo esto puede ser observado en el proyecto de muestra que está hospedado en Gitlab.

https://gitlab.com/raymundo.vr/curseca



Comments:

Usando Redux Toolkit y Redux Saga para crear una aplicación con React – Parte I – Blog de Raymundo Vásquez Ruiz -

[…] La tercera parte muestra la implementación de acciones y tests que requieren de interacción con el usuario. […]


#### [Usando Redux Toolkit y Redux Saga para crear una aplicación con React – Parte II – Blog de Raymundo Vásquez Ruiz](https://blog.vasquezruiz.me/react-redux-toolkit-saga-ii/ "") -

[…] la tercera parte se observa cómo se implementan acciones que requieren interacción del usario y sus […]