diff --git a/docs/source/3_dev/2_structure/2_frontend/1_network.rst b/docs/source/3_dev/2_structure/2_frontend/1_network.rst deleted file mode 100644 index e69de29..0000000 diff --git a/docs/source/3_dev/2_structure/2_frontend/1_techstack.rst b/docs/source/3_dev/2_structure/2_frontend/1_techstack.rst new file mode 100644 index 0000000..01ae551 --- /dev/null +++ b/docs/source/3_dev/2_structure/2_frontend/1_techstack.rst @@ -0,0 +1,17 @@ +Librerie e tecnologie utilizzate +-------------------------------- + +.. note:: + + Sono elencate solo le principali librerie utilizzate; dipendenze e librerie minori non sono specificate, ma sono visibili all'interno del file ``yarn.lock``. + +- I linguaggi di programmazione `JavaScript `_ e `TypeScript `_ + - Il gestore di dipendenze `Yarn `_ + - La libreria grafica `Bluelib `_ (sviluppata come progetto personale nell'estate 2021) + - Il framework per interfacce grafiche `React `_ + - Il router `Reach Router `_ + - L'integrazione con React di Bluelib `bluelib-react `_ (sviluppata durante il tirocinio) + - Il componente React `react-markdown `_ + - Il framework per testing `Jest `_ + - Un fork personalizzato del client XHR `axios `_ +- Il webserver statico `serve `_ diff --git a/docs/source/3_dev/2_structure/2_frontend/2_tree.rst b/docs/source/3_dev/2_structure/2_frontend/2_tree.rst new file mode 100644 index 0000000..4c9e3e6 --- /dev/null +++ b/docs/source/3_dev/2_structure/2_frontend/2_tree.rst @@ -0,0 +1,23 @@ +Struttura delle directory +------------------------- +.. default-domain:: js + +Le directory di :mod:`@steffo45/sophon-frontend` sono strutturate nella seguente maniera: + +src/components + Contiene i componenti React sia con le classi sia funzionali. + +src/contexts + Contiene i contesti React creati con :func:`React.createContext`. + +src/hooks + Contiene gli hook React personalizzati utilizzati nei componenti funzionali. + +src/types + Contiene estensioni ai tipi base TypeScript, come ad esempio i tipi restituiti dalla web API del :ref:`modulo backend`. + +src/utils + Contiene varie funzioni di utility. + +public + Contiene i file statici da servire assieme all'app. diff --git a/docs/source/3_dev/2_structure/2_frontend/3_resources.rst b/docs/source/3_dev/2_structure/2_frontend/3_resources.rst new file mode 100644 index 0000000..4710dba --- /dev/null +++ b/docs/source/3_dev/2_structure/2_frontend/3_resources.rst @@ -0,0 +1,113 @@ +Comunicazione con il server +--------------------------- +.. default-domain:: js + + +Axios +^^^^^ + +Per effettuare richieste all'API web, si è deciso di utilizzare la libreria :mod:`axios`, in quanto permette di creare dei "client" personalizzabili con varie proprietà. + +In particolare, si è scelto di forkarla, integrando anticipatamente una proposta di funzionalità che permette alle richieste di essere interrotte attraverso degli :class:`AbortController`. + + +Client personalizzati +^^^^^^^^^^^^^^^^^^^^^ + +Per permettere all'utente di selezionare l'istanza da utilizzare e di comunicare con l'API con le proprie credenziali, si è scelto di creare client personalizzati partendo da due contesti. + +All'interno di un contesto in cui è stata selezionata un'istanza (:data:`InstanceContext`), viene creato un client dal seguente hook: + +.. function:: useInstanceAxios(config = {}) + + Questo hook specifica il ``baseURL`` del client Axios, impostandolo all'URL dell'istanza selezionata. + +All'interno di un contesto in cui è stato effettuato l'accesso come utente (:data:`AuthorizationContext`), viene creato invece un client dal seguente hook: + +.. function:: useAuthorizedAxios(config = {}) + + Questo hook specifica il valore dell'header ``Authorization`` da inviare in tutte le richieste effettuate a :samp:`Bearer {TOKEN}`, utilizzando il token ottenuto al momento dell'accesso. + + +Utilizzo di viewset +^^^^^^^^^^^^^^^^^^^ + +Viene implementato un hook che si integra con i viewset di Django, fornendo un API semplificato per effettuare azioni su di essi. + +.. function:: useViewSet(baseRoute) + + Questo hook implementa tutte le azioni :py:mod:`rest_framework` di un viewset in lettura e scrittura. + + Richiede di essere chiamato all'interno di un :data:`AuthorizationContext`. + + .. function:: async list(config = {}) + .. function:: async retrieve(pk, config = {}) + .. function:: async create(config) + .. function:: async update(pk, config) + .. function:: async destroy(pk, config) + + Viene inoltre fornito supporto per le azioni personalizzate. + + .. function:: async command(config) + + Permette azioni personalizzate su tutto il viewset. + + .. function:: async action(config) + + Permette azioni personalizzate su uno specifico oggetto del viewset. + + +Emulazione di viewset +^^^^^^^^^^^^^^^^^^^^^ + +Viene creato un hook che tiene traccia degli oggetti restituiti da un determinato viewset, ed emula i risultati delle azioni effettuate, minimizzando i rerender e ottenendo una ottima user experience. + +.. function:: useManagedViewSet(baseRoute, pkKey, refreshOnMount) + + .. attribute:: viewset + + Il viewset restituito da :func:`useViewSet`, utilizzato come interfaccia di basso livello per effettuare azioni. + + .. attribute:: state + + Lo stato del viewset, che tiene traccia degli oggetti e delle azioni in corso su di essi. + + Gli oggetti all'interno di esso sono istanze di :class:`ManagedResource`, create usando wrapper di :func:`.update`, :func:`.destroy` e :func:`.action`, che permettono di modificare direttamente l'oggetto senza preoccuparsi dell'indice a cui si trova nell'array. + + .. attribute:: dispatch + + Riduttore che permette di alterare lo :attr:`.state`. + + .. function:: async refresh() + + Ricarica gli oggetti del viewset. + + Viene chiamata automaticamente al primo render se ``refreshOnMount`` è :data:`True`. + + .. function:: async create(data) + + Crea un nuovo oggetto nel viewset con i dati specificati come argomento, e lo aggiunge allo stato se la richiesta va a buon fine. + + .. function:: async command(method, cmd, data) + + Esegue l'azione personalizzata ``cmd`` su tutto il viewset, utilizzando il metodo ``method`` e con i dati specificati in ``data``. + + Se la richiesta va a buon fine, il valore restituito dal backend sostituisce nello stato le risorse dell'intero viewset. + + .. function:: async update(index, data) + + Modifica l'oggetto alla posizione ``index`` dell'array :attr:`.state` con i dati specificati in ``data``. + + Se la richiesta va a buon fine, la modifica viene anche applicata all'interno di :attr:`.state` + + .. function:: async destroy(index) + + Elimina l'oggetto alla posizione ``index`` dell'array :attr:`.state`. + + Se la richiesta va a buon fine, l'oggetto viene eliminato anche da :attr:`.state`. + + .. function:: async action(index, method, act, data) + + Esegue l'azione personalizzata ``act`` sull'oggetto alla posizione ``index`` dell'array :attr:`.state`, utilizzando il metodo ``method`` e con i dati specificati in ``data``. + + Se la richiesta va a buon fine, il valore restituito dal backend sostituisce l'oggetto utilizzato in :attr:`.state`. diff --git a/docs/source/3_dev/2_structure/2_frontend/4_contexts.rst b/docs/source/3_dev/2_structure/2_frontend/4_contexts.rst new file mode 100644 index 0000000..767eae0 --- /dev/null +++ b/docs/source/3_dev/2_structure/2_frontend/4_contexts.rst @@ -0,0 +1,161 @@ +Contesti innestati +------------------ + +Per minimizzare i rerender, l'applicazione è organizzata a "contesti innestati". + + +I contesti +^^^^^^^^^^ + +Viene definito un contesto per ogni tipo di risorsa selezionabile nell'interfaccia. + +Essi sono, in ordine dal più esterno al più interno: + +#. :data:`InstanceContext` (:ref:`Istanza`) +#. :data:`AuthorizationContext` (:ref:`Utente`) +#. :data:`GroupContext` (:ref:`Gruppo di ricerca`) +#. :data:`ProjectContext` (:ref:`Progetto di ricerca`) +#. :data:`NotebookContext` (:ref:`Notebook`) + + +Contenuto dei contesti +"""""""""""""""""""""" + +Questi contesti possono avere tre tipi di valori: :data:`undefined` se ci si trova al di fuori del contesto, :data:`null` se non è stato selezionato alcun oggetto oppure **l'oggetto selezionato** se esso esiste. + + +URL contestuale +^^^^^^^^^^^^^^^ + +Si è definita la seguente struttura per gli URL del frontend di Sophon, in modo che essi identificassero universalmente una risorsa e che essi fossero human-readable. + +.. code-block:: text + + /i/{ISTANZA} + /l/logged-in + /g/{GROUP_SLUG} + /p/{PROJECT_SLUG} + /n/{NOTEBOOK_SLUG}/ + +Ad esempio, l'URL per il notebook ``my-first-notebook`` dell'istanza demo di Sophon sarebbe: + +.. code-block:: text + + /i/https:api.prod.sophon.steffo.eu: + /l/logged-in + /g/my-first-group + /p/my-first-project + /n/my-first-notebook/ + + +Parsing degli URL contestuali +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Viene definita una funzione in grado di comprendere gli URL contestuali: + +.. function:: parsePath(path) + + :param path: Il "path" da leggere. + :returns: + Un oggetto con le seguenti chiavi, dette "segmenti di percorso", le quali possono essere :data:`undefined` per indicare che non è stato selezionato un oggetto di quel tipo: + + - ``instance``: l'URL dell'istanza da utilizzare, con caratteri speciali sostituiti da ``:`` + - ``loggedIn``: :class:`Boolean`, se :data:`True` l'utente ha effettuato il login (come :ref:`Ospite` o :ref:`Utente`) + - ``researchGroup``: lo slug del :ref:`gruppo di ricerca` selezionato + - ``researchProject``: lo slug del :ref:`progetto di ricerca` selezionato + - ``notebook``: lo slug del :ref:`notebook` selezionato + + Ad esempio, l'URL precedente restituirebbe il seguente oggetto se processato: + + .. code-block:: js + + { + "instance": "https:api.prod.sophon.steffo.eu:", + "loggedIn": True, + "researchGroup": "my-first-group", + "researchProject": "my-first-project", + "notebook": "my-first-notebook" + } + + +Routing basato sui contesti +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +I valori dei contesti vengono utilizzati per selezionare i componenti da mostrare all'utente nell'interfaccia grafica attraverso i seguenti componenti: + +.. function:: ResourceRouter({selection, unselectedRoute, selectedRoute}) + + Componente che sceglie se renderizzare ``unselectedRoute`` o ``selectedRoute`` in base alla *nullità* o *non-nullità* di ``selection``. + +.. function:: ViewSetRouter({viewSet, unselectedRoute, selectedRoute, pathSegment, pkKey}) + + Componente basato su :func:`ResourceRouter` che seleziona automaticamente l'elemento del viewset avente il valore del segmento di percorso ``pathSegment`` alla chiave ``pkKey``. + + +Esempio di utilizzo di ViewSetRouter +"""""""""""""""""""""""""""""""""""" + +.. function:: GroupRouter({...props}) + + Implementato come: + + .. code-block:: tsx + + ("/api/core/groups/", "slug")} + pathSegment={"researchGroup"} + pkKey={"slug"} + /> + + +Albero completo dei contesti +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +L'insieme di tutti i contesti è definito come componente :func:`App` nel modulo "principale" ``App.tsx``. + +Se ne riassume la struttura in pseudocodice: + +.. code-block:: html + + + + unselected: + + selected: + + + unselected: + + selected: + + + unselected: + + selected: + + + unselected: + + selected: + + + unselected: + + selected: + + + +Altri contesti +^^^^^^^^^^^^^^ + +Tema +"""" + +Il tema dell'istanza è implementato come uno speciale contesto globale :data:`ThemeContext` che riceve i dettagli dell'istanza a cui si è collegati dall':data:`InstanceContext`. + + +Cache +""""" + +Viene salvato l'elenco di tutti i membri dell':ref:`istanza` in uno speciale contesto :data:`CacheContext` in modo da poter risolvere gli id degli utenti al loro username senza dover effettuare ulteriori richieste. diff --git a/docs/source/3_dev/2_structure/2_frontend/index.rst b/docs/source/3_dev/2_structure/2_frontend/index.rst index 1d8aedd..a710115 100644 --- a/docs/source/3_dev/2_structure/2_frontend/index.rst +++ b/docs/source/3_dev/2_structure/2_frontend/index.rst @@ -8,28 +8,12 @@ Si è cercato di renderla più user-friendly possibile, cercando di comunicare p È collocato all'interno del repository in ``/frontend``. +Il modulo è formato dal package JavaScript :mod:`@steffo45/sophon-frontend`, che contiene tutti i componenti React che assemblati insieme formano l'intera interfaccia web. -Librerie e tecnologie utilizzate --------------------------------- - -.. note:: - - Sono elencate solo le principali librerie utilizzate; dipendenze e librerie minori non sono specificate, ma sono visibili all'interno del file ``yarn.lock``. - -- I linguaggi di programmazione `JavaScript `_ e `TypeScript `_ - - Il gestore di dipendenze `Yarn `_ - - La libreria grafica `Bluelib `_ (sviluppata come progetto personale nell'estate 2021) - - Il framework per interfacce grafiche `React `_ - - Il router `Reach Router `_ - - L'integrazione con React di Bluelib `bluelib-react `_ (sviluppata durante il tirocinio) - - Il componente React `react-markdown `_ - - Il framework per testing `Jest `_ - - Un fork personalizzato del client XHR `axios `_ -- Il webserver statico `serve `_ - - -Struttura del modulo --------------------- - -Il modulo consiste nel package JavaScript :mod:`@steffo45/sophon-frontend`, che contiene tutti i componenti che assemblati insieme formano l'intera interfaccia web. +.. toctree:: + :maxdepth: 1 + 1_techstack + 2_tree + 3_resources + 4_contexts diff --git a/docs/source/3_dev/4_tests/index.rst b/docs/source/3_dev/4_tests/index.rst index 00f5d8a..6290e5a 100644 --- a/docs/source/3_dev/4_tests/index.rst +++ b/docs/source/3_dev/4_tests/index.rst @@ -1,3 +1,132 @@ Test effettuati =============== +Per motivi di tempo necessario, sono state selezionate solo alcune parti di codice su cui effettuare test. + + +Tutti i viewset di :py:mod:`sophon.core` +---------------------------------------- +.. default-domain:: py +.. default-role:: py:obj +.. module:: sophon.core.tests + + +Test case generici +^^^^^^^^^^^^^^^^^^ + +Vengono definiti alcuni test case generici per facilitare le interazioni tra ``APITestCase`` e viewset. + +.. note:: + + I nomi delle funzioni usano nomi con capitalizzazione inconsistente in quanto lo stesso modulo `unittest` non rispetta lo stile suggerito in :pep:`8`. + +.. class:: BetterAPITestCase(APITestCase) + + .. method:: as_user(self, username: str, password: str = None) -> typing.ContextManager[None] + + Context manager che permette di effettuare richieste all'API come uno specifico utente, effettuando il logout quando sono state effettuate le richieste necessarie. + + .. method:: assertData(self, data: ReturnDict, expected: dict) + + Asserzione che permette di verificare che l'oggetto restituito da una richiesta all'API contenga almeno le chiavi e i valori contenuti nel dizionario ``expected``. + +.. class:: ReadSophonTestCase(BetterAPITestCase, metaclass=abc.ABCMeta) + + Classe **astratta** che implementa metodi per testare rapidamente le azioni di un `.views.ReadSophonViewSet`. + + .. classmethod:: get_basename(cls) -> str + + Metodo **astratto** che deve restituire il basename del viewset da testare. + + .. classmethod:: get_url(cls, kind: str, *args, **kwargs) -> str + + Metodo utilizzato dal test case per trovare gli URL ai quali possono essere effettuate le varie azioni. + + I seguenti metodi permettono di effettuare azioni sul viewset: + + .. method:: list(self) -> rest_framework.response.Response + .. method:: retrieve(self, pk) -> rest_framework.response.Response + .. method:: custom_list(self, method: str, action: str, data: dict = None) -> rest_framework.response.Response + .. method:: custom_detail(self, method: str, action: str, pk, data: dict = None) -> rest_framework.response.Response + + I seguenti metodi asseriscono che una determinata azione con determinati parametri risponderà con il codice di stato ``code``, e restituiscono i dati contenuti nella risposta se l'azione è riuscita (``200 <= code < 300``) + + .. method:: assertActionList(self, code: int = 200) -> typing.Optional[ReturnDict] + .. method:: assertActionRetrieve(self, pk, code: int = 200) -> typing.Optional[ReturnDict] + .. method:: assertActionCustomList(self, method: str, action: str, data: dict = None, code: int = 200) -> typing.Optional[ReturnDict] + .. method:: assertActionCustomDetail(self, method: str, action: str, pk, data: dict = None, code: int = 200) -> typing.Optional[ReturnDict] + + +.. class:: WriteSophonTestCase(ReadSophonTestCase, metaclass=abc.ABCMeta) + + Classe **astratta** che estende `.ReadSophonTestCase` con le azioni di un `.views.WriteSophonViewSet`. + + .. method:: create(self, data) -> rest_framework.response.Response + .. method:: update(self, pk, data) -> rest_framework.response.Response + .. method:: destroy(self, pk) -> rest_framework.response.Response + + .. method:: assertActionCreate(self, data, code: int = 201) -> typing.Optional[ReturnDict] + .. method:: assertActionUpdate(self, pk, data, code: int = 200) -> typing.Optional[ReturnDict] + .. method:: assertActionDestroy(self, pk, code: int = 200) -> typing.Optional[ReturnDict] + + +Test case concreti +^^^^^^^^^^^^^^^^^^ + +Vengono testate tutte le view dell'app tramite `.BetterAPITestCase` e tutti i viewset dell'app tramite `.ReadSophonTestCase` e `WriteSophonTestCase`. + +.. class:: UsersByIdTestCase(ReadSophonTestCase) +.. class:: UsersByUsernameTestCase(ReadSophonTestCase) +.. class:: ResearchGroupTestCase(WriteSophonTestCase) +.. class:: SophonInstanceDetailsTestCase(BetterAPITestCase) + + +Alcune interazioni di `sophon.notebooks` +---------------------------------------- +.. default-domain:: py +.. default-role:: py:obj +.. module:: sophon.notebooks.tests + +Vengono definiti alcuni test case per alcune interazioni dell'app `sophon.notebooks`. + +.. class:: JupyterTestCase(TestCase) + + Test case che testa la generazione dei token per Jupyter. + +.. class:: ApacheTestCase(TestCase) + + Test case che testa la conversione in `bytes` per la rubrica `dbm` del :ref:`modulo proxy`. + + +Alcune interazioni complicate del frontend +------------------------------------------ +.. default-domain:: js +.. default-role:: js:class + +Vengono infine definiti test case per alcune interazioni ritenute particolarmente complesse del frontend. + + +Encoding dell'URL dell'istanza nell'URL della pagina +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- encodes pathless URL +- encodes URL with port number +- encodes URL with simple path +- encodes URL with colon in path +- does not encode URL with ``%3A`` in path +- decodes pathless URL +- decodes URL with port number +- decodes URL with simple path +- decodes URL with colon in path + + +Parsing dei segmenti del path +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- parses empty path +- parses instance path +- parses username path +- parses userid path +- parses research group path +- parses research project path +- parses research project path diff --git a/frontend/src/utils/ParsePath.test.js b/frontend/src/utils/ParsePath.test.js index 4efed85..683bc9a 100644 --- a/frontend/src/utils/ParsePath.test.js +++ b/frontend/src/utils/ParsePath.test.js @@ -1,58 +1,58 @@ import { parsePath } from "./ParsePath" -test("splits empty path", () => { +test("parses empty path", () => { expect( parsePath("/"), ).toMatchObject( - {} + {}, ) }) -test("splits instance path", () => { +test("parses instance path", () => { expect( parsePath("/i/https:api:sophon:steffo:eu:"), ).toMatchObject( { - instance: "https:api:sophon:steffo:eu:" - } + instance: "https:api:sophon:steffo:eu:", + }, ) }) -test("splits username path", () => { +test("parses username path", () => { expect( parsePath("/i/https:api:sophon:steffo:eu:/u/steffo"), ).toMatchObject( { instance: "https:api:sophon:steffo:eu:", userName: "steffo", - } + }, ) }) -test("splits userid path", () => { +test("parses userid path", () => { expect( parsePath("/i/https:api:sophon:steffo:eu:/u/1"), ).toMatchObject( { instance: "https:api:sophon:steffo:eu:", userId: "1", - } + }, ) }) -test("splits research group path", () => { +test("parses research group path", () => { expect( parsePath("/i/https:api:sophon:steffo:eu:/g/testers"), ).toMatchObject( { instance: "https:api:sophon:steffo:eu:", researchGroup: "testers", - } + }, ) }) -test("splits research project path", () => { +test("parses research project path", () => { expect( parsePath("/i/https:api:sophon:steffo:eu:/g/testers/p/test"), ).toMatchObject( @@ -60,11 +60,11 @@ test("splits research project path", () => { instance: "https:api:sophon:steffo:eu:", researchGroup: "testers", researchProject: "test", - } + }, ) }) -test("splits research project path", () => { +test("parses research project path", () => { expect( parsePath("/i/https:api:sophon:steffo:eu:/g/testers/p/test/n/testerino"), ).toMatchObject( @@ -73,6 +73,6 @@ test("splits research project path", () => { researchGroup: "testers", researchProject: "test", notebook: "testerino", - } + }, ) })