https://d226lax1qjow5r.cloudfront.net/blog/blogposts/how-to-build-a-learning-platform-using-vonage-javascript-typescript-react-express-and-apollo-graphql/blog_remote-learning-platform_1200x600.jpg

Cómo construir una plataforma de aprendizaje con React, Express y Apollo GraphQL

Publicado el December 15, 2020

Tiempo de lectura: 18 minutos

2020 ha sido un año atípico para todos. Muchos sectores han tenido que "replantearse" su forma de hacer negocios y lo más probable es que estas estrategias no sean temporales, sino que hayan llegado para quedarse.

Uno de estos cambios es la forma en que aprendemos. Muchas escuelas, universidades y academias de todo el mundo han experimentado un aumento de los servicios a distancia, recurriendo a menudo a soluciones privadas para proporcionarlos.

Hoy veremos cómo crear nuestra propia plataforma de aprendizaje con funciones de vídeo y audio, notificaciones por SMS y autenticación sin contraseña.

Requisitos previos

Para crear y ejecutar la aplicación necesitarás los siguientes recursos:

Vonage API Account

To complete this tutorial, you will need a Vonage API account. If you don’t have one already, you can sign up today and start building with free credit. Once you have an account, you can find your API Key and API Secret at the top of the Vonage API Dashboard.

Qué construiremos

Construiremos una aplicación web que permita a los profesores crear clases instantáneas de vídeo/audio a las que un alumno pueda unirse con sólo el enlace. Los profesores podrán crear una lista de alumnos, identificados por sus Numbers de teléfono, y más tarde podrán enviarles el enlace para la llamada a través de SMS.

El profesor también puede crear tareas. Posteriormente, los alumnos pueden identificarse mediante autenticación sin contraseña y cargar archivos que el profesor puede revisar más tarde.

Para simplificar las cosas y ahorrar tiempo, se han omitido algunas funciones, como la autenticación (inicio y cierre de sesión) y una base de datos real. En su lugar, todas las páginas son de acceso público y los datos se almacenan en memoria mediante matrices JavaScript.

Si estás interesado en experimentar el producto final por ti mismo, he creado un repositorio Github que puedes clonar localmente. El repositorio tiene una carpeta final donde puedes ver el ejemplo terminado, y una carpeta starter con React, Express y Apollo GraphQL ya preconfigurados, que puedes usar para seguirlo y construirlo paso a paso.

El código de la demo está dividido en una server que contiene una carpeta Apollo GraphQL Server con Expressy una carpeta client que contiene un servidor React básica. El código del backend está escrito en JavaScript plano mientras que el frontend usa TypeScript, de esta forma si no estás familiarizado con las diferencias entre estos dos puedes compararlos uno al lado del otro.

Antes de empezar, asegúrese de ir a cada carpeta e instalar las dependencias utilizando npm, como se muestra a continuación:

cd server/ npm install cd ../client npm install

También es necesario configurar los secretos. En la carpeta client todo lo que tienes que hacer es renombrar el archivo .env a .env.local.

En la carpeta server por otra parte, cambie el nombre del archivo app.envs a .env también debes reemplazar los valores de marcador de posición del archivo por tus propias claves de AWS, nombre de S3 Bucket, claves de Vonage y número virtual de Vonage.

Si desea ejecutar el producto terminado, abra dos ventanas de terminal separadas y utilice npm para iniciar ambas aplicaciones como se muestra a continuación:

# In Terminal 1 start the server in development mode cd server/ npm run dev # In terminal 2 use the already-configured react-scripts script to run the react application cd client/ npm start

Se abrirá automáticamente una ventana del navegador y allí verá la aplicación en acción. Lo primero que verás es una ventana con un botón que te permite crear una clase. Antes de entrar en materia, dirígete a la Students y crea un par de alumnos utilizando números de teléfono válidos.

The Students PageThe Students Page

Ahora, vuelva a la pantalla principal haciendo clic en el título de la aplicación. A continuación, inicie una nueva clase.

Luego de un par de segundos, estarás en una sesión de la Video API de Vonage. Usando la lista de estudiantes, puedes enviar a los estudiantes una notificación por SMS para que puedan unirse a la clase con sólo hacer clic en el botón Invite botón .

Starting a classStarting a class

Supongamos que quieres crear una tarea en la que los estudiantes tengan que subir un documento PDF. Puedes hacerlo de forma que no sea necesario que tengan una cuenta real, sino que puedan autenticarse simplemente usando su teléfono.

Para ello, diríjase a la página Homeworks y cree una nueva tarea estableciendo una descripción. A continuación, como alumno, haga clic en el Upload enlace.

Creating an AssignmentCreating an Assignment

Para cargar el archivo, el alumno debe proporcionar el mismo número de teléfono que utilizó el profesor en el momento de la creación. Se enviará un código de verificación al número de teléfono y, tras introducirlo en la aplicación, el alumno podrá cargar el archivo.

Passwordless LoginPasswordless Login

Uploading a FileUploading a File

El profesor puede ver los archivos que cada alumno ha subido por tarea haciendo clic en el UUID generado automáticamente de la tarea.

Seeing assignmentsSeeing assignments

Familiarizarse con el código de inicio

Si quieres seguirnos pero no estás familiarizado con algunas de las tecnologías utilizadas aquí, te hemos cubierto. En esta sección, vamos a describir brevemente lo que son, cómo se configura en el código de inicio, y proporcionar algunos enlaces útiles para que pueda obtener más información. Si ya eres un profesional con GraphQL y React, entonces puedes saltarte esta sección e ir directamente a crear clasesaunque es posible que quieras leerla de todos modos para saber cómo encajan estas piezas en el código de demostración.

Apolo GraphQL

GraphQL proporciona un lenguaje de consulta y un tiempo de ejecución para consultar datos de un servidor (normalmente de múltiples fuentes). Permite describir claramente los datos y da al cliente el poder de preguntar exactamente lo que necesita.

Apollo GraphQL es una implementación estándar del sector de GraphQL. Proporciona bibliotecas de servidor y cliente que permiten combinar y consumir fácilmente bases de datos, API y microservicios en un único gráfico.

La carpeta del servidor está compuesta por un servidor GraphQL alimentado por Express. La configuración se encuentra en el archivo server/index.js archivo. Las piezas más importantes de la configuración son las Definiciones de Tipo y los Resolvers.

Type Definitions es donde GraphQL describe los datos que un cliente puede consumir. Esto se hace utilizando tipos. Las definiciones de tipos se configuran en el archivo server/src/typeDefs.js archivo. A continuación se muestran algunos ejemplos de los tipos para el código de demostración:

type Student {
  phoneNumber: String!
  firstName: String!
  lastName: String!
}

type Homework {
  uuid: String!
  description: String!
}

Los tipos más importantes son el Query y Mutation que exponen las "consultas" y "mutaciones" que un cliente puede realizar con los datos.

A continuación se muestran las consultas y mutaciones definidas para el código de demostración:

type Query {
  homeworks: [Homework]
  homework(uuid: String): Homework
  homeworkFiles(uuid: String): [HomeworkFile] 
  sessionDetails(uuid: String) : SessionResponse
  students: [Student]
  student(phoneNumber: String): Student
}

type Mutation {
  addHomeworkFile(url: String!, uuid: String!, token: String!): HomeworkFile
  checkCode(requestId: String!, code: String!, number: String!): CheckCodeResponse
  inviteStudent(phoneNumber: String!, url: String!): OperationResponse
  presignDocument(fileName: String!, isPublic: Boolean, token: String!): String!
  saveHomework(id: Int, description: String!): Homework
  saveStudent(
    id: Int
    phoneNumber: String!
    firstName: String!
    lastName: String!
  ): Student
  startVideocallSession: SessionResponse
  verifyRequest(number: String!): VerifyRequestResponse
}

Lo bueno de GraphQL es que tú defines el comportamiento de estas consultas y mutaciones usando tu propio código personalizado, lo que te permite recuperar la información de múltiples bases de datos, APIs REST, o incluso de otros servidores GraphQL. Ese código personalizado que creas se conoce como resolvers.

En el código de demostración, los resolvers se asignan a cada consulta y mutación en el archivo server/src/resolvers.js mientras que las funciones de resolución reales se encuentran en la carpeta server/src/graphql carpeta. Actualmente, los resolvers sólo lanzan una excepción NOT_IMPLEMENTED pero lo cambiaremos a lo largo de este artículo.

const saveHomework = async (_, { description }, __, ___) => {
  throw new Error(NOT_IMPLEMENTED);
};

Apollo también proporciona una biblioteca para código del lado del cliente que permite consumir fácilmente datos del servidor. Mantiene una caché para que el cliente no tenga que solicitar datos al servidor si los datos ya existen.

Si quieres saber más sobre GraphQL y Apollo Graphql puedes consultar los siguientes enlaces:

Reaccione

React es una biblioteca JavaScript que permite crear interfaces de usuario basadas en componentes. Cada componente puede reutilizarse y mantiene su propio estado que actualiza automáticamente la interfaz de usuario cuando se modifica.

Este proyecto utiliza componentes funcionales que proporcionan una forma sencilla pero potente de escribir componentes React. También utiliza ganchos para proporcionar funcionalidad adicional como el estado y la comunicación con el servidor.

El código de demostración muestra una aplicación React básica escrita en TypeScript. Utiliza la biblioteca Apollo Client para conectarse con el servidor y también para proporcionar una caché para almacenar los datos recuperados del servidor.

Toda la aplicación se envuelve dentro del ApolloProvider que permite el acceso a su contexto a través de todos los componentes.

ReactDOM.render(
  <ApolloProvider client={client}>
    <Pages />
  </ApolloProvider>,
  document.getElementById('root')
);

Si quieres saber más sobre React y su integración con el servidor Apollo puedes consultar los siguientes enlaces:

Creación de clases

Ok, si quieres seguirnos es hora de ensuciarnos las manos. Coge tu editor de código favorito y abre la carpeta starter carpeta. Lo primero que haremos será añadir la posibilidad de crear nuevas clases.

Ya que tenemos nuestro código dividido en código de servidor y de navegador, tiene sentido comenzar a configurar el código backend antes de trabajar en lo que verá el usuario. Así que vamos a empezar haciendo una mutación GraphQL que cree una sesión en el servicio Video API de Vonage.

Creación del servicio API de Video de Vonage y del Resolver

Para crear una sesión de audio/video en la Video API de Vonage utilizaremos el paquete opentok que ya está instalado. Lo primero que tenemos que hacer es inicializar el cliente pasando la clave API y el par secreto.

En el archivo server/src/services/vonage/videoApi.js rellenemos la función initializeOpentok función. Devolveremos una instancia única de la variable opentok esto asegurará que se devuelva la misma instancia cada vez que llamemos a la función. Nótese cómo estamos importando la clave y el secreto que definimos previamente como una variable de entorno utilizando las etiquetas apiKey y apiSecret de un archivo ../../utils/envs ya configurado.

const { vonageVideoApiKey : apiKey, vonageVideoApiSecret : apiSecret } = require('../../util/envs');

let opentok = null;
...
const initializeOpentok = () => {
  opentok = opentok ? opentok : new OpenTok(apiKey, apiSecret);
}

El siguiente paso es crear la sesión. Para ello utilizaremos la función opentok.createSession . Esta función recibe un objeto que establece la sesión como routed. A routed sesión significa que utilizaremos los servidores de medios de Vonage, lo que permite disminuir el uso de ancho de banda en sesiones multipartitas y también nos permite habilitar capacidades avanzadas como grabaciones e interconexión SIP.

// server/src/services/vonage/videoApi.js
...
const opentokSessionArgs = {
  mediaMode: 'routed',
};
...
const createSession = () => {
  return new Promise((resolve, reject) => {
    opentok.createSession(opentokSessionArgs, (err, session) => {
      if (err) {
        reject(err);
      } else {
        resolve(session);
      }
    });
  });
};

Por último, añadiremos una función para generar tokens JWT que se utilizarán para autenticar usuarios en el contexto de una sesión y también para establecer permisos.

// server/src/services/vonage/videoApi.js
...
const generateToken = (sessionId) => {
  return opentok.generateToken(sessionId);
}
...

Ahora que tenemos la funcionalidad en su lugar, todo lo que queda es exponer realmente que a los clientes. Para ello, crearemos un par de mutaciones que el cliente React puede consumir para permitir a los profesores crear sesiones y a los estudiantes unirse a ellas.

Abramos el archivo server/src/graphql/videoApi.js y rellenemos los marcadores de posición.

Para crear sesiones seguiremos estos pasos:

  1. Inicializar el cliente opentok.

  2. Crea la sesión.

  3. Generar un ID para la sesión que se utilizará como parte de la URL. Para ello utilizaremos el paquete uuid paquete npm.

  4. Guardar la sesión en almacenamiento persistente. Para simplificar las cosas, almacenaremos las cosas en memoria utilizando matrices definidas en server/src/services/db/index.jspero en una aplicación del mundo real, una base de datos real tiene más sentido.

  5. Generar un token para la sesión.

  6. Devuelve los datos, respetando el formato definido en la definición del tipo para la respuesta de mutación.

// server/src/graphql/videoApi.js
...
const startSession = async (_, __, ___, ____) => {
  try {
    // initialize opentok
    initializeOpentok();

    // create the session
    const session = await createSession();

    // generate an id
    const uuid = uuidv4();

    // save the session
    videocalls.push({
      uuid,
      sessionId: session.sessionId,
    });

    // generate a token with moderator privileges
    const token = session.generateToken({
      role: 'moderator',
      data: `role=moderator`,
    });

    // return date honoring the format for SessionResponse type
    return {
      uuid,
      token,
      session: session.sessionId,
      apiKey,
    };
  } catch (e) {
    console.error('An error occurredocurred when creating opentok session', e);
  }
};
...

La mutación para iniciar la sesión, junto con el tipo de respuesta, ya está definida en server/src/typeDefs.js.

// server/src/typeDefs.js
...
type SessionResponse {
  uuid: String!
  token: String!
  session: String!
  apiKey: String!
}
...
type Mutation {
  ...
  startVideocallSession: SessionResponse
  ...
}

La función de resolución también está ya asignada. Podemos verlo en el archivo server/src/resolver.js fichero:

// server/src/resolvers.js
const {
  ...,
  // here we reference the startSession function we have just created
  videoApi: { joinSession, startSession },
} = require('./graphql');
...
const resolvers = {
  ...
  Mutation: {
    ...
    // here we assign that function as the resolver for the startVideocallSession mutation
    startVideocallSession: startSession,
    ...
  },
};

A continuación, debemos crear una función de resolución que permita a los alumnos unirse a una sesión ya creada. Para ello, estos son los pasos que seguiremos:

  1. Comprueba que se ha proporcionado un UUID.

  2. Busca la videollamada en la base de datos.

  3. Inicializar el cliente opentok.

  4. Utiliza la sesión para generar un token para el alumno.

  5. Devuelve los datos, respetando el formato establecido en la definición del tipo para la mutación.

// server/src/resolvers.js
const {
  ...,
  // here we reference the startSession function we have just created
  videoApi: { joinSession, startSession },
} = require('./graphql');
...
const resolvers = {
  ...
  Mutation: {
    ...
    // here we assign that function as the resolver for the startVideocallSession mutation
    startVideocallSession: startSession,
    ...
  },
};

Igual que con la función anterior, el resolver ya está conectado con la definición del tipo. La única diferencia es que esta vez en lugar de una mutación, es una consulta.

// server/src/typeDefs.js
type Query {
  ...
  sessionDetails(uuid: String) : SessionResponse
  ...
}

// server/src/resolvers.js
const {
...
  videoApi: { joinSession, startSession },
} = require('./graphql');
...
const resolvers = {
  Query: {
    sessionDetails: joinSession,
  ...
  },
};

Ahora estamos listos para construir la interfaz de usuario.

Añadir la interfaz de usuario

En primer lugar, vamos a crear un par de componentes React. Dentro de la carpeta client/src/components/ creamos una nueva carpeta Videocall carpeta.

Ahora cree un archivo llamado Room.tsx dentro de la carpeta recién creada. Este es el componente que alojará la sesión.

Para construir el componente usaremos el paquete opentok-react paquete npm. El componente recibirá una propiedad uuid que se utilizará en la consulta para recuperar la información sobre la sesión.

// client/src/components/Videocall/Room.tsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { GET_START_CALL_SETTINGS } from '../../data/queries';
import { OTSession, OTPublisher, OTStreams, OTSubscriber } from 'opentok-react';

const Room = (props: any) => {
  // get the uuid from props
  const { uuid } = props;

  // make a query to the server (or not??? we'll talk about this later)
  const { data, loading, error } = useQuery(GET_START_CALL_SETTINGS, {
    variables: { uuid },
  });

  if (loading) {
    return <p>Loading...</p>;
  }

  if (error) {
    return <p>Error!</p>;
  }

  // after the query is complete, get the session details...
  const { apiKey, session, token } = data.sessionDetails;

  // ... and pass them to opentok-react
  return (
    <OTSession apiKey={apiKey} sessionId={session} token={token}>
      <OTPublisher />
      <OTStreams>
        <OTSubscriber />
      </OTStreams>
    </OTSession>
  );
};

export default Room;

A continuación, vamos a añadir un botón para crear la sesión. Aquí exploraremos una potente característica del cliente Apollo: la caché.

Actualmente, el componente Sala intenta recuperar los detalles de la sesión del servidor basándose en el UUID de una sesión ya creada.

Dado que también obtenemos esos mismos datos al crear la sesión, no tiene sentido hacer una segunda petición al unirnos. En su lugar lo escribiremos en la caché para que el componente Room pueda obtenerla de ahí y no tenga que hacer una nueva petición al servidor.

Cree un StartButton.tsx archivo y rellénalo de la siguiente manera:

// client/src/components/Videocall/StartButton.tsx
import { useMutation } from '@apollo/client';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { START_VIDEOCALL_SESSION } from '../../data/mutations';
import { GET_START_CALL_SETTINGS } from '../../data/queries';

const startCallButton = {
  padding: '10pt',
  borderRadius: '3px',
  border: '0px',
};

const StartButton = () => {
  // we use the useMutation hook to create a mutate function
  const [startSession] = useMutation(START_VIDEOCALL_SESSION, {
    // the update() function allows to run code after running the mutation
    update(client, { data: { startVideocallSession } }) {
      // here we write the resulting data into the cache
      client.writeQuery({
        query: GET_START_CALL_SETTINGS,
        data: {
          sessionDetails: startVideocallSession
        },
        variables: {
          uuid: startVideocallSession.uuid
        }
      });
    },
    onCompleted({startVideocallSession}) {
      // after creating the session we move to a different page
      history.push(`/session/${startVideocallSession.uuid}`);
    } 
  });
  const history = useHistory();

  // we render the button and call the mutate function on click
  return (
    <div>
      <button
        style={startCallButton}
        onClick={() => {
          startSession();
        }}
      >
        Start Call
      </button>
    </div>
  );
};

export default StartButton;

Antes de añadir las páginas, vamos a crear un archivo index.tsx en client/src/components/Videocall que expondrá ambos componentes bajo la misma importación:

// src/components/Videocall/index.tsx
export { default as StartButton } from './StartButton';
export { default as Room } from './Room';

Ahora simplemente cree una nueva página bajo client/src/pages/ llamada VideoSession.tsxy añada el componente Room. Observe que no necesitamos especificar el archivo Room sino simplemente importarlo a nivel de carpeta. Esto es gracias al archivo index.tsx que acabamos de añadir

// src/pages/VideoSession.tsx
import React from 'react';
import { useParams } from 'react-router-dom';
import { Room } from '../components/Videocall';
import { VideoSessionParams } from '../models';

const VideoSession = () => {
  // get the uuid from the url
  const { uuid } = useParams<VideoSessionParams>();
  
  // render the Room component and pass the UUID property
  return (
    <>
      <p>Joining to session {uuid}</p>
      <Room uuid={uuid} />
    </>
  );
};

export default VideoSession;

A continuación, añada la ruta VideoSession en el archivo src/pages/index.tsx archivo:

// src/pages/index.tsx
...
import VideoSession from './VideoSession';

function Pages() {
  return (
    <Router>
      <Navigation />
      <div id="roots" className="p-2">
        <Route path="/session/:uuid" exact component={VideoSession} />
        <Route path="/" exact component={Inicio} />
      </div>
    </Router>
  );
}
...

Por último, añada el botón a la página src/pages/Home.tsx página:

// src/pages/Home.tsx
...
import { StartButton } from '../components/Videocall';

const Inicio = () => {
  return (
    <div>
      <StartButton />
    </div>
  );
};
...

Creación de una lista de alumnos

El siguiente paso es permitir al profesor crear una lista de estudiantes. La idea es que cuando se inicie una llamada, el profesor pueda revisar la lista y enviar notificaciones SMS a los alumnos para invitarles a la llamada.

Al igual que con las clases, empezaremos realizando las mutaciones y consultas necesarias en el servidor GraphQL. Después añadiremos la interfaz de usuario.

Configuración de mutaciones y consultas

Empecemos a trabajar en el código del servidor permitiendo a un profesor crear un estudiante. Para mantener las cosas simples vamos a almacenar los estudiantes en una matriz, pero en una aplicación del mundo real, una base de datos tendría más sentido.

Abra el archivo server/src/graphql/student.js y rellene las funciones de resolución como se indica a continuación:

// server/src/graphql/student.js
// import the "database" service
const { students } = require('../services/db');
...
const saveStudent = async (_, student, __, ___) => {
  try {
    // push the new student into the array
    students.push(student);

    // return the newly created student
    return student;
  } catch (err) {
    console.error('Error while trying to create student', err);
    throw new Error(INTERNAL_ERROR);
  }
};

const getStudents = (_, __, ___, ____) => {
  // return all the students
  return students;
};

A continuación, agreguemos la magia de Vonage para enviar notificaciones. Para ello utilizaremos el paquete @vonage/server-sdk que ya está preinstalado e inicializado como instancia singleton en el archivo server/src/services/vonage/vonage.js archivo:

// server/src/services/vonage/vonage.js
// import the npm package
const Vonage = require('@vonage/server-sdk');
// import the Vonage credentials from the environment variables
const { vonageApiKey : apiKey, vonageApiSecret : apiSecret } = require('../../util/envs');

// define the Vonage client
let instance = null;

const getVonageClient = () => {
  // if the client is not already define then initialize it
  if (!instance) {
    instance = new Vonage({
      apiKey,
      apiSecret
    });
  }

  // return the client
  return instance
}

// export the function
module.exports = {
  getVonageClient
}

Abra el server/src/services/vonage/sms.js y rellene la función sendSms como se indica a continuación:

// server/src/services/vonage/sms.js
...
const sendSms = (to, text) => {
  return new Promise((resolve, reject) => {
    // get the Vonage client
    const vonageClient = getVonageClient();
    // get the Virtual Phone Number used to send the sms
    const from = vonageSenderNumber;

    // Call the sendSms method
    vonageClient.message.sendSms(from, to, text, (err, responseData) => {
      if (err) {
        reject(false);
      } else {
        if (responseData.messages[0]['status'] === '0') {
          console.log('Message sent successfully.');
          resolve(true)
        } else {
          console.log(
            `Message failed with error: ${responseData.messages[0]['error-text']}`
          );
          reject(false);
        }
      }
    });
  });
};
...

Añadir la interfaz de usuario

En primer lugar, vamos a crear algunos componentes que reutilizaremos más adelante cuando creemos estudiantes y los invitemos a una sesión de vídeo.

Cree una nueva carpeta en client/src/components llamada Studentsy dentro de ella crea tres archivos más index.tsx, StudentForm.tsx y StudentsList.tsx.

Al crear el formulario adoptaremos un enfoque similar al utilizado al crear una clase, donde después de llamar a la mutación que crea el alumno en el servidor también estamos actualizando la caché local para evitar posteriores peticiones al servidor.

Para el formulario real utilizaremos componentes controlados para que sus valores sean gestionados por el estado de React. Como estamos usando componentes funcionales, usaremos el hook gancho useState para proporcionar un estado al formit.

Rellene el archivo StudentForm.tsx como sigue:

// client/src/components/Students/StudentForm.tsx
import { useMutation } from '@apollo/client';
import React, { useState } from 'react';
import { ADD_STUDENT } from '../../data/mutations';
import { GET_STUDENTS } from '../../data/queries';
import { Student, StudentData, StudentVars } from '../../models';

const StudentForm = () => {
  // set the state for the form
  const [phoneNumber, setPhoneNumber] = useState('');
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // create the mutate function for adding student
  const [addStudent] = useMutation<{ saveStudent: Student }, StudentVars>(
    ADD_STUDENT,
    {
      onCompleted() { // set the onCompleted function to reset the state
        setPhoneNumber('');
        setFirstName('');
        setLastName('');
      },
      update(cache, { data }) { // set the update function to update local cache
        const existingStudentsdata = cache.readQuery<StudentData>({
          query: GET_STUDENTS,
        });
        cache.writeQuery({
          query: GET_STUDENTS,
          data: {
            students: [
              ...(existingStudentsdata?.students as Student[]),
              data?.saveStudent,
            ],
          },
        });
      },
    }
  );

  // render the form
  return (
    <form
      className="form-inline"
      onSubmit={(e) => {
        e.preventDefault();
        phoneNumber &&
          firstName &&
          lastName &&
          addStudent({
            variables: {
              phoneNumber,
              firstName,
              lastName,
            },
          });
      }}
    >
      <label htmlFor="phone">
        Phone Number
      </label>
      <input
        type="text"
        id="phone"
        name="phone"
        value={phoneNumber}
        onChange={(e) => setPhoneNumber(e.target.value)}
      />

      <label htmlFor="firstName">
        First Name
      </label>
      <input
        type="text"
        id="firstName"
        name="firstName"
        value={firstName}
        onChange={(e) => setFirstName(e.target.value)}
      />

      <label htmlFor="lastName">
        Last Name
      </label>
      <input
        type="text"
        id="lastName"
        name="lastName"
        value={lastName}
        onChange={(e) => setLastName(e.target.value)}
      />

      <button type="submit">
        Submit
      </button>
    </form>
  );
};

export default StudentForm;

Al crear la lista de alumnos, añadiremos una propiedad actions que será un array de "acciones" que se pueden aplicar a un alumno.

Para cada acción, añadiremos un botón en la tabla bajo la columna "Acciones", que activará una función personalizada. Piense en acciones como "editar", "eliminar" o "desactivar". Más adelante utilizaremos esta propiedad para "invitar" a un alumno a una clase.

Rellene el archivo StudentsList.tsx como sigue:

// client/src/components/Students/StudentsList.tsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { GET_STUDENTS } from '../../data/queries';
import { Student, StudentListProps } from '../../models';

const StudentsList = ({ actions = [] }: StudentListProps) => {
  // create the query
  const { data, loading, error } = useQuery(GET_STUDENTS);

  // render the table
  return (
    <>
      {loading && <p>Loading...</p>}
      {error && <p>Error!</p>}
      {data && (
        <table>
          <thead>
          <tr>
            <th>Phone Number</th>
            <th>First Name</th>
            <th>Last Name</th>
            {actions.length > 0 && <th>Actions</th>}
          </tr>
          </thead>
          <tbody>
          {data.students.map((student: Student) => {
            return (
              <tr key={student.phoneNumber}>
                <td>{student.phoneNumber}</td>
                <td>{student.firstName}</td>
                <td>{student.lastName}</td>
                <td>
                  {actions.map((action) => (
                    <button key={action.actionName} onClick={() => action.onAction(student)}>
                      {action.actionName}
                    </button>
                  ))}
                </td>
              </tr>
            );
          })}
          </tbody>
        </table>
      )}
    </>
  );
};

export default StudentsList;

Ahora expongamos los dos componentes recién creados en el directorio index.tsx como sigue:

// client/src/components/Students/index.tsx
export {default as StudentsList} from './StudentsList';
export {default as StudentForm} from './StudentForm';

Y ahora vamos a crear la página client/src/pages/StudentPage.tsx y añadamos la ruta en el client/src/page/index.tsx índice. Fíjate en que estamos importando tanto StudentForm y StudentsList del mismo espacio de nombres. (Gracias de nuevo, index.tsx!)

// client/src/pages/StudentPage.tsx
import React from 'react';
import { StudentsList, StudentForm } from '../components/Students';

const Students = () => {
  return (
    <>
      <h1>Students</h1>
      <StudentForm />
      <StudentsList />
    </>
  );
};

export default Students;

// client/src/pages/index.tsx
...
import StudentPage from './StudentPage';

function Pages() {
  return (
    <Router>
      <Navigation />
      <div id="roots" className="p-2">
        ...
        <Route path="/students" exact component={StudentPage} />
      </div>
    </Router>
  );
}
...

Ahora deberíamos poder crear nuevos alumnos y verlos en la lista.

Invitar a los estudiantes

La idea de tener alumnos es poder invitarlos a una convocatoria. ¿Recuerdas la actions de la que hemos hablado antes? Aquí es donde esa característica brillará, ya que nos permitirá proporcionar esa funcionalidad a la lista de estudiantes mientras nos permite reutilizar el mismo componente que creamos antes.

Vamos a crear un nuevo componente llamado Attendees.tsx en client/src/components/Videocall/. En este nuevo componente, crearemos una acción personalizada que desencadenará la inviteStudent mutación.

// client/src/components/Videocall/Attendees.tsx
import { useMutation } from '@apollo/client';
import React from 'react';
import { useLocation } from 'react-router';
import { INVITE_STUDENT } from '../../data/mutations';
import { Student, StudentListAction } from '../../models';
import { StudentsList } from '../Students';

const Attendees = () => {
  // create the mutate function
  const [inviteStudent] = useMutation(INVITE_STUDENT)
  // get the session url
  const location = useLocation();

  // create the custom action in an array
  const actions = new Array<StudentListAction>(
    {
      // set a name
      actionName: 'Invite',
      // set the action
      onAction: (student: Student) => {
        inviteStudent({
          variables: {
            phoneNumber: student.phoneNumber,
            url: window.location.origin + location.pathname
          }
        })
      }
    }
  )

  // render the StudentsList and pass the action
  return (
    <>
      <StudentsList actions={actions}/>
    </>
  )
}

export default Attendees;

Añada también el componente recién creado al índice Videocall:

// src/components/Videocall/index.tsx
...
export { default as Attendees } from './Attendees';

Por último, añada el componente Attendees a la página VideoSession:

// src/pages/VideoSession.tsx
...
import { Room, Attendees } from '../components/Videocall';
...

const VideoSession = () => {
  ...
  
  return (
    <>
      ...
      <Attendees />
    </>
  );
};

Ahora crea un par de estudiantes utilizando números de teléfono válidos, inicia una clase y haz clic en el botón "invitar" para invitarlos.

Creación y envío de asignaciones

El último paso de nuestra demostración es permitir que los estudiantes envíen tareas. Para asegurarnos de que podemos identificar a qué estudiante pertenece un archivo de tareas, utilizaremos un inicio de sesión sin contraseña basado en el número de teléfono utilizado para registrar al estudiante.

Configurar mutaciones y consultas

Lo primero que tenemos que hacer es permitir la creación de deberes y archivos de deberes. También tenemos que dar a los usuarios la posibilidad de subir archivos. Vamos a utilizar un cubo de S3 con Presigned POST Requests para esto último.

Empecemos con los resolvers para crear y recuperar los archivos de tareas y deberes. Abra el server/src/graphql/homework.js en servery rellena los resolvers como sigue:

// src/graphql/homework.js
...
const saveHomework = async (_, { description }, __, ___) => {
  const uuid = uuidv4();

  try {
    const homework = {
      uuid,
      description
    }
    homeworks.push(homework);

    return homework;
  } catch (err) {
    console.error('Error while trying to create homework', err);
    throw new Error(INTERNAL_ERROR);
  }
};

const getHomeworks = (_, __, ___, ____) => {
  return homeworks;
};

const getHomework = (_, { uuid }, __, ___) => {
  const [homework] = homeworks.filter((homework) => homework.uuid === uuid);
  return homework;
};

const addHomeworkFile = async (_, { url, uuid, token }, __, ___) => {
  // This token comes from the passwordless login
  if (!token) {
    throw new Error(NOT_AUTHENTICATED);
  }

  try {
    const decodedToken = jwt.verify(token, accessTokenSecret);
    const [student] = students.filter((student) => student.phoneNumber === decodedToken.phoneNumber)
    const [homework] = homeworks.filter((homework) => homework.uuid === uuid);
    const homeworkFile = {
      url,
      student,
      homework,
    };
    homeworkFiles.push(homeworkFile);

    return homeworkFile;
  } catch (err) {
    console.log('An error occurredocurred when trying to save homework file', err);
    throw new Error(INTERNAL_ERROR);
  }
};

const getHomeworkFiles = (_, { uuid }, __, ___) => {
  return homeworkFiles.filter(homeworkFile => homeworkFile.homework.uuid === uuid);
};
...

A continuación, vamos a añadir una mutación para pre-firmar una petición POST que se puede utilizar más tarde en el código del lado del cliente para subir el archivo a S3. Para ello, vamos a utilizar el paquete aws-sdk paquete npm. El servicio ya está configurado en server/src/services/aws/s3.js.

// server/src/services/aws/s3.js
...
const presignedPostDocument = (keyName, isPublic = false) => {
  const acl = isPublic ? 'public-read' : 'private';
  return new Promise((resolve, reject) => {
    const params = {
      Bucket: s3Bucket,
      Fields: {
        Key: keyName,
      },
      Expires: 300,
      Conditions: [
        ['content-length-range', 0, 5242880],
        ['eq', '$Content-Type', 'application/pdf'],
        { acl },
      ],
    };
    s3.createPresignedPost(params, (err, data) => {
      if (err) {
        reject('Error while creating presigned post', err);
      } else {
        resolve(data);
      }
    });
  });
};
...

Así que todo lo que tenemos que hacer es consumir el servicio en una nueva mutación. Abrir el archivo server/src/graphql/s3.js y rellena la función presignDocument como sigue:

// server/src/graphql/s3.js
...
const presignDocument = async (_, { fileName, isPublic, token }, __, ___) => {
  // This token comes from the passwordless login
  if (!token) {
    throw new Error(NOT_AUTHENTICATED);
  }

  try {
    // identify the student
    const data = jwt.verify(token, accessTokenSecret);
    console.info(
      `Student with id ${data.id} is presigning filename ${fileName}`
    );
    // presign the post requests
    const uploadData = await s3.presignedPostDocument(fileName, isPublic);
    // return the response as a stringified JSON
    return JSON.stringify(uploadData);
  } catch (err) {
    console.log('An error ocurred when presigning document:', err);
    throw new Error(INTERNAL_ERROR);
  }
};
...

Ahora es el momento de configurar la magia de Vonage para la autenticación sin contraseña. Para ello, utilizaremos Verify API. Primero, creemos el servicio. Abre el archivo server/src/services/vonage/verify.js y rellena los campos verifyRequest y checkCode como sigue:

// server/src/services/vonage/verify.js
...
const verifyRequest = (number) => {
  return new Promise((resolve, reject) => {
    // get the Vonage client
    const vonageClient = getVonageClient();
    const brand = 'Vonage APIs';

    // Create a verification request for the given number
    vonageClient.verify.request({number, brand}, (err, result) => {
      if (err) {
        reject(false);
      } else {
        // return the request id which will be used when verifying the code
        resolve(result.request_id);
      }
    });
  });
};

const checkCode = (code, request_id) => {
  return new Promise((resolve, reject) => {
    // get the Vonage client
    const vonageClient = getVonageClient();
    
    // here pass both the request id and the code sent by the student
    vonageClient.verify.check({
      request_id,
      code
    }, (err, result) => {
      if (err) {
        reject(false);
      } else {
        // if code is correct we authenticate the student
        if (result.status === '0') {
          resolve(true);
        } else {
          reject(false);
        }
      }
    });
  })
}
...

Por último, vamos a exponer estos servicios a través de GraphQL. Abre el archivo server/src/graphql/vonage.js y rellena los campos verifyRequestResolver y checkCodeResolver como sigue:

// server/src/graphql/vonage.js
...
const verifyRequestResolver = async (_, { number }, __, ___) => {
  try {
    const requestId = await verifyRequest(number);
    return {
      requestId,
    };
  } catch (err) {
    console.error(err);
    throw new Error(INTERNAL_ERROR);
  }
};

const checkCodeResolver = async (_, { requestId, code, number }, __, ___) => {
  try {
    const result = await checkCode(code, requestId);

    // if verification is successful, we return a JWT token
    if (result) {
      const [student] = students.filter(
        (student) => student.phoneNumber === number
      );

      // create the token
      const token = jwt.sign(
        {
          phoneNumber: student.phoneNumber,
        },
        accessTokenSecret,
        {
          expiresIn: '15min',
        }
      );

      return {
        token,
      };
    } else {
      return {
        token: null,
      };
    }
  } catch (err) {
    console.error('An error ocurred when trying to check code', err);
    return {
      token: null,
    };
  }
};
...

Crear componentes y páginas React

Empecemos por crear un formulario para crear Tareas y una tabla sencilla para listarlas.

Cree una Homeworks carpeta en client/src/components y, a continuación, cree HomeworkForm.tsx y HomeworkList.tsx dentro de ella. Rellene el primer archivo de la siguiente manera para crear el formulario:

// client/src/components/Homeworks/HomeworkForm.tsx
import { useMutation } from '@apollo/client';
import React, { useState } from 'react';
import { Homework } from '../../models';
import { ADD_HOMEWORK } from '../../data/mutations';
import { GET_HOMEWORKS } from '../../data/queries';

const HomeworkForm = () => {
  // declare the state for the controlled form
  const [description, setDescription] = useState('');

  // setup the mutate function that creates the homework
  const [addHomework] = useMutation<
    { saveHomework: Homework },
    { description: string }
  >(ADD_HOMEWORK, {
    // on completion, reset the state
    onCompleted() {
      setDescription('');
    },
    // after creating the homework, add it to the local cache
    update(cache, { data }) {
      const existingHomeworksData = cache.readQuery<{ homeworks: Homework[] }>({
        query: GET_HOMEWORKS,
      });
      cache.writeQuery({
        query: GET_HOMEWORKS,
        data: {
          homeworks: [
            ...(existingHomeworksData?.homeworks as Homework[]),
            data?.saveHomework,
          ],
        },
      });
    },
  });

  return (
    <>
      <form
        className="form-inline"
        onSubmit={(e) => {
          // when submit the form call the mutate function
          e.preventDefault();
          description &&
            addHomework({
              variables: {
                description,
              },
            });
        }}
      >
        <label htmlFor="description">
          Description
        </label>
        <input
          type="text"
          id="description"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
        />

        <button type="submit">
          Submit
        </button>
      </form>
    </>
  );
};

export default HomeworkForm;

Y luego rellene el archivo HomeworkList.tsx de la siguiente manera para crear una tabla sencilla que enumere los deberes creados. Tenga en cuenta que también estamos estableciendo un par de Links bajo las columnas "Identificador" "Acción". Estos enlaces permitirán a un profesor revisar los archivos de una tarea determinada y permitirán a los estudiantes subir los archivos reales.

En un momento trabajaremos en las páginas que abrirán estos enlaces.

// client/src/components/Homeworks/HomeworkList.tsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { Link } from 'react-router-dom';
import { GET_HOMEWORKS } from '../../data/queries';
import { Homework } from '../../models';

const HomeworkList = () => {
  // query the list of homeworks
  const { data, loading, error } = useQuery(GET_HOMEWORKS);

  // render the component with the retrieved homework
  return (
    <>
      {loading && <p>Loading...</p>}
      {error && <p>Error!</p>}
      {data && (
        <table>
          <thead>
            <tr>
              <th>Identifier</th>
              <th>Description</th>
              <th>Actions</th>
            </tr>
          </thead>
          <tbody>
            {data.homeworks.map((homework: Homework) => {
              return (
                <tr key={homework.uuid}>
                  <td>
                    <Link to={`/homeworks/${homework.uuid}/list`}>
                      {homework.uuid}
                    </Link>
                  </td>
                  <td>{homework.description}</td>
                  <td>
                    <Link to={`/homeworks/${homework.uuid}/upload`}>
                      Upload
                    </Link>
                  </td>
                </tr>
              )
            })}
          </tbody>
        </table>
      )}
    </>
  )
}

export default HomeworkList;

Ahora, vamos a exponer los componentes recién creados mediante la creación de un archivo index.tsx en client/src/components/Homeworks con el siguiente contenido:

// client/src/components/Homeworks/index.tsx
export {default as HomeworkList} from './HomeworkList';
export {default as HomeworkForm} from './HomeworkForm';

A continuación, cree el HomeworksPage.tsx en client/src/pages/ como sigue:

// client/src/pages/HomeworksPage.tsx
import React from 'react';
import { HomeworkForm, HomeworkList } from '../components/Homeworks';

const HomeworksPage = () => {
  return (
    <>
      <HomeworkForm />
      <HomeworkList />
    </>
  )
}

export default HomeworksPage;

Y no olvides añadirlo al archivo index.tsx en la misma carpeta:

// client/src/pages/index.tsx
...
import HomeworksPage from './HomeworksPage';

function Pages() {
  return (
    <Router>
      <Navigation />
      <div id="roots" className="p-2">
        ...
        <Route path="/homeworks" exact component={HomeworksPage} />
        <Route path="/" exact component={Home} />
      </div>
    </Router>
  );
}

Inicio de sesión sin contraseña

Para el inicio de sesión sin contraseña vamos a crear dos nuevos componentes: uno que servirá como página de inicio de sesión, y otro que tendrá el formulario que los estudiantes verán después de autenticarse.

Cree PasswordlessLogin.tsx y HomeworkFileForm.tsx en client/src/components/Homeworks.

En primer lugar, vamos a centrarnos en la creación del formulario de inicio de sesión. Para ello, nuestro componente definirá una mutación para crear una solicitud de verificación y otra para realizar la verificación propiamente dicha.

La interfaz de usuario consistirá en un cuadro de texto que solicita el número de teléfono y un botón para iniciar la solicitud. Después de que requestId ha sido devuelto con éxito por el servidor queremos mostrar un campo de texto adicional para introducir el código y un botón para la verificación.

Rellene el archivo PasswordlessLogin.tsx como sigue:

// client/src/components/Homeworks/PasswordlessLogin.tsx
import { useMutation } from '@apollo/client';
import React, { useState } from 'react';
import { VERIFY_REQUEST, CHECK_CODE } from '../../data/mutations';

// the component receives a custom onLogin function that runs after a student has successfullysuccesfully authenticated
const PasswordlessLogin = ({
  onLogin,
}: {
  onLogin: (token: String) => void;
}) => {
  // setup the state for the controlled form
  const [number, setNumber] = useState('');
  const [code, setCode] = useState('');
  const [requestId, setRequestId] = useState<string | null>(null);

  // setup mutate functions for both request a verification and check the code
  const [verifyRequest] = useMutation<{
    verifyRequest: { requestId: string };
    verifyRequestVars: { number: string };
  }>(VERIFY_REQUEST, {
    // after getting the request id add it to the state
    onCompleted(data: { verifyRequest: { requestId: string } }) {
      setRequestId(data.verifyRequest.requestId);
    },
  });
  const [checkCode] = useMutation<{
    checkCode: { token: string };
    checkCodeVars: { requestId: string; code: string; number: string };
  }>(CHECK_CODE, {
    // after authenticating call the custom onLogin function
    onCompleted(data: { checkCode: { token: string } }) {
      if (data.checkCode.token) {
        onLogin(data.checkCode.token);
      }
    },
  });

  return (
    <>
      <form
        onSubmit={(e) => {
          // on submit check the code
          e.preventDefault();
          number && code && requestId && checkCode({
            variables: {
              requestId,
              code,
              number
            }
          });
        }}
      >
        <div className="form-row">
          <div className="col">
            <label htmlFor="number">Phone Number</label>
            <input
              type="text"
              className="form-control"
              id="number"
              placeholder="Enter phone number"
              value={number}
              onChange={(e) => setNumber(e.target.value)}
            />
          </div>
          <div className="col">
            <button
              onClick={(e) => {
                // when clicking this button initiate the verification
                number &&
                  verifyRequest({
                    variables: {
                      number,
                    },
                  });
              }}
            >
              Request code
            </button>
          </div>
        </div>
        {requestId && ( // only show the elements below if a requestId has been successfullysuccesfully returned by the server
          <>
            <div className="form-group">
              <label htmlFor="code">Code</label>
              <input
                type="text"
                className="form-control"
                id="code"
                placeholder="Enter one time code"
                value={code}
                onChange={(e) => setCode(e.target.value)}
              />
            </div>
            <button type="submit" className="btn btn-primary">
              Submit
            </button>
          </>
        )}
      </form>
    </>
  );
};

export default PasswordlessLogin;

A continuación, cree el formulario para subir el archivo. Este formulario diferirá ligeramente de los que hemos construido anteriormente en este tutorial porque será un formulario no controlado. Además, es necesario realizar algunos pasos adicionales para subir el archivo a S3 antes de llamar a la función mutar.

Rellene el campo HomeworkFileForm.tsx de la siguiente manera:

// client/src/component/HomeworkFileForm.tsx
import React from 'react';
import { useMutation, useQuery } from '@apollo/client';
import {
  ADD_HOMEWORK_FILE,
  PRESIGN_HOMEWORK_FILE_UPLOAD,
} from '../../data/mutations';
import { GET_HOMEWORK } from '../../data/queries';
import { Homework } from '../../models';

// this components receives the uuid of the homework and the authentication token as properties
const HomeworkFileForm = ({ uuid, token }: { uuid: string; token: string }) => {
  // create a reference of the html element 
  let homeworkFileRef: HTMLInputElement;

  // use the uuid to retrieve the information of the homework
  const { data, loading, error } = useQuery<
    { homework: Homework },
    { uuid: string }
  >(GET_HOMEWORK, {
    variables: {
      uuid
    }
  });

  // setup the mutate functions for presiging the file and for adding the reference to the database
  const [presignHomeworkFileUpload] = useMutation(PRESIGN_HOMEWORK_FILE_UPLOAD);
  const [addHomeworkFile] = useMutation(ADD_HOMEWORK_FILE);

  render the component
  return (
    <>
      {loading && <p>Loading...</p>}
      {error && <p>Error!</p>}
      {data && (
        <>
          <p>Description: {data.homework.description}</p>
          <form
            onSubmit={async (e) => {
              e.preventDefault();

              try {
                // get the file from the html element reference
                const file =
                  homeworkFileRef &&
                  homeworkFileRef.files &&
                  homeworkFileRef.files[0];

                // make sure a file was provided
                if (!file) {
                  throw new Error('file is not defined');
                }

                // get the presign informatio from the server
                const { data } = await presignHomeworkFileUpload({
                  variables: {
                    fileName: `homeworks/uuid/${Date.now()}`,
                    isPublic: true,
                    token,
                  },
                });

                // parse the stringified JSON
                const imageData = JSON.parse(data.presignDocument);

                // create a form programatically for sending the file to S3
                const formData = new FormData();

                // add the required headers
                formData.append('Content-Type', file?.type);
                formData.append('acl', 'public-read');

                // add the signing information
                Object.keys(imageData.fields).forEach((key) => {
                  formData.append(key, imageData.fields[key]);
                });

                // and finally add the file
                formData.append('file', file);

                // use fetch to send a POST requests to S3
                const result = await fetch(imageData.url, {
                  method: 'POST',
                  body: formData,
                });

                // if the file was uploaded sucessfully then add the file information to the database
                if (result.status >= 200 && result.status <= 299) {
                  addHomeworkFile({
                    variables: {
                      url: imageData.url + '/' + imageData.fields.Key,
                      uuid,
                      token,
                    },
                  });
                }
              } catch (err) {
                console.error('An error ocurred', err);
              }
            }}
          >
            <input
              id="homeworkFile"
              type="file"
              name="homeworkFile"
              ref={(node: HTMLInputElement) => (homeworkFileRef = node)}
            />
            <button type="submit">Send</button>
          </form>
        </>
      )}
    </>
  );
};

export default HomeworkFileForm;

Ahora vamos a crear una página que mostrará un componente diferente dependiendo de si el estudiante ha iniciado sesión o no. Cree el archivo client/src/pages/AddHomeworkFilePage.tsx y rellénalo como sigue:

// client/src/pages/AddHomeworkFilePage.tsx
import React, {useState} from 'react';
import { useParams } from 'react-router-dom';
import { PasswordlessLogin, HomeworkFileForm } from '../components/Homeworks';

const AddHomeworkFilePage = () => {
  // setup the state for storing the token
  const [ token, setToken ] = useState<string | null>(null);
  // get the homework uuid from the url
  const { uuid } = useParams<{ uuid: string }>();

  // if a token exists show the homework form, if not show the login form
  return token ? <HomeworkFileForm token={token as string} uuid={uuid} /> : <PasswordlessLogin onLogin={(token) => setToken(token as string)} />
}

export default AddHomeworkFilePage;

De nuevo, no olvides añadir la página recién creada a la carpeta index.tsx en la misma carpeta:

// client/src/pages/index.tsx
...
import AddHomeworkFilePage from './AddHomeworkFilePage';

function Pages() {
  return (
    <Router>
      <Navigation />
      <div id="roots" className="p-2">
        ...
        <Route path="/homeworks/:uuid/upload" exact component={AddHomeworkFilePage} />
       ...
      </div>
    </Router>
  );
}
...

Creación de una lista de los archivos de tareas

Lo último que tenemos que hacer es permitir que el profesor compruebe realmente los archivos de deberes que han enviado los alumnos. Para ello simplemente crearemos un componente HomeworkFileList similar a los que acabamos de crear para Alumnos y Tareas.

Cree un nuevo client/src/components/Homeworks/HomeworkFileList.tsx y rellénalo como se indica a continuación para crear la lista de archivos de tareas:

// client/src/components/Homeworks/HomeworkFileList.tsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { GET_HOMEWORK_FILES } from '../../data/queries';
import { HomeworkFile } from '../../models';

// the component get the uuid of the homework as property
const HomeworkFileList = ({ uuid }: { uuid: string }) => {
  // retrieve the submitted files for the given homework
  const { data, loading, error } = useQuery<
    { homeworkFiles: HomeworkFile[] },
    { uuid: string }
  >(GET_HOMEWORK_FILES, {
    variables: {
      uuid,
    },
  });

  // render the html elements
  return (
    <>
      {loading && <p>Loading...</p>}
      {error && <p>Error!</p>}
      {data && (
        <>
          <h1>Homework Files</h1>
          <p>{data.homeworkFiles.length > 0 && data.homeworkFiles[0].homework.description}</p>
          <table>
            <thead>
              <tr>
                <th>Phone Number</th>
                <th>Student Name</th>
                <th>Link</th>
              </tr>
            </thead>
            <tbody>
              {data.homeworkFiles.map((homeworkFile) => {
                return (
                  <tr key={homeworkFile.student.phoneNumber}>
                    <td>{homeworkFile.student.phoneNumber}</td>
                    <td>
                      {homeworkFile.student.firstName}
                      {homeworkFile.student.lastName}
                    </td>
                    <td>
                      <a
                        target="_blank"
                        rel="noopener noreferrer"
                        href={homeworkFile.url}
                      >
                        {homeworkFile.url}
                      </a>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </>
      )}
    </>
  );
};

export default HomeworkFileList;

Por último, cree el archivo ListHomeworkFilesPage.tsx en client/src/pages como se muestra a continuación:

// client/src/pages/ListHomeworkFilesPage.tsx
import React from 'react';
import { useParams } from 'react-router-dom';
import { HomeworkFileList } from '../components/Homeworks';

const ListHomeworkFilesPage = () => {
  const { uuid } = useParams<{uuid: string}>();

  return <HomeworkFileList uuid={uuid} />
}

export default ListHomeworkFilesPage;

Y por última vez, no olvides añadir la ruta al archivo index.tsx en la misma carpeta:

// client/src/pages/index.tsx
...
import ListHomeworkFilesPage from './ListHomeworkFilesPage';

function Pages() {
  return (
    <Router>
      <Navigation />
      <div id="roots" className="p-2">
        ...
        <Route path="/homeworks/:uuid/list" exact component={ListHomeworkFilesPage} />
        ...
      </div>
    </Router>
  );
}
...

Conclusión

Y ya está. Espero que este post te haya dado una idea de lo que puedes hacer para adaptarte a la "nueva normalidad" y de cómo las cosas geniales que se están desarrollando en Vonage pueden ayudarte a conseguirlo.

Compartir:

https://a.storyblok.com/f/270183/250x250/20ed4402f3/hector-zelaya.png
Hector Zelaya

Héctor is a Computer Systems Engineer based in El Salvador. He works on DevOps, technical writing, and QA. He has plenty of experience with web applications and cloud services. When he is not in front of the computer he loves playing music, videogames and going on adventures with the love of his life: his wife ❤️