Author SHA1 Message Date
pierpaolo.mammi 2289f6e5be fix xdebug (taken from elixforms-web-services) 2026-07-24 15:05:17 +02:00
pierpaolo.mammi 7a015f2b70 move docker compose 2026-07-23 23:07:07 +02:00
pierpaolo.mammi 1cf674a31c switch to Guzzle module for HTTP calls 2026-07-22 13:05:17 +02:00
pierpaolo.mammi 01b22f9c2d switch to php composer 2026-07-22 12:55:08 +02:00
pierpaolo.mammi 151bc70775 refactor solution to PHP through AI 2026-07-20 14:27:17 +02:00
pierpaolo.mammi a77f6f19cf fix vite config for correct build paths 2026-07-17 16:36:55 +02:00
pierpaolo.mammi 746c43c7b4 exclude config.json and add template 2026-07-17 16:12:37 +02:00
pierpaolo.mammi 85b51dfbc4 adjust recupero contratti component configuration 2026-07-17 16:08:03 +02:00
pierpaolo.mammi 1aacb21b1b make userDisplayName optional 2026-07-17 16:08:03 +02:00
pierpaolo.mammi 1c02e3d1b2 apply custom elixforms styling 2026-07-17 16:08:03 +02:00
pierpaolo.mammi bad919a98a apply custom elixforms styling 2026-07-17 15:19:55 +02:00
pierpaolo.mammi fefb45505c move magic values to configuration file 2026-07-17 13:46:12 +02:00
pierpaolo.mammi c3d4c2a660 switch to bootstrap-italia styling 2026-07-17 13:34:40 +02:00
pierpaolo.mammi 1943252422 first update to bootstrap italia styles 2026-07-17 10:40:42 +02:00
pierpaolo.mammi 4cd22396d9 switch to bootstrap italia through AI 2026-07-17 09:53:46 +02:00
pierpaolo.mammi 99a64f1157 move submit button to the right 2026-07-17 09:40:44 +02:00
pierpaolo.mammi beee00bee3 add custom styling to common component 2026-07-16 16:09:33 +02:00
pierpaolo.mammi e404bf0954 fix autocomplete results box positioning 2026-07-16 15:36:14 +02:00
pierpaolo.mammi 50681d07c5 cleanup call to contratti API 2026-07-16 15:32:48 +02:00
pierpaolo.mammi 6b5aa556fe move field names in a single place 2026-07-14 16:20:08 +02:00
pierpaolo.mammi 2c8a24032f add missing values to mock server response 2026-07-14 16:12:40 +02:00
pierpaolo.mammi dcf28d46b5 temporary implementation to call mock server 2026-07-14 16:12:34 +02:00
pierpaolo.mammi 296c481516 fix wrong component parameter name 2026-07-14 16:12:02 +02:00
pierpaolo.mammi faedc6203a update npm packages and hopefully fix debugging 2026-07-14 16:11:22 +02:00
pierpaolo.mammi 99e868bfb4 try to restore debug functionality 2026-07-14 15:05:09 +02:00
pierpaolo.mammi 5b756cb9cd move gitignore to root 2026-07-14 15:04:43 +02:00
pierpaolo.mammi 550978a852 add basic mock server for api testing 2026-07-14 15:04:22 +02:00
administrator 0de10445f6 add new custom page: recupero-proposta-cct-da-contratti 2026-07-13 18:48:54 +02:00
70 changed files with 10834 additions and 1133 deletions
+2 -2
View File
@@ -12,7 +12,7 @@ description: >
## Panoramica ## Panoramica
La cartella `react/common/` contiene la libreria condivisa di componenti React/TypeScript usata da tutte le pagine custom. È basata su **Fluent UI React v8** e implementa un sistema di form con campi dinamici. La cartella `react/common/` contiene la libreria condivisa di componenti React/TypeScript usata da tutte le pagine custom. È basata su **Bootstrap Italia + HTML nativo** e implementa un sistema di form con campi dinamici.
## Architettura dei Componenti ## Architettura dei Componenti
@@ -103,7 +103,7 @@ var myElixFormsReact = new ElixForms.ElixFormsReact({
### Tipi di Campo Supportati ### Tipi di Campo Supportati
| Metodo Factory | Componente FluentUI | HTML Output | | Metodo Factory | Componente Bootstrap Italia | HTML Output |
|---|---|---| |---|---|---|
| `createTextInput` | `TextField` | Text input | | `createTextInput` | `TextField` | Text input |
| `createTextAreaInput` | `TextField` (multiline) | Textarea | | `createTextAreaInput` | `TextField` (multiline) | Textarea |
+2 -2
View File
@@ -50,7 +50,7 @@ npm install -D sass-embedded
} }
``` ```
> **Nota**: React, ReactDOM e FluentUI NON vanno nel package.json della pagina. > **Nota**: React e ReactDOM NON vanno nel package.json della pagina.
> Sono nel `react/package.json` root e vengono risolti via hoisting di npm. > Sono nel `react/package.json` root e vengono risolti via hoisting di npm.
## Step 3: Configurare vite.config.js ## Step 3: Configurare vite.config.js
@@ -169,7 +169,7 @@ function App() {
{/* Header, contenuto e footer della pagina */} {/* Header, contenuto e footer della pagina */}
<div className="grid_12 console_main"> <div className="grid_12 console_main">
<div className="inside"> <div className="inside">
<div className="receipt"> <div className="mainview">
<div className="container"> <div className="container">
<h1>Titolo Pagina</h1> <h1>Titolo Pagina</h1>
{elixFormsReactRender} {elixFormsReactRender}
+4 -11
View File
@@ -76,7 +76,7 @@ Le pagine seguono un layout standard elixForms con classi CSS specifiche:
<div class="fe-module-header-container"> <div class="fe-module-header-container">
<h2>Titolo Modulo</h2> <h2>Titolo Modulo</h2>
</div> </div>
<div class="receipt"> <div class="mainview">
<div class="container"> <div class="container">
<!-- Contenuto custom della pagina --> <!-- Contenuto custom della pagina -->
</div> </div>
@@ -115,7 +115,7 @@ Le pagine seguono un layout standard elixForms con classi CSS specifiche:
| `console_header` | Header della console | | `console_header` | Header della console |
| `console_main` | Area contenuto principale | | `console_main` | Area contenuto principale |
| `fe-module-header-container` | Header del modulo/form | | `fe-module-header-container` | Header del modulo/form |
| `receipt` | Container del contenuto del form | | `mainview` | Container del contenuto del form |
### Layout Campi Form (ElixFormsElement) ### Layout Campi Form (ElixFormsElement)
| Classe | Scopo | | Classe | Scopo |
@@ -130,7 +130,7 @@ Le pagine seguono un layout standard elixForms con classi CSS specifiche:
| `iuTypeString` | Tipo di input stringa | | `iuTypeString` | Tipo di input stringa |
| `iuClearContainer` | Clearfix interno | | `iuClearContainer` | Clearfix interno |
### Classi Input FluentUI Custom ### Classi Input Bootstrap Italia Custom
| Classe | Componente | | Classe | Componente |
|---|---| |---|---|
| `isiportalPartialAdminFormFieldSingleLineText` | TextField singola riga | | `isiportalPartialAdminFormFieldSingleLineText` | TextField singola riga |
@@ -143,20 +143,13 @@ Le pagine seguono un layout standard elixForms con classi CSS specifiche:
`ElixFormsComponent.module.scss`: `ElixFormsComponent.module.scss`:
```scss ```scss
@import '~@fluentui/react/dist/sass/References.scss';
.elixFormsComponent { .elixFormsComponent {
overflow: hidden; overflow: hidden;
padding: 1em; padding: 1em;
color: "[theme:bodyText, default: #323130]";
color: var(--bodyText);
&.teams {
font-family: $ms-font-family-fallbacks;
}
} }
``` ```
> **Nota**: Gli stili usano il **theming di FluentUI** con variabili CSS e placeholder di tema SharePoint. > **Nota**: Gli stili usano classi Bootstrap Italia e CSS del design system UniPR, senza dipendenze da FluentUI.
## Logo e Risorse Esterne ## Logo e Risorse Esterne
+2 -2
View File
@@ -118,13 +118,13 @@ I valori verranno letti da `QueryParamHelper` e pre-popoleranno i campi del form
``` ```
react/ ← npm install qui per dipendenze condivise react/ ← npm install qui per dipendenze condivise
├── node_modules/ ← react, react-dom, @fluentui/react ├── node_modules/ ← react, react-dom
├── package.json ├── package.json
└── scelta-carriera/ ← npm install qui per devDependencies └── scelta-carriera/ ← npm install qui per devDependencies
├── node_modules/ ← vite, eslint, plugin, sass-embedded ├── node_modules/ ← vite, eslint, plugin, sass-embedded
└── package.json └── package.json
``` ```
> **Nota**: Le dipendenze runtime (React, FluentUI) sono nel `package.json` root. > **Nota**: Le dipendenze runtime (React, ReactDOM) sono nel `package.json` root.
> Le dipendenze di sviluppo (Vite, ESLint) sono nel `package.json` della pagina. > Le dipendenze di sviluppo (Vite, ESLint) sono nel `package.json` della pagina.
> npm risolve le dipendenze risalendo la gerarchia delle cartelle (hoisting). > npm risolve le dipendenze risalendo la gerarchia delle cartelle (hoisting).
+190
View File
@@ -0,0 +1,190 @@
---
name: elixforms-php-common-library
description: >
Documentazione della libreria condivisa PHP common/ del progetto elixForms.
Descrive la classe base ElixFormsComponent, i metodi factory per i campi form,
ElixFormsElement per il layout, QueryParamHelper per i query parameter,
e il pattern di ereditarietà per creare pagine custom.
Attiva questa skill quando lavori sui componenti PHP condivisi, crei nuovi tipi di campo,
modifichi la classe base, o integri con la piattaforma elixForms in PHP.
---
# Libreria Common PHP elixForms
## Panoramica
La cartella `php/common/` contiene la libreria condivisa PHP usata da tutte le pagine custom PHP. È basata su **Bootstrap Italia + HTML nativo** e implementa un sistema di form con campi dinamici, analogo alla libreria React in `react/common/`.
## Architettura delle Classi
```
ElixFormsComponent (classe astratta, PHP 8.0+)
└── SceltaCarrieraPage (classe concreta, in scelta-carriera/index.php)
└── [AltrePageCustom] (classi concrete nelle sotto-cartelle)
```
### Namespace
Tutte le classi usano il namespace PSR-4:
```php
namespace ElixForms\Common;
```
Le pagine importano con:
```php
require_once __DIR__ . '/../../vendor/autoload.php';
use ElixForms\Common\ElixFormsComponent;
```
## File della Libreria
### `ElixFormsComponent.php` (Classe Base Astratta)
Equivalente di `ElixFormsComponentAbstract.tsx` in React.
#### Proprietà
```php
protected array $properties; // Equivalente di IElixFormsComponentProperties
```
Chiavi supportate in `$properties`:
| Chiave | Tipo | Obbligatorio | Descrizione |
|---|---|---|---|
| `moduleName` | string | Sì | Nome del modulo nel breadcrumb |
| `cardTitle` | string | Sì | Titolo della card principale |
| `userDisplayName` | string | No | Nome utente nell'header |
| `headerTitle` | string | No | Titolo sotto il breadcrumb |
| `headerHeroImageSrc` | string | No | URL immagine hero |
| `cardDescription` | string | No | Descrizione nella card |
| `alertInfoTitle` | string | No | Titolo alert informativo |
| `alertInfoMessage` | string | No | Messaggio alert informativo |
| `instructionsTitle` | string | No | Titolo istruzioni |
| `instructionsMessage` | string | No | Messaggio istruzioni |
| `submitText` | string | No | Testo bottone submit (default: 'INVIA') |
| `additionalFieldsJson` | string | No | JSON schema per campi dinamici |
| `isDarkTheme` | bool | No | Tema scuro (riservato per uso futuro) |
#### Metodi Factory (Campi Form)
| Metodo | Tipo Campo | HTML Output |
|---|---|---|
| `createTextInput($name, $label, $required)` | Testo | `<input type="text">` |
| `createTextAreaInput($name, $label, $required)` | Textarea | `<textarea>` |
| `createNumberInput($name, $label, $required)` | Numerico | `<input type="number">` |
| `createBooleanInput($name, $label, $required)` | Booleano (Sì/No) | Radio buttons |
| `createRadioInput($name, $label, $options, $required)` | Radio | Radio buttons |
| `createCheckboxInput($name, $label, $options)` | Checkbox multipli | Checkboxes + hidden |
| `createDropdownInput($name, $label, $options, $required)` | Dropdown | `<select>` |
| `createHiddenInput($name)` | Nascosto | `<input type="hidden">` |
#### Metodi Estensibili (Override)
```php
// Campi custom del form — OBBLIGATORIO sovrascrivere
protected function createCustomFormFields(): string { return ''; }
// Contenuto extra prima del form
protected function renderExtraContentPre(): string { return ''; }
// Contenuto extra dopo il form
protected function renderExtraContentPost(): string { return ''; }
```
#### Metodo Principale
```php
public function render(): string
```
Genera l'HTML **completo** della pagina (`<!DOCTYPE html>` ... `</html>`), includendo:
- `<head>` con CSS Bootstrap Italia e design system UniPR
- Header con logo UniPR e nome utente
- Breadcrumb
- Card con alert, istruzioni, form e campi
- Footer "powered by elixForms"
- Script JS per checkbox
### `ElixFormsElement.php` (Wrapper Layout)
Genera il layout a due colonne per ogni campo form:
```php
$element = new ElixFormsElement($labelHtml, $inputHtml, $separator);
echo $element->render();
```
Output HTML:
```html
<div class="row mb-3 align-items-start">
<div class="col-12 col-md-4">
<div class="text-md-end pe-md-3 mt-2">{label}</div>
</div>
<div class="col-12 col-md-8">
{input}
</div>
</div>
```
### `QueryParamHelper.php` (Utility Query String)
Classe statica per leggere parametri dalla query string `$_GET`:
```php
QueryParamHelper::getCheckedFromQuery('COL0006'); // int[] (checkbox, valori separati da virgola)
QueryParamHelper::getOptionFromQuery('COL0015'); // ?int (radio/dropdown)
QueryParamHelper::getBooleanFromQuery('COL0004'); // ?bool (boolean)
QueryParamHelper::getDecodedTextFromQuery('COL0002'); // ?string (testo URL-decoded)
```
### `HttpClient.php` (Utility Richieste HTTP REST)
Adapter applicativo basato su Guzzle per effettuare chiamate HTTP server-to-server sicure verso Web Service esterni (es. endpoint contratti). Accetta un `GuzzleHttp\\ClientInterface` opzionale nel costruttore per consentire mocking e test senza traffico di rete:
```php
use ElixForms\Common\HttpClient;
$client = new HttpClient([
'X-Api-Key' => 'chiave-api-custom',
'Accept' => 'application/json'
]);
$client->setBasicAuth('username', 'password');
$client->setTimeout(10); // timeout in secondi
// Richiesta GET
$response = $client->get('http://api-endpoint/cerca', [
'parametro1' => 'valore1'
]);
```
**Best Practices gestite da HttpClient:**
- **Timeout**: Gestione dei timeout di connessione ed esecuzione per evitare blocchi infiniti del server Apache.
- **Sicurezza delle credenziali**: Permette di eseguire chiamate server-side mantenendo chiavi API e password nascoste al browser del client.
- **Error Handling**: Se il server remoto restituisce codici HTTP diversi da 20x (es. 404, 500), solleva una `RuntimeException` contenente il codice di stato generico senza includere nel messaggio d'errore l'eventuale payload della risposta (es. tag HTML dell'errore di Apache) per motivi di sicurezza ed integrità del JSON finale.
## Differenze rispetto alla versione React
| Aspetto | React | PHP |
|---|---|---|
| **Rendering** | Client-side (JSX → DOM) | Server-side (PHP → HTML) |
| **State** | `this.state` + `this.setState()` | Valori da `$_GET` (stateless) |
| **Checkbox sync** | React state + hidden input | JS client-side + hidden input |
| **Build** | Vite → single bundle JS | Nessuna build necessaria |
| **CSS** | Iniettato nel JS | `<link>` tags nell'HTML |
| **Output** | Singolo file `.js` | File `.php` serviti da Apache |
## Parametri Obbligatori elixForms
Gestiti automaticamente come hidden inputs dalla classe base:
- `RWE2_MODULE_ID`, `RWE2_REQUEST_ID`
- `custom-workflow-back-url`, `custom-workflow-generic-id`
- `custom-workflow-current-tabrel-genid`, `custom-workflow-source-field`
- `crc`, `MODULE_TESTMODE_KEY`, `ELANG`
## Note Importanti
- Il form fa POST a `https://procedure.unipr.it/rwe2/ComeBackToElixAndSave`
- L'encoding charset è `ISO-8859-1` per compatibilità con il backend
- Gli ID dei campi form **non devono mai essere manipolati** (vedi AGENTS.md)
- L'ID dell'hidden input dei checkbox e dei mandatory fields ha suffisso `_hidden`
- I checkbox usano un attributo `data-param` e uno script JS per sincronizzare il valore con l'hidden input
+258
View File
@@ -0,0 +1,258 @@
---
name: elixforms-php-create-new-page
description: >
Guida step-by-step per creare una nuova pagina custom PHP nel progetto elixForms.
Copre la struttura dei file, l'importazione della libreria common,
le convenzioni di naming, e la configurazione per il deploy su Apache.
Attiva questa skill quando devi creare una nuova pagina PHP,
duplicare una pagina esistente, o scaffoldare un nuovo micro-progetto PHP nel monorepo.
---
# Creare una Nuova Pagina PHP elixForms Custom
## Prerequisiti
- PHP 8.0+ installato
- Web server Apache configurato per servire la cartella `php/`
## Step 1: Creare la Cartella della Pagina
```powershell
cd php
mkdir <nome-pagina>
```
Il nome della cartella deve essere in **kebab-case** (es. `scelta-carriera`, `recupero-proposta`).
## Step 2: Creare il File index.php
Creare `php/<nome-pagina>/index.php`:
```php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../common/ElixFormsComponent.php';
use ElixForms\Common\ElixFormsComponent;
/**
* Pagina custom "<NomePagina>" per elixForms.
*/
class NomePaginaPage extends ElixFormsComponent
{
protected function createCustomFormFields(): string
{
$html = '';
// Aggiungi qui i campi del form usando i metodi factory:
// $html .= $this->createTextInput('COL0001', 'Campo Testo', true);
// $html .= $this->createDropdownInput('COL0002', 'Scelta', [...options], true);
return $html;
}
// Opzionale: contenuto extra prima del form
// protected function renderExtraContentPre(): string { return ''; }
// Opzionale: contenuto extra dopo il form
// protected function renderExtraContentPost(): string { return ''; }
}
// Entry point
$page = new NomePaginaPage([
'moduleName' => 'Nome Modulo',
'cardTitle' => 'Titolo Pagina',
'cardDescription' => 'Descrizione della pagina.',
'userDisplayName' => '', // Verrà popolato da elixForms
'headerTitle' => '',
'headerHeroImageSrc' => '',
'alertInfoTitle' => '',
'alertInfoMessage' => '',
'instructionsTitle' => '',
'instructionsMessage' => '',
'submitText' => 'INVIA',
]);
echo $page->render();
```
## Step 3: Definire i Campi Custom
Sovrascrivere `createCustomFormFields()` con i campi necessari:
```php
protected function createCustomFormFields(): string
{
$html = '';
// Dropdown
$html .= $this->createDropdownInput('COL0015', 'Scelta', [
['value' => 0, 'label' => 'Opzione A'],
['value' => 1, 'label' => 'Opzione B'],
['value' => 2, 'label' => 'Opzione C'],
], true);
// Testo
$html .= $this->createTextInput('COL0002', 'Nome', true);
// Textarea
$html .= $this->createTextAreaInput('COL0003', 'Note', false);
// Booleano (Sì/No)
$html .= $this->createBooleanInput('COL0004', 'Conferma', true);
// Radio
$html .= $this->createRadioInput('COL0005', 'Preferenza', [
['value' => 1, 'label' => 'Prima scelta'],
['value' => 2, 'label' => 'Seconda scelta'],
], true);
// Checkbox
$html .= $this->createCheckboxInput('COL0006', 'Opzioni Multiple', [
['value' => 1, 'label' => 'Opzione 1'],
['value' => 2, 'label' => 'Opzione 2'],
]);
// Numerico
$html .= $this->createNumberInput('COL0007', 'Quantità', true);
return $html;
}
```
## Step 4: Opzionale — Aggiungere File di Configurazione
Per pagine complesse, creare un file `config.json` nella cartella della pagina:
```json
{
"component": {
"moduleName": "Nome Modulo",
"cardTitle": "Titolo",
"cardDescription": "Descrizione",
"submitText": "CONFERMA"
},
"elixForms": {
"inputs": {
"codiceFiscale": "COL0009"
},
"outputs": {
"campo1": "COL0001",
"campo2": "COL0002"
}
}
}
```
Caricare nel PHP:
```php
$config = json_decode(file_get_contents(__DIR__ . '/config.json'), true);
$page = new NomePaginaPage($config['component']);
echo $page->render();
```
## Step 4.1: Pagine Interattive e Chiamate Asincrone (AJAX)
Se la pagina richiede autocompletamento o interattività (es. ricerca contratti):
### 1. Proxy Script PHP Locale
Per non esporre credenziali sensibili (API Key, Basic Auth) al client, creare sempre uno script proxy locale (es. `search-contratti.php`) che:
- Riceve i dati dal client.
- Esegue la chiamata protetta tramite `HttpClient`.
- Valida con `json_decode` che la risposta sia JSON valido prima di inviarla.
- Risponde con `500` e un JSON d'errore pulito in caso di eccezioni.
### 2. Risoluzione Percorso URL in JavaScript
Il server integrato PHP non esegue il redirect automatico per aggiungere lo slash alla fine delle cartelle. In JavaScript, calcolare sempre la base directory dinamica per supportare URL sia con che senza slash finale o con `index.php` esplicito:
```javascript
var baseDir = window.location.pathname;
if (!baseDir.endsWith('/') && !baseDir.endsWith('.php')) {
baseDir += '/';
}
var lastSlash = baseDir.lastIndexOf('/');
var dir = baseDir.substring(0, lastSlash + 1);
var url = dir + 'search-contratti.php?term=' + encodeURIComponent(term);
```
### 3. Parsing Sicuro degli Errori HTTP (Fetch)
Non eseguire direttamente `response.json()` su risposte d'errore (500/404) o non-JSON per evitare il crash `Unexpected token '<'`. Utilizzare la tecnica del `response.text()`:
```javascript
fetch(url)
.then(function(response) {
return response.text().then(function(text) {
var data = null;
try {
data = JSON.parse(text);
} catch (e) {
// Risposta non JSON (es. HTML d'errore del server)
}
if (!response.ok) {
var errMsg = (data && data.message) ? data.message :
(data && data.error) ? data.error :
('Errore API: ' + response.status + ' ' + response.statusText);
throw new Error(errMsg);
}
if (data === null) {
throw new Error('Risposta del server non in formato JSON.');
}
return data;
});
})
```
## Step 5: Testing Locale
Per testare le pagine localmente, è necessario avviare il server web dalla cartella radice `php/` (in modo che la cartella `common/` sia accessibile correttamente):
### Opzione 1: PHP CLI Server locale
```powershell
cd php
php -S localhost:8080
```
Aprire nel browser:
```
http://localhost:8080/<nome-pagina>/?RWE2_MODULE_ID=123&RWE2_REQUEST_ID=456&COL0002=test&COL0015=3
```
### Opzione 2: Docker
Dalla root del repository, eseguire:
```bash
docker run --rm -p 8080:8080 -v "${PWD}/php:/app" -w /app php:8.2-cli php -S 0.0.0.0:8080
```
Aprire nel browser:
```
http://localhost:8080/<nome-pagina>/?RWE2_MODULE_ID=123&RWE2_REQUEST_ID=456&COL0002=test&COL0015=3
```
## Step 6: Deploy su Apache
1. Caricare la cartella `php/` (o la singola cartella della pagina) sul server Apache
2. Assicurarsi che Apache abbia `mod_php` o `php-fpm` configurato
3. La pagina sarà accessibile via URL diretto (es. `https://server.example.com/php/nome-pagina/`)
4. elixForms reindirizza l'utente alla pagina passando i parametri in query string
## Struttura Finale
```
php/<nome-pagina>/
├── index.php ← Entry point e classe pagina
└── config.json ← Opzionale: configurazione separata
```
## Checklist Nuova Pagina PHP
- [ ] Cartella creata in `php/<nome-pagina>/`
- [ ] `index.php` con `declare(strict_types=1)` e namespace
- [ ] Classe che estende `ElixFormsComponent`
- [ ] `createCustomFormFields()` sovrascritta con i campi corretti
- [ ] Proprietà di configurazione impostate correttamente
- [ ] `require_once __DIR__ . '/../common/ElixFormsComponent.php'`
- [ ] Test locale con `php -S` o Docker
- [ ] Verifica che i campi form producano l'HTML corretto
- [ ] Verifica che i parametri obbligatori vengano letti da `$_GET`
@@ -0,0 +1,124 @@
---
name: elixforms-php-project-architecture
description: >
Architettura e struttura della parte PHP del progetto elixForms Custom Pages.
Descrive la cartella php/, la libreria common condivisa, il pattern delle pagine,
le convenzioni di naming, e il deploy su Apache.
Attiva questa skill quando lavori sulla parte PHP del progetto,
crei nuove pagine PHP, modifichi l'architettura, o devi capire come è organizzato il codice PHP.
---
# Architettura PHP del Progetto elixForms Custom Pages
## Panoramica
La cartella `php/` contiene le custom page elixForms realizzate in **PHP 8.0+ puro** (nessun framework). Ogni pagina è un file PHP autonomo che include la libreria `common/` e produce HTML server-side con **Bootstrap Italia**.
Questa struttura è l'equivalente PHP del monorepo React in `react/`.
## Struttura del Workspace
```
elixforms-custom-pages/
├── composer.json ← Configurazione Composer e autoload PSR-4
├── vendor/ ← Autoloader generato (non versionato)
├── react/ ← Custom pages React (esistente)
└── php/ ← Custom pages PHP
├── AGENTS.md ← Regole dell'agente PHP
├── common/ ← Libreria condivisa PHP
│ ├── ElixFormsComponent.php ← Classe base astratta
│ ├── ElixFormsElement.php ← Wrapper layout label+input
│ └── QueryParamHelper.php ← Utility query string
└── scelta-carriera/ ← Esempio di pagina custom
└── index.php ← Entry point (classe + rendering)
```
## Pattern Architetturale: Singolo File PHP per Pagina
Ogni pagina (es. `scelta-carriera/`) è un file PHP autonomo che:
1. **Carica l'autoloader Composer** tramite `vendor/autoload.php`
2. **Definisce una classe** che estende `ElixFormsComponent`
3. **Sovrascrive `createCustomFormFields()`** per i campi specifici
4. **Istanzia e renderizza** la pagina con `echo $page->render()`
5. **Genera HTML completo** (dalla `<!DOCTYPE html>` al `</html>`)
6. **Non richiede build** — servito direttamente da Apache
## Namespace e Autoloading
Il progetto usa **namespace PSR-4**:
```php
namespace ElixForms\Common; // Per le classi in common/
```
L'autoloading PSR-4 è gestito da Composer:
```php
require_once __DIR__ . '/../../vendor/autoload.php';
```
## Requisiti PHP
- **Versione minima**: PHP 8.0+
- **Funzionalità usate**:
- `declare(strict_types=1)` — strict typing
- Typed properties (`protected array $properties`)
- `match` expression (nel renderField)
- Named arguments
- Union types (`int|null`, `string|null`)
- Arrow functions (`fn() =>`)
## CSS Strategy
Le pagine PHP usano `<link>` tags per caricare i CSS (non bundle JS):
```html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Titillium+Web:wght@300;400;600;700&display=swap">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-italia@2.18.2/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="https://console-unipr.elixforms.it/rwe2/css/design-bs/util.css">
<link rel="stylesheet" href="https://console-unipr.elixforms.it/rwe2/css/themes/blu-italia.css">
```
> **Nota**: Stessi CSS del progetto React, ma caricati via `<link>` anziché `@import url()` nel JS bundle.
## Integrazione con elixForms
Identica alla versione React:
1. **Query parameters**: Parametri obbligatori passati nell'URL da elixForms
2. **Form POST**: Il form fa submit a `https://procedure.unipr.it/rwe2/ComeBackToElixAndSave`
3. **Hidden inputs**: I parametri query vengono inseriti come campi hidden nel form
4. **Encoding**: `accept-charset="ISO-8859-1"` per compatibilità con il backend
## Convenzioni di Naming
- **Cartelle pagina**: kebab-case (es. `scelta-carriera`)
- **File entry point**: sempre `index.php`
- **Classi PHP**: PascalCase con suffisso `Page` (es. `SceltaCarrieraPage`)
- **Namespace**: `ElixForms\Common` per la libreria condivisa
- **Configurazione**: opzionale `config.json` nella cartella della pagina
## Differenze con il Progetto React
| Aspetto | React (`react/`) | PHP (`php/`) |
|---|---|---|
| **Linguaggio** | TypeScript/JSX | PHP 8.0+ |
| **Rendering** | Client-side | Server-side |
| **Build tool** | Vite (single bundle JS) | Nessuno (file serviti direttamente) |
| **Dipendenze** | npm (React, Vite, plugin) | Composer (autoload PSR-4) |
| **CSS loading** | `@import url()` + CSS injection plugin | `<link>` tags |
| **State management** | React state (`this.setState`) | Stateless (`$_GET`) |
| **Deploy** | Upload del bundle `.js` | Upload dei file `.php` |
| **Dev server** | `npm run dev` (Vite HMR) | `php -S` / Docker |
## Deploy su Apache
1. Eseguire `composer install --no-dev --optimize-autoloader` dalla root del repository
2. Caricare sul server le cartelle `php/` e `vendor/`, mantenendole allo stesso livello
3. Configurazione Apache minima:
- `mod_php` o `php-fpm` abilitato
- `DirectoryIndex index.php` (default Apache)
4. La pagina è accessibile via URL: `https://server/php/nome-pagina/`
5. elixForms reindirizza l'utente alla pagina passando i parametri via query string
+4 -5
View File
@@ -12,7 +12,7 @@ description: >
## Panoramica ## Panoramica
Questo progetto è un **monorepo** per la creazione di pagine web React custom destinate alla piattaforma **elixForms** dell'Università di Parma (UniPR). Ogni pagina è un micro-progetto indipendente che produce un **singolo file JS bundle** contenente React, Fluent UI, CSS e il codice custom, da pubblicare su un server remoto. Questo progetto è un **monorepo** per la creazione di pagine web React custom destinate alla piattaforma **elixForms** dell'Università di Parma (UniPR). Ogni pagina è un micro-progetto indipendente che produce un **singolo file JS bundle** contenente React, Bootstrap Italia e il codice custom, da pubblicare su un server remoto.
## Struttura del Workspace ## Struttura del Workspace
@@ -21,7 +21,7 @@ elixforms-custom-pages/ ← Root del workspace (VS Code)
├── .vscode/ ← Configurazioni VS Code (launch, tasks) ├── .vscode/ ← Configurazioni VS Code (launch, tasks)
├── docs/ ← Documentazione (PDF manuali) ├── docs/ ← Documentazione (PDF manuali)
└── react/ ← Root del monorepo React └── react/ ← Root del monorepo React
├── package.json ← Dipendenze condivise (React, ReactDOM, FluentUI) ├── package.json ← Dipendenze condivise (React, ReactDOM)
├── tsconfig.base.json ← Configurazione TypeScript base condivisa ├── tsconfig.base.json ← Configurazione TypeScript base condivisa
├── .gitignore ├── .gitignore
├── common/ ← Libreria condivisa di componenti ElixForms ├── common/ ← Libreria condivisa di componenti ElixForms
@@ -58,7 +58,7 @@ Ogni pagina (es. `scelta-carriera/`) è un progetto Vite completamente autonomo
1. **Importa dalla libreria `common/`** tramite alias `@common` configurato in `vite.config.js` 1. **Importa dalla libreria `common/`** tramite alias `@common` configurato in `vite.config.js`
2. **Ha le proprie dipendenze** nel suo `package.json` (solo devDependencies e plugin Vite) 2. **Ha le proprie dipendenze** nel suo `package.json` (solo devDependencies e plugin Vite)
3. **Condivide le dipendenze runtime** (React, ReactDOM, FluentUI) dal `package.json` root di `react/` 3. **Condivide le dipendenze runtime** (React, ReactDOM) dal `package.json` root di `react/`
4. **Produce un singolo file JS** tramite la configurazione Rollup in `vite.config.js` 4. **Produce un singolo file JS** tramite la configurazione Rollup in `vite.config.js`
5. **Estende il tsconfig base** per avere configurazione TypeScript coerente 5. **Estende il tsconfig base** per avere configurazione TypeScript coerente
@@ -67,7 +67,6 @@ Ogni pagina (es. `scelta-carriera/`) è un progetto Vite completamente autonomo
Le dipendenze runtime sono hoisted al livello `react/`: Le dipendenze runtime sono hoisted al livello `react/`:
- `react` ^19.2.7 - `react` ^19.2.7
- `react-dom` ^19.2.5 - `react-dom` ^19.2.5
- `@fluentui/react` ^8.125.6
## Configurazione TypeScript ## Configurazione TypeScript
@@ -121,7 +120,7 @@ server.fs.allow: [".."] // Permette import da common/
Le pagine caricano CSS dal server remoto elixForms tramite `@import url(...)` in `App.css`: Le pagine caricano CSS dal server remoto elixForms tramite `@import url(...)` in `App.css`:
- CSS da `console-unipr.elixforms.it` per il design system dell'università - CSS da `console-unipr.elixforms.it` per il design system dell'università
- I componenti FluentUI usano stili inline/theme nativi - I componenti Bootstrap Italia usano classi HTML e CSS del design system UniPR
- SCSS modules sono usati in `common/` per stili dei componenti condivisi - SCSS modules sono usati in `common/` per stili dei componenti condivisi
- `sass-embedded` è installato come devDependency per supporto SCSS - `sass-embedded` è installato come devDependency per supporto SCSS
@@ -106,9 +106,6 @@ import { IElixFormsComponentProperties } from './IElixFormsComponentProperties';
### Import di Moduli ### Import di Moduli
```tsx ```tsx
// Fluent UI - import tutto il namespace
import * as FluentUI from '@fluentui/react';
// React - import specifici // React - import specifici
import { Component, type FormEvent, type JSX, useState } from 'react'; import { Component, type FormEvent, type JSX, useState } from 'react';
import React from 'react'; import React from 'react';
@@ -167,8 +164,8 @@ declare module "*.png";
- Usare `this.setState` con spread operator per update immutabili - Usare `this.setState` con spread operator per update immutabili
- I checkbox mantengono un `Map<string, number[]>` separato - I checkbox mantengono un `Map<string, number[]>` separato
5. **Fluent UI**: 5. **Bootstrap Italia**:
- Usare Fluent UI React v8 (`@fluentui/react`) - Usare markup HTML nativo con classi Bootstrap Italia e del design system UniPR
- Componenti: `TextField`, `Checkbox`, `ChoiceGroup`, `Dropdown`, `Label`, `Stack` - Per i campi testuali usare `input`, `textarea`, `select` con classi `form-control`/`form-select`
- Le opzioni radio usano `IChoiceGroupOption[]` - Per i checkbox/radio usare `input type="checkbox"`/`input type="radio"` con classi `form-check-input`
- Le opzioni dropdown usano `IDropdownOption[]` - Il layout dei campi deve rimanere compatibile con `ElixFormsElement`
+4 -4
View File
@@ -105,7 +105,7 @@ npm run lint
``` ```
dist/ dist/
<nome-pagina>.js ← Bundle unico (React + FluentUI + CSS + codice) <nome-pagina>.js ← Bundle unico (React + CSS + codice)
<nome-pagina>.js.map ← Source map <nome-pagina>.js.map ← Source map
*.png, *.svg ← Asset referenziati nel codice *.png, *.svg ← Asset referenziati nel codice
``` ```
@@ -121,10 +121,10 @@ dist/
- **Fix**: Verificare `resolve.alias` e che la cartella `common/` esista - **Fix**: Verificare `resolve.alias` e che la cartella `common/` esista
### Bundle troppo grande ### Bundle troppo grande
- **Causa**: FluentUI include molti componenti - **Causa**: l'uso di componenti UI pesanti o CSS non ottimizzati
- **Fix possibili**: - **Fix possibili**:
- Tree shaking (import specifici: `import { TextField } from '@fluentui/react'`) - Mantenere i CSS locali limitati e affidarsi ai CSS remoti del design system
- Considerare `@fluentui/react-components` (v9) per bundle più piccoli - Evitare dipendenze UI pesanti per le pagine custom
- Esternalizzare React se la pagina host lo fornisce già - Esternalizzare React se la pagina host lo fornisce già
### CSS non applicato in produzione ### CSS non applicato in produzione
+171
View File
@@ -0,0 +1,171 @@
# Port elixForms Custom Pages in PHP + Creazione Agenti React e PHP
## Obiettivo
Creare una versione PHP della libreria `common/` e una pagina di esempio `scelta-carriera`, replicando la stessa architettura e logica attualmente implementata in React/TypeScript. Inoltre, creare due agenti specializzati ("React" e "PHP") e le relative skill per il mondo PHP.
## Panoramica dell'architettura React attuale
```mermaid
graph TD
A["ElixFormsComponentAbstract.tsx<br/>(classe astratta)"] --> B["SceltaCarrieraComponent.tsx<br/>(override createCustomFormFields)"]
A --> C["RecuperoPropostaCctDaContrattiComponent.tsx<br/>(override createCustomFormFields + autocomplete)"]
A --> D["ElixFormsElement.tsx<br/>(layout label+input)"]
A --> E["QueryParamHelper.tsx<br/>(lettura query params)"]
A --> F["ElixFormsTypes.tsx<br/>(tipi opzioni)"]
B --> G["App.jsx + main.jsx<br/>(entry point pagina)"]
C --> H["App.jsx + main.jsx<br/>(entry point pagina)"]
```
## Differenze chiave React → PHP
| Aspetto | React | PHP |
|---|---|---|
| **Rendering** | Client-side (JSX → DOM) | Server-side (PHP → HTML) |
| **State management** | `this.state` + `this.setState()` | Valori da `$_GET` / `$_POST` (stateless) |
| **Form field factory** | Callback con oggetto factory | Metodi della classe base chiamati direttamente |
| **Build** | Vite → singolo bundle JS | Nessuna build — file PHP serviti direttamente |
| **CSS** | Iniettati nel JS via plugin | `<link>` tag nell'HTML |
| **Bootstrap Italia** | Classi HTML nel JSX | Stesse classi HTML nel template PHP |
> [!IMPORTANT]
> In PHP non serve un build tool. Ogni pagina è un singolo file PHP (o un set di file PHP) che include la libreria common e produce HTML direttamente.
## Proposed Changes
### 1. Libreria Common PHP
#### [NEW] [ElixFormsComponent.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/common/ElixFormsComponent.php)
Classe astratta PHP che replica `ElixFormsComponentAbstract.tsx`:
- **Proprietà**: `$properties` (array associativo equivalente a `IElixFormsComponentProperties`)
- **Metodi factory**: `createTextInput()`, `createTextAreaInput()`, `createNumberInput()`, `createBooleanInput()`, `createRadioInput()`, `createCheckboxInput()`, `createDropdownInput()`, `createHiddenInput()`
- **Metodi di layout**: `render()` che genera l'intera pagina HTML (header Bootstrap Italia, breadcrumb, card, form, footer)
- **Metodi estensibili**: `createCustomFormFields()`, `renderExtraContentPre()`, `renderExtraContentPost()` — da sovrascrivere nelle pagine figlie
- **Validazione**: Controllo parametri obbligatori (`mandatoryFormFieldNames`) dalla query string
- I valori dei campi vengono pre-popolati da `$_GET` (equivalente di `QueryParamHelper`)
#### [NEW] [ElixFormsElement.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/common/ElixFormsElement.php)
Classe wrapper per layout label+input (replica `ElixFormsElement.tsx`):
- Genera il layout a due colonne Bootstrap (`row`, `col-12 col-md-4`, `col-12 col-md-8`)
#### [NEW] [QueryParamHelper.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/common/QueryParamHelper.php)
Classe statica utility (replica `QueryParamHelper.tsx`):
- `getCheckedFromQuery($paramName)``int[]`
- `getOptionFromQuery($paramName)``?int`
- `getBooleanFromQuery($paramName)``?bool`
- `getDecodedTextFromQuery($paramName)``?string`
---
### 2. Pagina di Esempio
#### [NEW] [index.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/scelta-carriera/index.php)
Pagina di esempio che replica `scelta-carriera` React:
- Estende `ElixFormsComponent` sovrascrivendo `createCustomFormFields()`
- Definisce dropdown, text input, textarea, boolean, radio, checkbox
- Include il CSS di Bootstrap Italia e del design system UniPR via `<link>`
---
### 3. Skill PHP (nuove)
#### [NEW] [SKILL.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/php-common-library/SKILL.md)
Skill `elixforms-php-common-library`:
- Documenta la libreria PHP common, i metodi della classe base, le proprietà, e il pattern di estensione
#### [NEW] [SKILL.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/php-create-new-page/SKILL.md)
Skill `elixforms-php-create-new-page`:
- Guida step-by-step per creare una nuova pagina PHP custom
#### [NEW] [SKILL.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/php-project-architecture/SKILL.md)
Skill `elixforms-php-project-architecture`:
- Descrive la struttura della cartella `php/` e le convenzioni del monorepo PHP
---
### 4. Agenti (AGENTS.md personalizzati)
> [!IMPORTANT]
> Gli "agenti" nella struttura Gemini vengono realizzati creando file `AGENTS.md` nelle rispettive cartelle. In questo modo ogni agente "scopre" automaticamente le regole specifiche per il proprio contesto.
#### [NEW] [AGENTS.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/react/AGENTS.md)
Agente **"React"** — regole specifiche per il framework React:
- Usa React + TypeScript class components per la libreria common
- Usa Vite per il bundling single-file
- Bootstrap Italia con classi HTML nel JSX
- Regola sugli ID dei form fields (ripresa dal root)
- Riferimenti alle skill React esistenti
#### [NEW] [AGENTS.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/AGENTS.md)
Agente **"PHP"** — regole specifiche per PHP:
- PHP puro, nessun framework
- Bootstrap Italia con classi HTML nei template PHP
- Pattern ereditarietà con classe base
- Stessa regola sugli ID dei form fields
- Riferimenti alle skill PHP
---
## Struttura finale del workspace
```
elixforms-custom-pages/
├── .agents/
│ ├── AGENTS.md ← Regole globali (invariate)
│ └── skills/
│ ├── common-library/ ← Skill React (esistente)
│ ├── create-new-page/ ← Skill React (esistente)
│ ├── css-design-system/ ← Skill condivisa (esistente)
│ ├── custom-workflow-logic/ ← Skill condivisa (esistente)
│ ├── dev-workflow/ ← Skill React (esistente)
│ ├── project-architecture/ ← Skill React (esistente)
│ ├── typescript-react-conventions/ ← Skill React (esistente)
│ ├── vite-build-config/ ← Skill React (esistente)
│ ├── php-common-library/ ← [NEW] Skill PHP
│ ├── php-create-new-page/ ← [NEW] Skill PHP
│ └── php-project-architecture/ ← [NEW] Skill PHP
├── react/
│ ├── AGENTS.md ← [NEW] Agente React
│ ├── common/
│ ├── scelta-carriera/
│ └── recupero-proposta-cct-da-contratti/
└── php/
├── AGENTS.md ← [NEW] Agente PHP
├── common/
│ ├── ElixFormsComponent.php ← [NEW]
│ ├── ElixFormsElement.php ← [NEW]
│ └── QueryParamHelper.php ← [NEW]
└── scelta-carriera/
└── index.php ← [NEW]
```
## Open Questions
> [!IMPORTANT]
> **Versione PHP**: quale versione minima di PHP devo usare? (es. PHP 7.4+ o PHP 8.0+ con typed properties, union types, match expression, ecc.)
> [!IMPORTANT]
> **Namespace PHP**: vuoi usare i namespace PSR-4 (es. `namespace ElixForms\Common;`) o preferisci un approccio più semplice con `require_once`?
> [!IMPORTANT]
> **Deploy**: le pagine PHP verranno servite da un web server Apache/Nginx? O dovranno essere incluse/embedded in un sistema esistente?
## Verification Plan
### Manual Verification
- Verificare che la pagina PHP `scelta-carriera/index.php` generi HTML valido con la stessa struttura del corrispettivo React
- Verificare che i campi del form (dropdown, text, textarea, boolean, radio, checkbox) siano tutti presenti e funzionanti
- Verificare che i parametri obbligatori vengano correttamente letti dalla query string
- Verificare che il form faccia POST a `https://procedure.unipr.it/rwe2/ComeBackToElixAndSave` con encoding `ISO-8859-1`
- Verificare che le skill e gli agenti siano correttamente scoperti da Gemini
@@ -0,0 +1,57 @@
# Porting "recupero-proposta-cct-da-contratti" in PHP
## Obiettivo
Convertire la pagina custom `recupero-proposta-cct-da-contratti` in PHP, mantenendo la stessa logica di funzionamento della versione React/TypeScript.
## Architettura e Sicurezza
La pagina originale React esegue una chiamata client-side fetch ad un web service esterno (`http://localhost:8000/contratti/cerca`) utilizzando credenziali Basic Auth ed una API Key custom. Poiché in PHP eseguiamo il rendering server-side, esporre le credenziali nel codice JavaScript inviato al browser è una vulnerabilità di sicurezza.
### Soluzione Proposta: PHP Proxy Script
Proponiamo la creazione di un file `search-contratti.php` locale alla pagina che:
1. Riceve in GET il parametro di ricerca `term` ed il codice fiscale dell'utente `cod_fis`.
2. Esegue una chiamata cURL server-to-server verso il WS reale usando le credenziali memorizzate in sicurezza in `config.json`.
3. Restituisce il JSON dei contratti trovati al client.
Il componente client-side eseguirà la fetch locale verso questo file `search-contratti.php` ed implementerà un dropdown di autocompletamento in JavaScript nativo (Vanilla JS) per mantenere le stesse animazioni ed interazioni della versione React (debounce, navigazione con frecce tastiera, selezione ed autocompilazione dei campi nascosti del form).
```
[Browser Client]
│ (fetch /term=...)
[search-contratti.php] (Proxy locale in php/)
│ (cURL server-to-server + Basic Auth & API Key)
[Web Service Contratti]
```
## Proposed Changes
### Componente: `php/recupero-proposta-cct-da-contratti/`
#### [NEW] [config.json](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/recupero-proposta-cct-da-contratti/config.json)
Copia del file di configurazione con le credenziali API, l'endpoint del Web Service ed i mapping dei campi elixForms.
#### [NEW] [search-contratti.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/recupero-proposta-cct-da-contratti/search-contratti.php)
Script PHP proxy che:
- Valida l'input.
- Effettua la richiesta HTTP GET al WS esterno tramite `curl`.
- Passa l'header `Authorization: Basic ...` e `X-Api-Key: ...`.
- Ritorna `application/json` con i risultati.
#### [NEW] [index.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/recupero-proposta-cct-da-contratti/index.php)
Pagina custom principale:
- Eredita da `ElixFormsComponent`.
- Legge il Codice Fiscale dell'utente tramite `QueryParamHelper::getDecodedTextFromQuery`.
- Genera i campi nascosti per i dati di output (`idDomanda`, `idRicevuta`, `codiceContratto`, `titoloContratto`).
- Rendering dell'input di ricerca ed implementazione del JavaScript nativo per l'autocompletamento (sincronizzato con il file proxy `search-contratti.php`).
## Verification Plan
### Manual Verification
- Avviare il server PHP locale: `php -S localhost:8080` nella cartella `php/recupero-proposta-cct-da-contratti/`.
- Verificare che il proxy `search-contratti.php?term=test&cod_fis=...` risponda correttamente con i dati mock o reali del WS.
- Verificare che digitando nel campo di ricerca "Cerca Contratto" vengano visualizzati i risultati nel menu a discesa.
- Testare la navigazione del menu a discesa con la tastiera (Frecce Su/Giù, Enter, Esc).
- Verificare che alla selezione di un contratto, i campi hidden vengano correttamente compilati con i valori associati (`idDomanda`, `idRicevuta`, ecc.) e venga mostrata la card riepilogativa del contratto selezionato.
+44
View File
@@ -0,0 +1,44 @@
# Walkthrough — Port PHP + Agenti Custom
Ho completato tutte le attività pianificate per la realizzazione della struttura in PHP delle custom pages elixForms e per la configurazione dei due agenti dedicati ("React" e "PHP").
## Modifiche e Creazioni Effettuate
### 1. Libreria Comune PHP (`php/common/`)
Abbiamo replicato la stessa logica e architettura della libreria comune React in PHP 8.0+:
- **[QueryParamHelper.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/common/QueryParamHelper.php)**: Fornisce metodi statici tipizzati per estrarre e decodificare i dati provenienti dalla query string (`$_GET`).
- **[ElixFormsElement.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/common/ElixFormsElement.php)**: Gestisce il layout standard a due colonne (label a sinistra, input a destra) con le classi Bootstrap.
- **[ElixFormsComponent.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/common/ElixFormsComponent.php)**: Classe astratta base che definisce il ciclo di rendering dell'intera pagina e tutti i metodi factory dei campi (testo, textarea, checkbox, radio, dropdown, boolean). Gestisce automaticamente la presenza dei parametri obbligatori elixForms ed inserisce il JavaScript client-side per la sincronizzazione dei checkbox.
### 2. Pagina di Esempio PHP
- **[index.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/scelta-carriera/index.php)**: Una pagina "Scelta Carriera" d'esempio che estende `ElixFormsComponent` e definisce tutti i campi form (dropdown, text, textarea, boolean, radio, checkbox), replicando fedelmente la controparte React.
### 3. Nuove Skill PHP
Abbiamo creato tre nuove skill all'interno di `.agents/skills/` per guidare lo sviluppo PHP futuro:
- **[php-common-library/SKILL.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/php-common-library/SKILL.md)**: Dettaglia i metodi factory, il layout ed i parametri del componente base.
- **[php-create-new-page/SKILL.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/php-create-new-page/SKILL.md)**: Guida passo-passo per lo scaffolding e testing locale di nuove pagine.
- **[php-project-architecture/SKILL.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/php-project-architecture/SKILL.md)**: Definisce le differenze architetturali rispetto al mondo React e le modalità di deploy su Apache.
### 4. Configurazione degli Agenti Dedicati
Abbiamo strutturato due file di istruzioni separati che istruiscono l'IDE sul comportamento da adottare a seconda che si stia lavorando in React o in PHP:
- **[react/AGENTS.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/react/AGENTS.md)** (Agente React): Focalizzato su Vite, bundling single-file, TypeScript (`import type`), e React Class/Functional Components.
- **[php/AGENTS.md](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/AGENTS.md)** (Agente PHP): Focalizzato su PHP 8.0+ puro, PSR-4 namespaces, server locale integrato, e deploy diretto su Apache.
---
## Istruzioni per il Test Locale PHP
1. Navigare nella cartella della pagina di esempio:
```bash
cd php/scelta-carriera
```
2. Avviare il server built-in di PHP:
```bash
php -S localhost:8080
```
3. Visitare l'URL inserendo dei parametri fittizi di prova per bypassare il controllo di autenticazione:
```
http://localhost:8080/?RWE2_MODULE_ID=123&RWE2_REQUEST_ID=456&custom-workflow-back-url=http://example.com&custom-workflow-generic-id=1&custom-workflow-current-tabrel-genid=2&custom-workflow-source-field=src&crc=abc&MODULE_TESTMODE_KEY=key&ELANG=it&COL0002=ValoreTest
```
4. Il form caricherà la pagina con il tema e layout corretti di Bootstrap Italia e i campi pre-popolati.
+8
View File
@@ -0,0 +1,8 @@
FROM php:8.5.8-cli
RUN pecl install xdebug && docker-php-ext-enable xdebug
WORKDIR /app
EXPOSE 8000
CMD ["php", "-S", "0.0.0.0:8000"]
+10
View File
@@ -0,0 +1,10 @@
#zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20250925/xdebug.so
[xdebug]
xdebug.mode=develop,debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9023
xdebug.log=/tmp/xdebug.log
xdebug.log_level=7
xdebug.idekey=VSCODE
+5
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
# Dependency directories # Dependency directories
node_modules node_modules
vendor
# Build generated files # Build generated files
dist dist
@@ -27,6 +28,7 @@ temp
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage coverage
.phpunit.result.cache
# OSX # OSX
.DS_Store .DS_Store
@@ -52,3 +54,6 @@ obj
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Configuration files
**/config.json
+57 -18
View File
@@ -5,11 +5,33 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Node - Recupero Proposta CCT da contratti",
"name": "Scelta Carriere (BROWSER)", "cwd": "${workspaceFolder}/react/recupero-proposta-cct-da-contratti",
"env": {
"NO_COLOR": "true"
},
"outputCapture": "console",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"skipFiles": [
"<node_internals>/**",
"**/node_modules/**",
"**/@vite/client/**"
],
"serverReadyAction": {
"action": "debugWithChrome",
"pattern": "Local:\\s+http://localhost:([0-9]+)/",
"uriFormat": "http://localhost:%s",
"webRoot": "${workspaceFolder}/react/recupero-proposta-cct-da-contratti/src"
}
},
{
"name": "Node - Scelta Carriere",
"cwd": "${workspaceFolder}/react/scelta-carriera", "cwd": "${workspaceFolder}/react/scelta-carriera",
"env": { "env": {
"NO_COLOR": "1" "NO_COLOR": "true"
}, },
"outputCapture": "console", "outputCapture": "console",
"type": "node", "type": "node",
@@ -26,24 +48,41 @@
"pattern": "Local:\\s+http://localhost:([0-9]+)/", "pattern": "Local:\\s+http://localhost:([0-9]+)/",
"uriFormat": "http://localhost:%s", "uriFormat": "http://localhost:%s",
"webRoot": "${workspaceFolder}/react/scelta-carriera/src" "webRoot": "${workspaceFolder}/react/scelta-carriera/src"
}, }
}, },
{ {
"name": "Scelta Carriere (DEBUG ONLY)", "name": "WWW - Listen for Xdebug",
"cwd": "${workspaceFolder}/react/scelta-carriera", "type": "php",
"env": {
"NO_COLOR": "1"
},
"outputCapture": "console",
"type": "node",
"request": "launch", "request": "launch",
"runtimeExecutable": "npm", "hostname": "0.0.0.0",
"runtimeArgs": ["run", "dev"], "port": 9023,
"skipFiles": [ "pathMappings": {
"<node_internals>/**", "/app": "${workspaceFolder}/php"
"**/node_modules/**",
"**/@vite/client/**"
]
}, },
"log": true
},
{
"name": "WWW - Launch Built-in web server",
"type": "php",
"request": "launch",
"runtimeArgs": [
"-dxdebug.mode=debug",
"-dxdebug.start_with_request=yes",
"-S",
"localhost:0"
],
"program": "",
"cwd": "${workspaceRoot}",
"port": 9023,
"serverReadyAction": {
"pattern": "Development Server \\(http://localhost:([0-9]+)\\) started",
"uriFormat": "http://localhost:%s",
"action": "openExternally"
},
"env": {
"XDEBUG_MODE": "debug,develop",
"XDEBUG_CONFIG": "client_port=${port}"
}
}
] ]
} }
+10
View File
@@ -0,0 +1,10 @@
@echo off
SETLOCAL EnableDelayedExpansion
echo %cmdcmdline% | findstr /i /c:"%~nx0" >NUL && set iscommandline=1
echo %PSModulePath% | findstr /i /c:"%USERPROFILE%" >NUL && set ispowershell=1
cd /D "%~dp0"
@pwsh -NoProfile -ExecutionPolicy Bypass -Command .\scripts\composer-dump-autoload.ps1
IF DEFINED iscommandline IF NOT DEFINED ispowershell pause
ENDLOCAL
+10
View File
@@ -0,0 +1,10 @@
@echo off
SETLOCAL EnableDelayedExpansion
echo %cmdcmdline% | findstr /i /c:"%~nx0" >NUL && set iscommandline=1
echo %PSModulePath% | findstr /i /c:"%USERPROFILE%" >NUL && set ispowershell=1
cd /D "%~dp0"
@pwsh -NoProfile -ExecutionPolicy Bypass -Command .\scripts\composer-install.ps1
IF DEFINED iscommandline IF NOT DEFINED ispowershell pause
ENDLOCAL
+25
View File
@@ -0,0 +1,25 @@
{
"name": "elixforms-web-pages/pages",
"description": "Custom web pages PHP per elixForms",
"license": "proprietary",
"require": {
"php": ">=8.0",
"guzzlehttp/guzzle": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
},
"autoload": {
"psr-4": {
"ElixForms\\Common\\": "php/common/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit tests"
}
}
Generated
+2491
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
name: elixforms-www
services:
elixforms-www:
build:
context: .
dockerfile: .docker/php/Dockerfile
container_name: elixforms-www
working_dir: /app/php
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "8080:8000"
volumes:
- .:/app
- ./.docker/php/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini
#environment:
# XDEBUG_MODE: debug
# XDEBUG_CONFIG: client_host=host.docker.internal client_port=9003 start_with_request=yes
+11
View File
@@ -0,0 +1,11 @@
# Mock server
## JSON-Server
See: <https://github.com/typicode/json-server>
To start server:
```pwsh
npx json-server db.json
```
+52
View File
@@ -0,0 +1,52 @@
{
"$schema": [
"./node_modules/json-server/schema.json"
],
"posts": [
{
"id": "1",
"title": "a title",
"views": 100
},
{
"id": "2",
"title": "another title",
"views": 200
}
],
"comments": [
{
"id": "1",
"text": "a comment about post 1",
"postId": "1"
},
{
"id": "2",
"text": "another comment about post 1",
"postId": "1"
}
],
"contratti": [
{
"idContratto": "ID_CONTRATTO_1",
"titoloContratto": "Titolo del contratto #1",
"idDomanda": "12345",
"idRicevuta": "ABCDE"
},
{
"idContratto": "ID_CONTRATTO_2",
"titoloContratto": "Titolo del secondo contratto",
"idDomanda": "67890",
"idRicevuta": "FGHIJ"
},
{
"idContratto": "ID_CONTRATTO_3",
"titoloContratto": "Terzo titolo",
"idDomanda": "13579",
"idRicevuta": "KLMNO"
}
],
"profile": {
"name": "typicode"
}
}
+532
View File
@@ -0,0 +1,532 @@
{
"name": "mock-server",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"json-server": "^1.0.0-beta.15"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"license": "MIT"
},
"node_modules/@tinyhttp/accepts": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@tinyhttp/accepts/-/accepts-2.3.0.tgz",
"integrity": "sha512-hdKkMGAUqnagpWO1R8rVBYqbu4sWQ2Fo682gkJmO0nl54DPvnzxx81b2WZtV3VwB7EdLfUoasj2BAkyTcyZ5aw==",
"license": "MIT",
"dependencies": {
"mime": "4.1.0"
},
"engines": {
"node": ">=14.13.1"
},
"funding": {
"type": "individual",
"url": "https://github.com/tinyhttp/tinyhttp?sponsor=1"
}
},
"node_modules/@tinyhttp/app": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@tinyhttp/app/-/app-3.0.7.tgz",
"integrity": "sha512-btit/gSWisksJ19crNLct1mwZvX+/AYwh/H1x8SB/VGDmNFkSsdRDZaa54wW1Eq3bM7mCIr7+l7Oh3tAi/C+Bw==",
"license": "MIT",
"dependencies": {
"@tinyhttp/accepts": "^2.3.0",
"@tinyhttp/cookie": "2.1.1",
"@tinyhttp/proxy-addr": "3.0.1",
"@tinyhttp/req": "2.2.8",
"@tinyhttp/res": "2.2.11",
"@tinyhttp/router": "2.2.5",
"regexparam": "^2.0.2"
},
"engines": {
"node": ">=16.10.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/tinyhttp/tinyhttp?sponsor=1"
}
},
"node_modules/@tinyhttp/content-disposition": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz",
"integrity": "sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==",
"license": "MIT",
"engines": {
"node": ">=12.17.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/tinyhttp/tinyhttp?sponsor=1"
}
},
"node_modules/@tinyhttp/content-type": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@tinyhttp/content-type/-/content-type-0.1.4.tgz",
"integrity": "sha512-dl6f3SHIJPYbhsW1oXdrqOmLSQF/Ctlv3JnNfXAE22kIP7FosqJHxkz/qj2gv465prG8ODKH5KEyhBkvwrueKQ==",
"license": "MIT",
"engines": {
"node": ">=12.4"
}
},
"node_modules/@tinyhttp/cookie": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@tinyhttp/cookie/-/cookie-2.1.1.tgz",
"integrity": "sha512-h/kL9jY0e0Dvad+/QU3efKZww0aTvZJslaHj3JTPmIPC9Oan9+kYqmh3M6L5JUQRuTJYFK2nzgL2iJtH2S+6dA==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"funding": {
"type": "individual",
"url": "https://github.com/tinyhttp/tinyhttp?sponsor=1"
}
},
"node_modules/@tinyhttp/cookie-signature": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@tinyhttp/cookie-signature/-/cookie-signature-2.1.1.tgz",
"integrity": "sha512-VDsSMY5OJfQJIAtUgeQYhqMPSZptehFSfvEEtxr+4nldPA8IImlp3QVcOVuK985g4AFR4Hl1sCbWCXoqBnVWnw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/@tinyhttp/cors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@tinyhttp/cors/-/cors-2.0.1.tgz",
"integrity": "sha512-qrmo6WJuaiCzKWagv2yA/kw6hIISfF/hOqPWwmI6w0o8apeTMmRN3DoCFvQ/wNVuWVdU5J4KU7OX8aaSOEq51A==",
"license": "MIT",
"dependencies": {
"@tinyhttp/vary": "^0.1.3"
},
"engines": {
"node": ">=12.20 || 14.x || >=16"
}
},
"node_modules/@tinyhttp/encode-url": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@tinyhttp/encode-url/-/encode-url-2.1.1.tgz",
"integrity": "sha512-AhY+JqdZ56qV77tzrBm0qThXORbsVjs/IOPgGCS7x/wWnsa/Bx30zDUU/jPAUcSzNOzt860x9fhdGpzdqbUeUw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/@tinyhttp/etag": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@tinyhttp/etag/-/etag-2.1.2.tgz",
"integrity": "sha512-j80fPKimGqdmMh6962y+BtQsnYPVCzZfJw0HXjyH70VaJBHLKGF+iYhcKqzI3yef6QBNa8DKIPsbEYpuwApXTw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/@tinyhttp/forwarded": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@tinyhttp/forwarded/-/forwarded-2.1.2.tgz",
"integrity": "sha512-9H/eulJ68ElY/+zYpTpNhZ7vxGV+cnwaR6+oQSm7bVgZMyuQfgROW/qvZuhmgDTIxnGMXst+Ba4ij6w6Krcs3w==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/@tinyhttp/logger": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@tinyhttp/logger/-/logger-2.1.0.tgz",
"integrity": "sha512-Ma1fJ9CwUbn9r61/4HW6+nflsVoslpOnCrfQ6UeZq7GGIgwLzofms3HoSVG7M+AyRMJpxlfcDdbH5oFVroDMKA==",
"license": "MIT",
"dependencies": {
"colorette": "^2.0.20",
"dayjs": "^1.11.13",
"http-status-emojis": "^2.2.0"
},
"engines": {
"node": ">=14.18 || >=16.20"
}
},
"node_modules/@tinyhttp/proxy-addr": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@tinyhttp/proxy-addr/-/proxy-addr-3.0.1.tgz",
"integrity": "sha512-vP0JVsy9ZMIldsaP/QHdMF+sb3B6wn7e2QXRdqpX/Cqz1ie35Am29DK88DeVmiwdTQle3FtYaVNtU3RgTGYZ+w==",
"license": "MIT",
"dependencies": {
"@tinyhttp/forwarded": "2.1.2"
},
"engines": {
"node": ">=16.10.0"
}
},
"node_modules/@tinyhttp/req": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@tinyhttp/req/-/req-2.2.8.tgz",
"integrity": "sha512-HCsceFNgMpssUsnRao16iJyyfdWRwKlhL7OMTPUEjZsZGREnBzpjlrPHA31G5xNNzR7XOWVXDXGyrGgSpcwGSA==",
"license": "MIT",
"dependencies": {
"@tinyhttp/accepts": "2.3.0",
"@tinyhttp/type-is": "2.2.5",
"@tinyhttp/url": "2.1.1",
"header-range-parser": "^1.1.3"
},
"engines": {
"node": ">=14.13.1"
}
},
"node_modules/@tinyhttp/res": {
"version": "2.2.11",
"resolved": "https://registry.npmjs.org/@tinyhttp/res/-/res-2.2.11.tgz",
"integrity": "sha512-t7GJzjqpG2svJ11RYvqaYU+xTV9MsEr0usbTIAWa5B8d7IIzpMC6AlT2K6wCIu1XGUYRpZY7qMM5cMgluvsfeg==",
"license": "MIT",
"dependencies": {
"@tinyhttp/content-disposition": "2.2.4",
"@tinyhttp/cookie": "2.1.1",
"@tinyhttp/cookie-signature": "2.1.1",
"@tinyhttp/encode-url": "2.1.1",
"@tinyhttp/req": "2.2.8",
"@tinyhttp/send": "2.2.5",
"@tinyhttp/vary": "^0.1.3",
"mime": "4.1.0"
},
"engines": {
"node": ">=14.13.1"
}
},
"node_modules/@tinyhttp/router": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@tinyhttp/router/-/router-2.2.5.tgz",
"integrity": "sha512-HI9Mpo9+IVpCzx/36okjJvtvifBSh3Ufhl9n1vylAbNLEykceJiMBkr06+W0qqRlo8TiZeUtg2XinEJu+GFcRA==",
"license": "MIT",
"dependencies": {
"regexparam": "^2.0.2"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tinyhttp/send": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@tinyhttp/send/-/send-2.2.5.tgz",
"integrity": "sha512-XhBwziPOCydOJzb9rVw0xuKX6HmMA0gXKHHqec7V97jU6JtSCCIvwW2FBMh/XrG9S5W8DRETroPohASrIwf7Uw==",
"license": "MIT",
"dependencies": {
"@tinyhttp/content-type": "^0.1.4",
"@tinyhttp/etag": "2.1.2",
"mime": "4.1.0"
},
"engines": {
"node": ">=14.13.1"
}
},
"node_modules/@tinyhttp/type-is": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@tinyhttp/type-is/-/type-is-2.2.5.tgz",
"integrity": "sha512-BCPEB+NV8v/9lzEE9GbfRPAKVsyayp84m6SSWn70j8yFkPBXeuVeq004pwVrjW1CRdmAZz9ZSH147pqqzAdr5g==",
"license": "MIT",
"dependencies": {
"@tinyhttp/content-type": "^0.1.4",
"mime": "4.1.0"
},
"engines": {
"node": ">=14.13.1"
}
},
"node_modules/@tinyhttp/url": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@tinyhttp/url/-/url-2.1.1.tgz",
"integrity": "sha512-POJeq2GQ5jI7Zrdmj22JqOijB5/GeX+LEX7DUdml1hUnGbJOTWDx7zf2b5cCERj7RoXL67zTgyzVblBJC+NJWg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/@tinyhttp/vary": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@tinyhttp/vary/-/vary-0.1.3.tgz",
"integrity": "sha512-SoL83sQXAGiHN1jm2VwLUWQSQeDAAl1ywOm6T0b0Cg1CZhVsjoiZadmjhxF6FHCCY7OHHVaLnTgSMxTPIDLxMg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/dot-prop": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz",
"integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==",
"license": "MIT",
"dependencies": {
"type-fest": "^5.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eta": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz",
"integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/bgub/eta?sponsor=1"
}
},
"node_modules/header-range-parser": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/header-range-parser/-/header-range-parser-1.1.5.tgz",
"integrity": "sha512-n5JOx67HBL0MGqtu6NFoEYWb+xDYAOgBI5dBkyMDff1xHbhGnjCMglj1aiMNPHps6HwXO+2i5jbPU/zJSk7etQ==",
"license": "MIT",
"engines": {
"node": ">=12.22.0"
}
},
"node_modules/http-status-emojis": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/http-status-emojis/-/http-status-emojis-2.2.0.tgz",
"integrity": "sha512-ompKtgwpx8ff0hsbpIB7oE4ax1LXoHmftsHHStMELX56ivG3GhofTX8ZHWlUaFKfGjcGjw6G3rPk7dJRXMmbbg==",
"license": "MIT"
},
"node_modules/inflection": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/inflection/-/inflection-3.0.2.tgz",
"integrity": "sha512-+Bg3+kg+J6JUWn8J6bzFmOWkTQ6L/NHfDRSYU+EVvuKHDxUDHAXgqixHfVlzuBQaPOTac8hn43aPhMNk6rMe3g==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/json-server": {
"version": "1.0.0-beta.15",
"resolved": "https://registry.npmjs.org/json-server/-/json-server-1.0.0-beta.15.tgz",
"integrity": "sha512-I5UB/OWHLGoQW9IVld2yzZFYYiGYNBn2OYRlGbkfj5xDidT26yKYkV7Sr093zJtPh9zbofaLgyT89Ov5U0RRBQ==",
"license": "MIT",
"dependencies": {
"@tinyhttp/app": "^3.0.1",
"@tinyhttp/cors": "^2.0.1",
"@tinyhttp/logger": "^2.1.0",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
"dot-prop": "^10.1.0",
"eta": "^4.5.0",
"inflection": "^3.0.2",
"json5": "^2.2.3",
"lowdb": "^7.0.1",
"milliparsec": "^5.1.0",
"sirv": "^3.0.2",
"sort-on": "^7.0.0"
},
"bin": {
"json-server": "lib/bin.js"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/lowdb": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz",
"integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==",
"license": "MIT",
"dependencies": {
"steno": "^4.0.2"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/milliparsec": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/milliparsec/-/milliparsec-5.1.1.tgz",
"integrity": "sha512-jkEDaSWZp4/Q3vprqdqukBqUEyNNqC1pwTjZ5cp9YkaR1wv5fvTCd8VFsecbw7i8DNBGjzhJ83MDoPZlcTaPQg==",
"license": "MIT",
"engines": {
"node": ">=18.13 || >=19.20 || >=20"
}
},
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/regexparam": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz",
"integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/sort-on": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/sort-on/-/sort-on-7.0.0.tgz",
"integrity": "sha512-e+4RRxt7jsWdGPp4H5PKOER/ELYlemNB1plvW686Qi3j4WVaCjCpro2zaTD7Cn0VtBImq/hg3x1JfovMNXXfJQ==",
"license": "MIT",
"dependencies": {
"dot-prop": "^10.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/steno": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz",
"integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/tagged-tag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/type-fest": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
"integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"json-server": "^1.0.0-beta.15"
}
}
+43
View File
@@ -0,0 +1,43 @@
# Agente PHP — Regole e Convenzioni
Questo file definisce le istruzioni specifiche, le regole e le convenzioni per lo sviluppo e la manutenzione delle pagine custom e dei componenti scritti in **PHP 8.0+** nella cartella `php/`.
## Gestione ID dei Form Element
Gli ID dei campi del form (es. nel Dropdown, Checkbox, TextField) creati tramite i metodi della classe [ElixFormsComponent.php](file:///c:/__Git/_UniPR/elixforms-custom-pages/php/common/ElixFormsComponent.php) **non devono mai essere cambiati o manipolati** (ad es. aggiungendo suffissi come `_dropdown`), in quanto la piattaforma elixForms si basa sul loro nome ed ID esatto.
In caso di conflitti di ID (ad esempio con input `hidden`), applicare il suffisso `_hidden` all'input hidden e mantenere l'ID originale senza suffissi sull'elemento principale visibile.
## Convenzioni di Codice e Framework
1. **PHP Puro (No Framework)**:
- Utilizzare PHP 8.0+ con tipizzazione forte (`declare(strict_types=1);`).
- Sfruttare le feature moderne come `match` expression, property promotion e typed properties.
- Le pagine custom ereditano da `ElixFormsComponent` e sovrascrivono `createCustomFormFields()`.
2. **Namespace PSR-4**:
- Tutte le classi nella libreria comune usano il namespace `ElixForms\Common;`.
- L'autoload PSR-4 è gestito da Composer tramite `vendor/autoload.php`.
- Gli entry point includono `require_once __DIR__ . '/../../vendor/autoload.php';`; le classi comuni non devono includersi manualmente tra loro.
3. **Rendering Server-Side**:
- I file PHP generano direttamente l'HTML server-side usando il design system Bootstrap Italia di UniPR.
- Non c'è alcun passaggio di build o bundling JS.
4. **Checkbox e Stato**:
- I checkbox inviano il proprio stato serializzato come stringa separata da virgole tramite un input hidden (gestito client-side da uno script JS automatico).
## Deploy e Server Locale
- **Dipendenze**: dalla root del repository eseguire `composer install --optimize-autoloader` oppure `composer-install.bat`.
- **Deploy**: Eseguire `composer install --no-dev --optimize-autoloader` e caricare sul server sia `php/` sia `vendor/`, mantenendole allo stesso livello.
- **Server locale**:
- **PHP CLI**: Avviare il server dalla cartella `php/` tramite `php -S localhost:8080` (le pagine saranno raggiungibili su `http://localhost:8080/<nome-pagina>/`).
- **Docker**: Dalla root del repository, eseguire:
```bash
docker run --rm -p 8080:8080 -v "${PWD}:/app" -w /app/php php:8.2-cli php -S 0.0.0.0:8080
```
## Skill PHP Correlate
Fai riferimento alle seguenti skill per maggiori dettagli operativi:
- [elixforms-php-common-library](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/php-common-library/SKILL.md): Classi, metodi factory e helper.
- [elixforms-php-create-new-page](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/php-create-new-page/SKILL.md): Guida passo passo per la creazione di nuove pagine.
- [elixforms-php-project-architecture](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/php-project-architecture/SKILL.md): Struttura delle cartelle, convenzioni e deploy.
- [elixforms-css-design-system](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/css-design-system/SKILL.md): Linee guida del design system UniPR.
- [elixforms-custom-workflow-logic](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/custom-workflow-logic/SKILL.md): Query parameters ed integrazione elixForms.
+607
View File
@@ -0,0 +1,607 @@
<?php
declare(strict_types=1);
namespace ElixForms\Common;
/**
* Classe astratta base per le custom page PHP di elixForms.
* Equivalente PHP di ElixFormsComponentAbstract.tsx nel progetto React.
*
* Ogni pagina custom deve:
* 1. Estendere questa classe
* 2. Sovrascrivere createCustomFormFields() per definire i campi del form
* 3. Opzionalmente sovrascrivere renderExtraContentPre() / renderExtraContentPost()
* 4. Chiamare render() per generare l'HTML completo della pagina
*
* Uso tipico:
* ```php
* $page = new MiaPaginaCustom([
* 'moduleName' => 'Nome Modulo',
* 'cardTitle' => 'Titolo Card',
* // ...altre proprietà...
* ]);
* echo $page->render();
* ```
*/
abstract class ElixFormsComponent
{
/**
* Proprietà della pagina (equivalente a IElixFormsComponentProperties).
*
* Chiavi supportate:
* - isDarkTheme: bool (opzionale)
* - userDisplayName: string (opzionale)
* - additionalFieldsJson: string (opzionale, JSON schema per campi dinamici)
* - moduleName: string (obbligatorio)
* - headerTitle: string (opzionale)
* - headerHeroImageSrc: string (opzionale)
* - cardTitle: string (obbligatorio)
* - cardDescription: string (opzionale)
* - alertInfoTitle: string (opzionale)
* - alertInfoMessage: string (opzionale)
* - instructionsTitle: string (opzionale)
* - instructionsMessage: string (opzionale)
* - submitText: string (opzionale, default 'INVIA')
*
* @var array<string, mixed>
*/
protected array $properties;
/**
* Nomi dei parametri obbligatori che elixForms passa in query string.
* Se mancano, la pagina mostra un errore di autenticazione.
*
* @var string[]
*/
private array $mandatoryFormFieldNames = [
'RWE2_MODULE_ID',
'RWE2_REQUEST_ID',
'custom-workflow-back-url',
'custom-workflow-generic-id',
'custom-workflow-current-tabrel-genid',
'custom-workflow-source-field',
'crc',
'MODULE_TESTMODE_KEY',
'ELANG',
];
/**
* @param array<string, mixed> $properties Proprietà della pagina
*/
public function __construct(array $properties)
{
$this->properties = $properties;
}
// =========================================================================
// Metodi estensibili (da sovrascrivere nelle pagine figlie)
// =========================================================================
/**
* Genera i campi custom del form.
* Da sovrascrivere nelle pagine figlie per definire i campi specifici.
*
* @return string HTML dei campi custom
*/
protected function createCustomFormFields(): string
{
return '';
}
/**
* Contenuto extra da renderizzare PRIMA del form.
* Sovrascrivere per aggiungere contenuto personalizzato.
*
* @return string HTML del contenuto extra
*/
protected function renderExtraContentPre(): string
{
return '';
}
/**
* Contenuto extra da renderizzare DOPO il form.
* Sovrascrivere per aggiungere contenuto personalizzato.
*
* @return string HTML del contenuto extra
*/
protected function renderExtraContentPost(): string
{
return '';
}
// =========================================================================
// Metodi factory per campi form
// =========================================================================
/**
* Crea un campo di testo (input type="text").
*
* @param string $paramName Nome/ID del campo (corrisponde alla colonna elixForms)
* @param string $label Etichetta del campo
* @param bool $required Se il campo è obbligatorio
* @return string HTML del campo
*/
protected function createTextInput(string $paramName, string $label, bool $required = false): string
{
$currentValue = htmlspecialchars(QueryParamHelper::getDecodedTextFromQuery($paramName) ?? '', ENT_QUOTES, 'UTF-8');
$requiredAttr = $required ? ' required' : '';
$labelHtml = '<label class="form-label fw-semibold" for="' . htmlspecialchars($paramName) . '">' . htmlspecialchars($label) . '</label>';
$inputHtml = '<input id="' . htmlspecialchars($paramName) . '" name="' . htmlspecialchars($paramName) . '" class="form-control" type="text" value="' . $currentValue . '"' . $requiredAttr . ' />';
return (new ElixFormsElement($labelHtml, $inputHtml))->render();
}
/**
* Crea un campo textarea.
*
* @param string $paramName Nome/ID del campo
* @param string $label Etichetta del campo
* @param bool $required Se il campo è obbligatorio
* @return string HTML del campo
*/
protected function createTextAreaInput(string $paramName, string $label, bool $required = false): string
{
$currentValue = htmlspecialchars(QueryParamHelper::getDecodedTextFromQuery($paramName) ?? '', ENT_QUOTES, 'UTF-8');
$requiredAttr = $required ? ' required' : '';
$labelHtml = '<label class="form-label fw-semibold" for="' . htmlspecialchars($paramName) . '">' . htmlspecialchars($label) . '</label>';
$inputHtml = '<textarea id="' . htmlspecialchars($paramName) . '" name="' . htmlspecialchars($paramName) . '" class="form-control" rows="4"' . $requiredAttr . '>' . $currentValue . '</textarea>';
return (new ElixFormsElement($labelHtml, $inputHtml))->render();
}
/**
* Crea un campo numerico (input type="number").
*
* @param string $paramName Nome/ID del campo
* @param string $label Etichetta del campo
* @param bool $required Se il campo è obbligatorio
* @return string HTML del campo
*/
protected function createNumberInput(string $paramName, string $label, bool $required = false): string
{
$currentValue = htmlspecialchars(QueryParamHelper::getDecodedTextFromQuery($paramName) ?? '', ENT_QUOTES, 'UTF-8');
$requiredAttr = $required ? ' required' : '';
$labelHtml = '<label class="form-label fw-semibold" for="' . htmlspecialchars($paramName) . '">' . htmlspecialchars($label) . '</label>';
$inputHtml = '<input id="' . htmlspecialchars($paramName) . '" name="' . htmlspecialchars($paramName) . '" class="form-control" type="number" value="' . $currentValue . '"' . $requiredAttr . ' />';
return (new ElixFormsElement($labelHtml, $inputHtml))->render();
}
/**
* Crea un campo booleano (radio Sì/No).
*
* @param string $paramName Nome/ID del campo
* @param string $label Etichetta del campo
* @param bool $required Se il campo è obbligatorio
* @return string HTML del campo
*/
protected function createBooleanInput(string $paramName, string $label, bool $required = false): string
{
$currentValue = QueryParamHelper::getDecodedTextFromQuery($paramName) ?? '';
$requiredAttr = $required ? ' required' : '';
$escapedName = htmlspecialchars($paramName);
$escapedLabel = htmlspecialchars($label);
$labelHtml = '<span class="form-label fw-semibold">' . $escapedLabel . '</span>';
$options = [
['value' => 'true', 'label' => 'Sì'],
['value' => 'false', 'label' => 'No'],
];
$inputHtml = '<div class="d-flex flex-column gap-2" role="radiogroup" aria-label="' . $escapedLabel . '">';
foreach ($options as $option) {
$id = $escapedName . '_' . $option['value'];
$checked = $currentValue === $option['value'] ? ' checked' : '';
$inputHtml .= <<<HTML
<div class="form-check">
<input id="{$id}" class="form-check-input" type="radio" name="{$escapedName}" value="{$option['value']}"{$checked}{$requiredAttr} />
<label class="form-check-label" for="{$id}">{$option['label']}</label>
</div>
HTML;
}
$inputHtml .= '</div>';
return (new ElixFormsElement($labelHtml, $inputHtml))->render();
}
/**
* Crea un campo radio con opzioni personalizzate.
*
* @param string $paramName Nome/ID del campo
* @param string $label Etichetta del campo
* @param array<array{value: int, label: string}> $options Opzioni radio
* @param bool $required Se il campo è obbligatorio
* @return string HTML del campo
*/
protected function createRadioInput(string $paramName, string $label, array $options, bool $required = false): string
{
$currentValue = (string)(QueryParamHelper::getOptionFromQuery($paramName) ?? '');
$requiredAttr = $required ? ' required' : '';
$escapedName = htmlspecialchars($paramName);
$escapedLabel = htmlspecialchars($label);
$labelHtml = '<span class="form-label fw-semibold">' . $escapedLabel . '</span>';
$inputHtml = '<div class="d-flex flex-column gap-2" role="radiogroup" aria-label="' . $escapedLabel . '">';
foreach ($options as $option) {
$id = $escapedName . '_' . $option['value'];
$checked = $currentValue === (string)$option['value'] ? ' checked' : '';
$optionLabel = htmlspecialchars($option['label']);
$inputHtml .= <<<HTML
<div class="form-check">
<input id="{$id}" class="form-check-input" type="radio" name="{$escapedName}" value="{$option['value']}"{$checked}{$requiredAttr} />
<label class="form-check-label" for="{$id}">{$optionLabel}</label>
</div>
HTML;
}
$inputHtml .= '</div>';
return (new ElixFormsElement($labelHtml, $inputHtml))->render();
}
/**
* Crea un campo checkbox con opzioni multiple.
* I valori selezionati vengono inviati come stringa separata da virgola in un input hidden.
*
* @param string $paramName Nome/ID del campo (usato per l'hidden input)
* @param string $label Etichetta del campo
* @param array<array{value: int, label: string, required?: bool}> $options Opzioni checkbox
* @return string HTML del campo
*/
protected function createCheckboxInput(string $paramName, string $label, array $options): string
{
$checkedValues = QueryParamHelper::getCheckedFromQuery($paramName);
$currentValue = implode(',', $checkedValues);
$escapedName = htmlspecialchars($paramName);
$escapedLabel = htmlspecialchars($label);
$labelHtml = '<label class="form-label fw-semibold" for="' . $escapedName . '">' . $escapedLabel . '</label>';
$inputHtml = '<div class="d-flex flex-column gap-2" role="group" aria-label="' . $escapedLabel . '">';
foreach ($options as $entryIndex => $entry) {
$id = $escapedName . '_' . $entryIndex;
$isChecked = in_array($entryIndex, $checkedValues) ? ' checked' : '';
$optionLabel = htmlspecialchars($entry['label']);
$inputHtml .= <<<HTML
<div class="form-check">
<input id="{$id}" class="form-check-input" type="checkbox" value="{$entryIndex}" data-param="{$escapedName}"{$isChecked} />
<label class="form-check-label" for="{$id}">{$optionLabel}</label>
</div>
HTML;
}
$inputHtml .= '</div>';
// Hidden input che contiene il valore aggregato (aggiornato via JS client-side)
$inputHtml .= '<input type="hidden" id="' . $escapedName . '" name="' . $escapedName . '" value="' . htmlspecialchars($currentValue) . '" />';
return (new ElixFormsElement($labelHtml, $inputHtml))->render();
}
/**
* Crea un campo dropdown (select).
*
* @param string $paramName Nome/ID del campo
* @param string $label Etichetta del campo
* @param array<array{value: int, label: string}> $options Opzioni dropdown
* @param bool $required Se il campo è obbligatorio
* @return string HTML del campo
*/
protected function createDropdownInput(string $paramName, string $label, array $options, bool $required = false): string
{
$queryValue = QueryParamHelper::getOptionFromQuery($paramName);
$currentValue = $queryValue !== null ? (string)$queryValue : '';
$requiredAttr = $required ? ' required' : '';
$escapedName = htmlspecialchars($paramName);
$escapedLabel = htmlspecialchars($label);
$labelHtml = '<label class="form-label fw-semibold" for="' . $escapedName . '">' . $escapedLabel . '</label>';
$inputHtml = '<select id="' . $escapedName . '" name="' . $escapedName . '" class="form-select"' . $requiredAttr . '>';
$inputHtml .= '<option value="">Seleziona...</option>';
foreach ($options as $option) {
$selected = $currentValue === (string)$option['value'] ? ' selected' : '';
$optionLabel = htmlspecialchars($option['label']);
$inputHtml .= '<option value="' . $option['value'] . '"' . $selected . '>' . $optionLabel . '</option>';
}
$inputHtml .= '</select>';
return (new ElixFormsElement($labelHtml, $inputHtml))->render();
}
/**
* Crea un campo nascosto (hidden input).
* L'ID dell'hidden ha il suffisso _hidden per evitare conflitti.
*
* @param string $paramName Nome del campo
* @return string HTML dell'input hidden
*/
protected function createHiddenInput(string $paramName): string
{
$value = htmlspecialchars(QueryParamHelper::getDecodedTextFromQuery($paramName) ?? '', ENT_QUOTES, 'UTF-8');
$escapedName = htmlspecialchars($paramName);
return '<input type="hidden" id="' . $escapedName . '_hidden" name="' . $escapedName . '" value="' . $value . '" />';
}
// =========================================================================
// Metodi interni di rendering
// =========================================================================
/**
* Genera gli hidden input per tutti i parametri obbligatori elixForms.
*
* @return string HTML degli hidden input
*/
private function createMandatoryFormFields(): string
{
$html = '';
foreach ($this->mandatoryFormFieldNames as $paramName) {
$html .= $this->createHiddenInput($paramName);
}
return $html;
}
/**
* Genera i campi form da uno schema JSON (additionalFieldsJson).
*
* @return string HTML dei campi aggiuntivi
*/
private function createAdditionalFormFields(): string
{
$json = $this->properties['additionalFieldsJson'] ?? '[]';
$schema = json_decode($json, true);
if (!is_array($schema)) {
return '';
}
$html = '';
foreach ($schema as $field) {
$html .= $this->renderField($field);
}
return $html;
}
/**
* Renderizza un singolo campo form in base al tipo specificato nello schema.
*
* @param array<string, mixed> $field Definizione del campo
* @return string HTML del campo
*/
private function renderField(array $field): string
{
$key = $field['key'] ?? '';
$fieldLabel = $field['label'] ?? '';
$required = $field['required'] ?? false;
$fieldOptions = $field['options'] ?? [];
return match ($field['type'] ?? '') {
'text' => $this->createTextInput($key, $fieldLabel, $required),
'textarea' => $this->createTextAreaInput($key, $fieldLabel, $required),
'number' => $this->createNumberInput($key, $fieldLabel, $required),
'boolean' => $this->createBooleanInput($key, $fieldLabel, $required),
'radio' => $this->createRadioInput($key, $fieldLabel, $fieldOptions, $required),
'checkbox' => $this->createCheckboxInput($key, $fieldLabel, $fieldOptions),
'dropdown' => $this->createDropdownInput($key, $fieldLabel, $fieldOptions, $required),
default => '',
};
}
// =========================================================================
// Render principale
// =========================================================================
/**
* Genera l'HTML completo della pagina.
* Include: head con CSS, header Bootstrap Italia, breadcrumb, card con form, footer.
*
* @return string HTML completo della pagina
*/
public function render(): string
{
$userDisplayName = $this->properties['userDisplayName'] ?? '';
$moduleName = htmlspecialchars($this->properties['moduleName'] ?? '');
$headerTitle = $this->properties['headerTitle'] ?? '';
$headerHeroImageSrc = $this->properties['headerHeroImageSrc'] ?? '';
$cardTitle = $this->properties['cardTitle'] ?? '';
$cardDescription = $this->properties['cardDescription'] ?? '';
$alertInfoTitle = $this->properties['alertInfoTitle'] ?? '';
$alertInfoMessage = $this->properties['alertInfoMessage'] ?? '';
$instructionsTitle = $this->properties['instructionsTitle'] ?? '';
$instructionsMessage = $this->properties['instructionsMessage'] ?? '';
$submitText = $this->properties['submitText'] ?? 'INVIA';
// Verifica parametri obbligatori
$missingParams = array_filter($this->mandatoryFormFieldNames, fn($p) => !isset($_GET[$p]));
$hasAuthenticationError = count($missingParams) > 0;
// Contenuti del form
$mandatoryFields = $this->createMandatoryFormFields();
$additionalFields = $this->createAdditionalFormFields();
$customFields = $this->createCustomFormFields();
$extraContentPre = $this->renderExtraContentPre();
$extraContentPost = $this->renderExtraContentPost();
// Costruisci l'HTML della pagina
ob_start();
?>
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($cardTitle) ?> — elixForms</title>
<meta name="description" content="<?= htmlspecialchars($cardDescription) ?>">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Titillium+Web:wght@300;400;600;700&display=swap">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-italia@2.18.2/dist/css/bootstrap-italia.min.css">
<link rel="stylesheet" href="https://console-unipr.elixforms.it/rwe2/css/design-bs/util.css">
<link rel="stylesheet" href="https://console-unipr.elixforms.it/rwe2/css/themes/blu-italia.css">
</head>
<body>
<div>
<header class="it-header-wrapper text-white">
<?php if ($userDisplayName): ?>
<div class="it-header-slim-wrapper">
<div class="container">
<div class="row">
<div class="col-12">
<div class="it-header-slim-wrapper-content">
<div class="nav-mobile"></div>
<div class="it-header-slim-right-zone">
<div class="it-access-top-wrapper">
<?= htmlspecialchars($userDisplayName) ?>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<div class="it-header-center-wrapper">
<div class="container">
<div class="it-header-center-content-wrapper">
<div class="it-brand-wrapper">
<a href="https://www.unipr.it/" title="Torna alla homepage dell'Università di Parma">
<img class="icon" src="https://procedure.unipr.it/UploadImgs/12_marchio_UNIPR_neg_b1_squareLogo.png" alt="Università di Parma" />
<div class="it-brand-text">
<p class="h2 no_toc">Università di Parma</p>
</div>
</a>
</div>
<div class="it-right-zone"></div>
</div>
</div>
</div>
</header>
<div class="container my-2 mx-4">
<div class="cmp-breadcrumb" role="navigation">
<nav class="breadcrumb-container" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">Procedure Online<span class="separator">&gt;</span></li>
<li class="breadcrumb-item active" aria-current="page"><?= $moduleName ?></li>
</ol>
</nav>
</div>
<?php if ($headerHeroImageSrc): ?>
<img src="<?= htmlspecialchars($headerHeroImageSrc) ?>" alt="Hero Image" class="hero-image img-fluid mb-3" />
<?php endif; ?>
<?php if ($headerTitle): ?>
<p class="h2"><?= htmlspecialchars($headerTitle) ?></p>
<?php endif; ?>
<div class="container">
<div class="card shadow-sm mb-4">
<div class="card-body">
<?php if ($cardTitle): ?>
<p class="h3 mb-3"><?= htmlspecialchars($cardTitle) ?></p>
<?php endif; ?>
<?php if ($cardDescription): ?>
<p class="lead text-secondary"><?= htmlspecialchars($cardDescription) ?></p>
<?php endif; ?>
<?php if ($alertInfoTitle || $alertInfoMessage): ?>
<div class="custom-alert custom-alert-warning" role="status">
<?php if ($alertInfoTitle): ?><div><b><?= htmlspecialchars($alertInfoTitle) ?></b></div><?php endif; ?>
<?php if ($alertInfoMessage): ?><div class="small"><?= htmlspecialchars($alertInfoMessage) ?></div><?php endif; ?>
</div>
<?php endif; ?>
<?php if ($hasAuthenticationError): ?>
<div class="alert alert-danger" role="alert">Accesso non autorizzato!</div>
<?php else: ?>
<?= $extraContentPre ?>
<?php if ($instructionsTitle || $instructionsMessage): ?>
<div class="custom-alert custom-alert-info" role="status">
<?php if ($instructionsTitle): ?><div><b><?= htmlspecialchars($instructionsTitle) ?></b></div><?php endif; ?>
<?php if ($instructionsMessage): ?><div class="small"><?= htmlspecialchars($instructionsMessage) ?></div><?php endif; ?>
</div>
<?php endif; ?>
<form action="https://procedure.unipr.it/rwe2/ComeBackToElixAndSave" method="post" accept-charset="ISO-8859-1" class="mt-3">
<?= $mandatoryFields ?>
<?= $additionalFields ?>
<?= $customFields ?>
<div class="mt-4 d-flex justify-content-end">
<button type="submit" class="btn btn-primary"><?= htmlspecialchars($submitText) ?> <span class="it-arrow-right text-white ms-2"></span></button>
</div>
</form>
<?= $extraContentPost ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
<footer class="it-footer text-white">
<div class="it-footer-small-prints clearfix">
<div class="container">
<section>
<div class="row clearfix">
<div class="col-sm-12">
<div class="footer-content text-center p-2">
<div class="subfooter-top">powered by <span class="highlight">elixForms</span></div>
</div>
</div>
</div>
</section>
</div>
</div>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
<?= $this->renderCheckboxScript() ?>
</body>
</html>
<?php
return ob_get_clean();
}
/**
* Genera il JavaScript per la gestione client-side dei checkbox.
* Aggiorna l'hidden input con i valori selezionati separati da virgola.
*
* @return string Tag <script> con il codice JS
*/
private function renderCheckboxScript(): string
{
return <<<'SCRIPT'
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('input[type="checkbox"][data-param]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
var paramName = this.getAttribute('data-param');
var hiddenInput = document.getElementById(paramName);
if (!hiddenInput) return;
var checkboxes = document.querySelectorAll('input[type="checkbox"][data-param="' + paramName + '"]');
var values = [];
checkboxes.forEach(function(cb) {
if (cb.checked) {
values.push(cb.value);
}
});
hiddenInput.value = values.join(',');
});
});
});
</script>
SCRIPT;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace ElixForms\Common;
/**
* Wrapper per layout label + input in un layout a due colonne Bootstrap.
* Equivalente PHP di ElixFormsElement.tsx nel progetto React.
*
* Genera markup compatibile con il design system Bootstrap Italia:
* - Colonna sinistra (col-12 col-md-4): label
* - Colonna destra (col-12 col-md-8): input
*/
class ElixFormsElement
{
private string $labelHtml;
private string $inputHtml;
private string $separator;
/**
* @param string $labelHtml HTML della label
* @param string $inputHtml HTML dell'input
* @param string $separator Separatore opzionale tra label e input
*/
public function __construct(string $labelHtml, string $inputHtml, string $separator = '')
{
if (empty($labelHtml) || empty($inputHtml)) {
throw new \InvalidArgumentException('labelHtml and inputHtml are required.');
}
$this->labelHtml = $labelHtml;
$this->inputHtml = $inputHtml;
$this->separator = $separator;
}
/**
* Genera l'HTML del campo form con layout a due colonne.
*
* @return string HTML renderizzato
*/
public function render(): string
{
$sep = $this->separator !== '' ? htmlspecialchars($this->separator) : '';
return <<<HTML
<div class="row mb-3 align-items-start">
<div class="col-12 col-md-4">
<div class="text-md-end pe-md-3 mt-2">
{$this->labelHtml}{$sep}
</div>
</div>
<div class="col-12 col-md-8">
{$this->inputHtml}
</div>
</div>
HTML;
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace ElixForms\Common;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\RequestOptions;
/**
* Client HTTP robusto per effettuare chiamate REST esterne.
* Adapter applicativo basato su Guzzle per la gestione uniforme di
* header, autenticazione, timeout ed errori delle chiamate esterne.
*/
class HttpClient
{
private ClientInterface $client;
private array $defaultHeaders;
private ?string $username = null;
private ?string $password = null;
private int $timeout = 10;
/**
* @param array<string, string> $defaultHeaders Header predefiniti per ogni richiesta
*/
public function __construct(array $defaultHeaders = [], ?ClientInterface $client = null)
{
$this->defaultHeaders = $defaultHeaders;
$this->client = $client ?? new Client();
}
/**
* Imposta le credenziali per l'autenticazione Basic.
*/
public function setBasicAuth(string $username, string $password): self
{
$this->username = $username;
$this->password = $password;
return $this;
}
/**
* Imposta il timeout massimo per la connessione e l'esecuzione.
*/
public function setTimeout(int $seconds): self
{
if ($seconds <= 0) {
throw new \InvalidArgumentException('Il timeout deve essere maggiore di zero.');
}
$this->timeout = $seconds;
return $this;
}
/**
* Esegue una richiesta HTTP GET.
*
* @param string $url URL della richiesta
* @param array<string, mixed> $queryParams Parametri query aggiuntivi
* @param array<string, string> $headers Header specifici per questa richiesta
* @return string Risposta in formato testuale
* @throws \RuntimeException In caso di errore HTTP o di trasporto
*/
public function get(string $url, array $queryParams = [], array $headers = []): string
{
$options = $queryParams === [] ? [] : [RequestOptions::QUERY => $queryParams];
return $this->request($url, 'GET', $headers, $options);
}
/**
* Esegue una richiesta HTTP POST.
*
* @param string $url URL della richiesta
* @param mixed $data Dati da inviare nel body (array, stringa o JSON)
* @param array<string, string> $headers Header specifici per questa richiesta
* @return string Risposta in formato testuale
* @throws \RuntimeException In caso di errore HTTP o di trasporto
*/
public function post(string $url, $data, array $headers = []): string
{
$options = is_array($data)
? [RequestOptions::FORM_PARAMS => $data]
: [RequestOptions::BODY => $data];
return $this->request($url, 'POST', $headers, $options);
}
/**
* @param array<string, string> $headers
* @param array<string, mixed> $requestOptions
*/
private function request(string $url, string $method, array $headers, array $requestOptions = []): string
{
$options = [
RequestOptions::HEADERS => array_merge($this->defaultHeaders, $headers),
RequestOptions::TIMEOUT => $this->timeout,
RequestOptions::CONNECT_TIMEOUT => $this->timeout,
RequestOptions::ALLOW_REDIRECTS => ['max' => 3],
RequestOptions::VERIFY => true,
RequestOptions::HTTP_ERRORS => true,
];
if ($this->username !== null && $this->password !== null) {
$options[RequestOptions::AUTH] = [$this->username, $this->password];
}
try {
$response = $this->client->request(
strtoupper($method),
$url,
array_replace($options, $requestOptions)
);
} catch (RequestException $exception) {
$response = $exception->getResponse();
if ($response !== null) {
throw new \RuntimeException(
"Richiesta fallita con codice di stato HTTP {$response->getStatusCode()}.",
0,
$exception
);
}
throw new \RuntimeException('Errore di connessione al servizio esterno.', 0, $exception);
} catch (GuzzleException $exception) {
throw new \RuntimeException('Errore durante la chiamata al servizio esterno.', 0, $exception);
}
$statusCode = $response->getStatusCode();
if ($statusCode < 200 || $statusCode >= 300) {
throw new \RuntimeException("Richiesta fallita con codice di stato HTTP {$statusCode}.");
}
return (string)$response->getBody();
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace ElixForms\Common;
/**
* Utility statica per leggere parametri dalla URL corrente ($_GET).
* Equivalente PHP di QueryParamHelper.tsx nel progetto React.
*/
class QueryParamHelper
{
/**
* Ottiene un array di valori numerici dalla query string (per checkbox).
* Il parametro è una stringa di valori separati da virgola (es. "1,3,5").
*
* @param string $paramName Nome del parametro nella query string
* @return int[] Array di interi
*/
public static function getCheckedFromQuery(string $paramName): array
{
$raw = $_GET[$paramName] ?? '';
if ($raw === '') {
return [];
}
$parts = explode(',', (string)$raw);
$result = [];
foreach ($parts as $part) {
$trimmed = trim($part);
if ($trimmed !== '' && is_numeric($trimmed)) {
$result[] = (int)$trimmed;
}
}
return $result;
}
/**
* Ottiene un singolo valore numerico dalla query string (per radio/dropdown).
*
* @param string $paramName Nome del parametro nella query string
* @return int|null Valore numerico o null se non presente
*/
public static function getOptionFromQuery(string $paramName): ?int
{
if (!isset($_GET[$paramName])) {
return null;
}
$value = $_GET[$paramName];
if (!is_numeric($value)) {
return null;
}
return (int)$value;
}
/**
* Ottiene un valore booleano dalla query string.
*
* @param string $paramName Nome del parametro nella query string
* @return bool|null true/false o null se non presente
*/
public static function getBooleanFromQuery(string $paramName): ?bool
{
if (!isset($_GET[$paramName])) {
return null;
}
return strtolower((string)$_GET[$paramName]) === 'true';
}
/**
* Ottiene un valore testuale decodificato dalla query string.
*
* @param string $paramName Nome del parametro nella query string
* @return string|null Valore decodificato o null se non presente
*/
public static function getDecodedTextFromQuery(string $paramName): ?string
{
if (!isset($_GET[$paramName])) {
return null;
}
return urldecode((string)$_GET[$paramName]);
}
}
@@ -0,0 +1,32 @@
{
"component": {
"minCharsForSearch": 3,
"moduleName": "Ripartizione Utili / Compensi - Presentazione Proposta",
"headerTitle": "",
"headerHeroImageSrc": "",
"cardTitle": "Recupero Proposta CCT da Contratti",
"cardDescription": "",
"alertInfoTitle": "",
"alertInfoMessage": "",
"instructionsTitle": "Istruzioni di compilazione",
"instructionsMessage": "Completa i campi richiesti per procedere con la richiesta.",
"submitText": "CONFERMA E PROSEGUI"
},
"elixForms": {
"inputs": {
"codiceFiscale": "COL0009"
},
"outputs": {
"idDomanda": "COL0001",
"idRicevuta": "COL0002",
"codiceContratto": "COL0003",
"titoloContratto": "COL0004"
}
},
"contrattiWS": {
"apiUrl": "http://localhost:8000/contratti/cerca",
"apiUsername": "apiUsername",
"apiPassword": "[PASSWORD]",
"apiKey": "apiKey"
}
}
@@ -0,0 +1,438 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use ElixForms\Common\ElixFormsComponent;
use ElixForms\Common\QueryParamHelper;
/**
* Pagina custom "Recupero Proposta CCT da Contratti" per elixForms.
* Equivalente PHP del componente React RecuperoPropostaCctDaContrattiComponent.
*/
class RecuperoPropostaCctDaContrattiPage extends ElixFormsComponent
{
private string $codiceFiscale;
private array $fieldKeys;
private int $minCharsForSearch;
public function __construct(array $properties, array $fieldKeys, int $minCharsForSearch = 3)
{
parent::__construct($properties);
$this->fieldKeys = $fieldKeys;
$this->minCharsForSearch = $minCharsForSearch;
// Recupera il codice fiscale dalla query string
$this->codiceFiscale = QueryParamHelper::getDecodedTextFromQuery($fieldKeys['codiceFiscale']) ?? '';
}
/**
* Genera l'HTML e CSS del componente Autocomplete e i relativi campi nascosti.
*/
protected function createCustomFormFields(): string
{
$escapedCodFis = htmlspecialchars($this->codiceFiscale, ENT_QUOTES, 'UTF-8');
$minChars = $this->minCharsForSearch;
// Campi di output definiti nella configurazione
$outCodiceContratto = htmlspecialchars($this->fieldKeys['codiceContratto']);
$outTitoloContratto = htmlspecialchars($this->fieldKeys['titoloContratto']);
$outIdDomanda = htmlspecialchars($this->fieldKeys['idDomanda']);
$outIdRicevuta = htmlspecialchars($this->fieldKeys['idRicevuta']);
// Recupero di eventuali valori correnti dal GET per precompilazione iniziale
$currCodice = htmlspecialchars(QueryParamHelper::getDecodedTextFromQuery($this->fieldKeys['codiceContratto']) ?? '', ENT_QUOTES, 'UTF-8');
$currTitolo = htmlspecialchars(QueryParamHelper::getDecodedTextFromQuery($this->fieldKeys['titoloContratto']) ?? '', ENT_QUOTES, 'UTF-8');
$currDomanda = htmlspecialchars(QueryParamHelper::getDecodedTextFromQuery($this->fieldKeys['idDomanda']) ?? '', ENT_QUOTES, 'UTF-8');
$currRicevuta = htmlspecialchars(QueryParamHelper::getDecodedTextFromQuery($this->fieldKeys['idRicevuta']) ?? '', ENT_QUOTES, 'UTF-8');
$initialSearchText = '';
if ($currCodice !== '' && $currTitolo !== '') {
$initialSearchText = "[{$currCodice}] {$currTitolo}";
}
// Layout del campo di ricerca
$autocompleteInputHtml = <<<HTML
<div class="autocomplete-wrapper" id="autocomplete-container">
<input
id="autocomplete-search"
name="autocomplete-search"
className="form-control"
class="form-control"
type="text"
placeholder="Digita almeno {$minChars} caratteri per cercare..."
value="{$initialSearchText}"
autoComplete="off"
/>
<div id="autocomplete-dropdown" class="autocomplete-dropdown d-none"></div>
<div id="selected-contract-card" class="card mt-3 d-none">
<div class="card-body">
<div class="row g-2">
<div class="col-12 col-md-6"><strong>ID Contratto:</strong> <span id="summary-id"></span></div>
<div class="col-12 col-md-6"><strong>Titolo:</strong> <span id="summary-title"></span></div>
<div class="col-12 col-md-6"><strong>ID Domanda:</strong> <span id="summary-domanda"></span></div>
<div class="col-12 col-md-6"><strong>ID Ricevuta:</strong> <span id="summary-ricevuta"></span></div>
</div>
</div>
</div>
</div>
<!-- Input Nascosti per elixForms -->
<input type="hidden" id="codiceContratto_hidden" name="{$outCodiceContratto}" value="{$currCodice}" />
<input type="hidden" id="titoloContratto_hidden" name="{$outTitoloContratto}" value="{$currTitolo}" />
<input type="hidden" id="idDomanda_hidden" name="{$outIdDomanda}" value="{$currDomanda}" />
<input type="hidden" id="idRicevuta_hidden" name="{$outIdRicevuta}" value="{$currRicevuta}" />
HTML;
$labelHtml = '<label class="form-label fw-semibold" htmlFor="autocomplete-search">Cerca Contratto</label>';
return (new \ElixForms\Common\ElixFormsElement($labelHtml, $autocompleteInputHtml))->render();
}
/**
* Inietta i CSS specifici per l'autocomplete e la card.
*/
protected function renderExtraContentPre(): string
{
ob_start();
?>
<style>
.autocomplete-wrapper {
position: relative;
width: 100%;
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: #ffffff;
border: 1px solid #a19f9d;
border-top: none;
border-radius: 0 0 2px 2px;
max-height: 250px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.autocomplete-dropdown-item {
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
border-bottom: 1px solid #f3f2f1;
transition: background-color 0.1s ease;
}
.autocomplete-dropdown-item:last-child {
border-bottom: none;
}
.autocomplete-dropdown-item:hover,
.autocomplete-dropdown-item.highlighted {
background-color: #edebe9;
}
.autocomplete-dropdown-item .contract-id {
font-weight: 600;
color: #323130;
}
.autocomplete-dropdown-item .contract-title {
color: #605e5c;
}
.autocomplete-loading {
padding: 12px;
text-align: center;
color: #605e5c;
font-size: 14px;
font-style: italic;
}
.autocomplete-no-results {
padding: 12px;
text-align: center;
color: #a19f9d;
font-size: 14px;
}
.autocomplete-error {
padding: 12px;
text-align: center;
color: #a4262c;
font-size: 14px;
}
</style>
<?php
return ob_get_clean();
}
/**
* Inietta il codice JavaScript per gestire le chiamate AJAX al proxy ed il comportamento dell'interfaccia.
*/
protected function renderExtraContentPost(): string
{
$escapedCodFis = htmlspecialchars($this->codiceFiscale, ENT_QUOTES, 'UTF-8');
$minChars = $this->minCharsForSearch;
ob_start();
?>
<script>
document.addEventListener('DOMContentLoaded', function() {
var searchInput = document.getElementById('autocomplete-search');
var dropdown = document.getElementById('autocomplete-dropdown');
var container = document.getElementById('autocomplete-container');
var card = document.getElementById('selected-contract-card');
var summaryId = document.getElementById('summary-id');
var summaryTitle = document.getElementById('summary-title');
var summaryDomanda = document.getElementById('summary-domanda');
var summaryRicevuta = document.getElementById('summary-ricevuta');
var hiddenCodice = document.getElementById('codiceContratto_hidden');
var hiddenTitolo = document.getElementById('titoloContratto_hidden');
var hiddenDomanda = document.getElementById('idDomanda_hidden');
var hiddenRicevuta = document.getElementById('idRicevuta_hidden');
var codFis = <?= json_encode($escapedCodFis) ?>;
var minChars = <?= $minChars ?>;
var searchResults = [];
var highlightedIndex = -1;
var debounceTimer = null;
// Se i campi hidden sono precompilati all'avvio, mostra la card di riepilogo
if (hiddenCodice.value && hiddenTitolo.value) {
showSummaryCard({
idContratto: hiddenCodice.value,
titoloContratto: hiddenTitolo.value,
idDomanda: hiddenDomanda.value,
idRicevuta: hiddenRicevuta.value
});
}
// Input event listener (con debounce)
searchInput.addEventListener('input', function() {
var text = this.value;
if (text.length === 0) {
clearSelection();
hideDropdown();
return;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
}
if (text.length >= minChars) {
// Controlla se l'utente ha solo modificato il testo del contratto selezionato
if (hiddenCodice.value && text !== "[" + hiddenCodice.value + "] " + hiddenTitolo.value) {
clearSelection();
}
debounceTimer = setTimeout(function() {
fetchResults(text);
}, 300);
} else {
hideDropdown();
}
});
// Gestione eventi da tastiera
searchInput.addEventListener('keydown', function(event) {
if (dropdown.classList.contains('d-none')) return;
var items = dropdown.querySelectorAll('.autocomplete-dropdown-item');
if (items.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
highlightedIndex = Math.min(highlightedIndex + 1, items.length - 1);
updateHighlight(items);
break;
case 'ArrowUp':
event.preventDefault();
highlightedIndex = Math.max(highlightedIndex - 1, 0);
updateHighlight(items);
break;
case 'Enter':
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < searchResults.length) {
selectContract(searchResults[highlightedIndex]);
}
break;
case 'Escape':
event.preventDefault();
hideDropdown();
break;
}
});
// Chiudi il menu a discesa se si clicca all'esterno
document.addEventListener('mousedown', function(event) {
if (!container.contains(event.target)) {
hideDropdown();
}
});
function fetchResults(term) {
showDropdown();
dropdown.innerHTML = '<div class="autocomplete-loading">Ricerca in corso...</div>';
highlightedIndex = -1;
// Calcolo dinamico della base directory per supportare URL con o senza slash finale
var baseDir = window.location.pathname;
if (!baseDir.endsWith('/') && !baseDir.endsWith('.php')) {
baseDir += '/';
}
var lastSlash = baseDir.lastIndexOf('/');
var dir = baseDir.substring(0, lastSlash + 1);
var url = dir + 'search-contratti.php?term=' + encodeURIComponent(term) + '&cod_fis=' + encodeURIComponent(codFis);
fetch(url)
.then(function(response) {
return response.text().then(function(text) {
var data = null;
try {
data = JSON.parse(text);
} catch (e) {
// Risposta non JSON
}
if (!response.ok) {
var errMsg = (data && data.message) ? data.message :
(data && data.error) ? data.error :
('Errore API: ' + response.status + ' ' + response.statusText);
throw new Error(errMsg);
}
if (data === null) {
throw new Error('Risposta del server non in formato JSON.');
}
return data;
});
})
.then(function(data) {
searchResults = data;
renderResults(data);
})
.catch(function(error) {
console.error('Errore durante la ricerca contratti:', error);
dropdown.innerHTML = '<div class="autocomplete-error">' + error.message + '</div>';
});
}
function renderResults(results) {
if (results.length === 0) {
dropdown.innerHTML = '<div class="autocomplete-no-results">Nessun risultato trovato</div>';
return;
}
dropdown.innerHTML = '';
results.forEach(function(contract, index) {
var item = document.createElement('div');
item.className = 'autocomplete-dropdown-item';
item.innerHTML = '<span class="contract-id">[' + contract.idContratto + ']</span> ' +
'<span class="contract-title">' + contract.titoloContratto + '</span>';
// Mouse interactions
item.addEventListener('mousedown', function(event) {
event.preventDefault();
selectContract(contract);
});
item.addEventListener('mouseenter', function() {
highlightedIndex = index;
var items = dropdown.querySelectorAll('.autocomplete-dropdown-item');
updateHighlight(items);
});
dropdown.appendChild(item);
});
}
function updateHighlight(items) {
items.forEach(function(item, index) {
if (index === highlightedIndex) {
item.classList.add('highlighted');
// Scorri il menu se l'elemento evidenziato è fuori vista
item.scrollIntoView({ block: 'nearest' });
} else {
item.classList.remove('highlighted');
}
});
}
function selectContract(contract) {
searchInput.value = '[' + contract.idContratto + '] ' + contract.titoloContratto;
hiddenCodice.value = contract.idContratto;
hiddenTitolo.value = contract.titoloContratto;
hiddenDomanda.value = contract.idDomanda;
hiddenRicevuta.value = contract.idRicevuta;
showSummaryCard(contract);
hideDropdown();
}
function showSummaryCard(contract) {
summaryId.textContent = contract.idContratto;
summaryTitle.textContent = contract.titoloContratto;
summaryDomanda.textContent = contract.idDomanda;
summaryRicevuta.textContent = contract.idRicevuta;
card.classList.remove('d-none');
}
function clearSelection() {
hiddenCodice.value = '';
hiddenTitolo.value = '';
hiddenDomanda.value = '';
hiddenRicevuta.value = '';
card.classList.add('d-none');
}
function showDropdown() {
dropdown.classList.remove('d-none');
}
function hideDropdown() {
dropdown.classList.add('d-none');
highlightedIndex = -1;
}
});
</script>
<?php
return ob_get_clean();
}
}
// =============================================================================
// Entry Point della Pagina
// =============================================================================
// Lettura e validazione della configurazione
$configFile = __DIR__ . '/config.json';
if (!file_exists($configFile)) {
die("File di configurazione config.json non trovato.");
}
$config = json_decode(file_get_contents($configFile), true);
if (json_last_error() !== JSON_ERROR_NONE) {
die("Errore di sintassi nel file config.json.");
}
// Istanziazione ed esecuzione del rendering
$page = new RecuperoPropostaCctDaContrattiPage(
$config['component'],
[
'codiceFiscale' => $config['elixForms']['inputs']['codiceFiscale'],
'idDomanda' => $config['elixForms']['outputs']['idDomanda'],
'idRicevuta' => $config['elixForms']['outputs']['idRicevuta'],
'codiceContratto'=> $config['elixForms']['outputs']['codiceContratto'],
'titoloContratto'=> $config['elixForms']['outputs']['titoloContratto'],
],
$config['component']['minCharsForSearch'] ?? 3
);
echo $page->render();
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../vendor/autoload.php';
use ElixForms\Common\HttpClient;
try {
// 1. Lettura dei parametri di input
$term = $_GET['term'] ?? '';
$codFis = $_GET['cod_fis'] ?? '';
if (empty($term) || strlen($term) < 3) {
http_response_code(400);
echo json_encode(['error' => 'Il parametro di ricerca "term" deve contenere almeno 3 caratteri.'], JSON_UNESCAPED_UNICODE);
exit;
}
if (empty($codFis)) {
http_response_code(400);
echo json_encode(['error' => 'Il parametro "cod_fis" (Codice Fiscale) è obbligatorio.'], JSON_UNESCAPED_UNICODE);
exit;
}
// 2. Caricamento configurazione
$configFile = __DIR__ . '/config.json';
if (!file_exists($configFile)) {
throw new \RuntimeException('File di configurazione config.json non trovato.');
}
$configJson = file_get_contents($configFile);
$config = json_decode($configJson, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($config['contrattiWS'])) {
throw new \RuntimeException('Configurazione del Web Service "contrattiWS" mancante o non valida.');
}
$wsConfig = $config['contrattiWS'];
$apiUrl = $wsConfig['apiUrl'] ?? '';
$apiUsername = $wsConfig['apiUsername'] ?? '';
$apiPassword = $wsConfig['apiPassword'] ?? '';
$apiKey = $wsConfig['apiKey'] ?? '';
// 3. Inizializzazione ed esecuzione della chiamata HTTP con HttpClient
$client = new HttpClient([
'X-Api-Key' => $apiKey,
'Accept' => 'application/json'
]);
// Configura autenticazione basic
$client->setBasicAuth($apiUsername, $apiPassword);
// Esegui la richiesta GET
$responseBody = $client->get($apiUrl, [
'cod_fis' => $codFis,
'term' => $term
]);
// Valida che la risposta sia in formato JSON corretto
json_decode($responseBody);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Il servizio contratti ha restituito una risposta in un formato non valido (non JSON).');
}
// Ritorna la risposta del WS direttamente
echo $responseBody;
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode([
'error' => 'Errore durante la ricerca dei contratti.',
'message' => 'Servizio temporaneamente non disponibile o risposta non valida.'
], JSON_UNESCAPED_UNICODE);
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use ElixForms\Common\ElixFormsComponent;
/**
* Pagina custom "Scelta Carriera" per elixForms.
* Equivalente PHP della pagina React scelta-carriera.
*
* Estende ElixFormsComponent sovrascrivendo:
* - createCustomFormFields(): definisce dropdown, text, textarea, boolean, radio, checkbox
* - renderExtraContentPost(): aggiunge un alert informativo in fondo
*/
class SceltaCarrieraPage extends ElixFormsComponent
{
protected function createCustomFormFields(): string
{
$html = '';
// Dropdown: COL0015 Goals
$html .= $this->createDropdownInput('COL0015', 'COL0015 Goals', [
['value' => 0, 'label' => 'Not applicable'],
['value' => 1, 'label' => 'Goal 1: No poverty'],
['value' => 2, 'label' => 'Goal 2: Zero hunger'],
['value' => 3, 'label' => 'Goal 3: Good health and well-being'],
['value' => 4, 'label' => 'Goal 4: Quality education'],
['value' => 5, 'label' => 'Goal 5: Gender equality'],
['value' => 6, 'label' => 'Goal 6: Clean water and sanitation'],
['value' => 7, 'label' => 'Goal 7: Affordable and clean energy'],
['value' => 8, 'label' => 'Goal 8: Decent work and economic growth'],
['value' => 9, 'label' => 'Goal 9: Industry, Innovation, and Infrastructure'],
['value' => 10, 'label' => 'Goal 10: Reduced inequalities'],
['value' => 11, 'label' => 'Goal 11: Sustainable cities and communities'],
['value' => 12, 'label' => 'Goal 12: Responsible consumption and production'],
['value' => 13, 'label' => 'Goal 13: Climate action'],
['value' => 14, 'label' => 'Goal 14: Life below water'],
['value' => 15, 'label' => 'Goal 15: Life on land'],
['value' => 16, 'label' => 'Goal 16: Peace, justice and strong institutions'],
['value' => 17, 'label' => 'Goal 17: Partnerships for the goals'],
], true);
// Text input: COL0002
$html .= $this->createTextInput('COL0002', 'Campo STRING', true);
// Textarea: COL0003
$html .= $this->createTextAreaInput('COL0003', 'Campo TEXTAREA', true);
// Boolean: COL0004
$html .= $this->createBooleanInput('COL0004', 'Campo BOOLEAN', true);
// Radio: COL0005
$html .= $this->createRadioInput('COL0005', 'Campo RADIO', [
['value' => 1, 'label' => 'Opzione 1'],
['value' => 2, 'label' => 'Opzione 2'],
['value' => 3, 'label' => 'Altra opzione'],
], true);
// Checkbox: COL0006
$html .= $this->createCheckboxInput('COL0006', 'Campo CHECKBOX', [
['value' => 4, 'label' => 'Check 1'],
['value' => 5, 'label' => 'Check 2'],
['value' => 6, 'label' => 'Altro check'],
]);
return $html;
}
protected function renderExtraContentPost(): string
{
return '<div class="alert alert-info mt-4">Questa pagina è la versione PHP della custom page Scelta Carriera.</div>';
}
}
// =============================================================================
// Entry point — Istanziazione e rendering della pagina
// =============================================================================
$page = new SceltaCarrieraPage([
'moduleName' => '',
'alertInfoMessage' => '',
'userDisplayName' => 'John Doe',
'headerTitle' => 'Modulo A/13 - Richiesta di Certificato',
'cardTitle' => 'Scelta Carriera',
'cardDescription' => 'Benvenuto nella piattaforma di scelta carriera! Esplora le tue opzioni e trova la strada giusta per te.',
'headerHeroImageSrc' => '',
]);
echo $page->render();
+36
View File
@@ -0,0 +1,36 @@
# Agente React — Regole e Convenzioni
Questo file definisce le istruzioni specifiche, le regole e le convenzioni per lo sviluppo e la manutenzione delle pagine custom e dei componenti scritti in **React e TypeScript** nel monorepo `react/`.
## Gestione ID dei Form Element
Gli ID dei campi del form (es. nel Dropdown, Checkbox, TextField) creati tramite i metodi `create...` in [ElixFormsComponentAbstract.tsx](file:///c:/__Git/_UniPR/elixforms-custom-pages/react/common/src/ElixFormsComponentAbstract.tsx) **non devono mai essere cambiati o manipolati** (ad es. aggiungendo suffissi come `_dropdown`), in quanto la piattaforma elixForms si basa sul loro nome ed ID esatto.
In caso di conflitti di ID (ad esempio con input `hidden`), applicare il suffisso `_hidden` all'input hidden e mantenere l'ID originale senza suffissi sull'elemento principale visibile.
## Convenzioni di Codice e Framework
1. **Libreria Common (`react/common/`)**:
- Scritta in **TypeScript** (`.tsx` o `.ts`).
- Usa **React Class Components** (non hooks/functional components) ereditando da `React.Component`.
- Lo stato è gestito in modo immutabile tramite `this.setState`.
- I checkbox mantengono uno stato locale tramite un `Map<string, number[]>` interno.
2. **Pagine Custom (`react/<nome-pagina>/`)**:
- Possono essere in `.jsx` o `.tsx` (TypeScript con `checkJs: true`).
- Usano **Functional Components** ed eventualmente Hooks se necessario.
- Istanziano la pagina tramite la classe: `new ElixForms.ElixFormsReact(props).render()`.
3. **Import di Tipi**:
- Avendo `verbatimModuleSyntax: true` abilitato in TypeScript, è obbligatorio usare `import type` per i tipi (es. `import type { JSX } from 'react';`).
## Configurazione di Build e Sviluppo
- Ogni pagina è un'applicazione Vite autonoma configurata in `vite.config.js`.
- La build deve produrre un **singolo bundle JS** autolimitato (`codeSplitting: false`, `manualChunks: undefined`).
- I CSS locali e SCSS moduli devono essere iniettati nel bundle finale tramite il plugin `vite-plugin-css-injected-by-js`.
- I CSS globali del design system di UniPR non vanno inclusi nella build ma caricati a runtime via `@import url(...)` in `App.css`.
## Skill React Correlate
Fai riferimento alle seguenti skill per maggiori dettagli operativi:
- [elixforms-common-library](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/common-library/SKILL.md): Documentazione componenti e factory.
- [elixforms-create-new-page](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/create-new-page/SKILL.md): Scaffolding di una nuova pagina React.
- [elixforms-typescript-conventions](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/typescript-react-conventions/SKILL.md): Linee guida di tipizzazione.
- [elixforms-vite-build-config](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/vite-build-config/SKILL.md): Bundling e build singola.
- [elixforms-dev-workflow](file:///c:/__Git/_UniPR/elixforms-custom-pages/.agents/skills/dev-workflow/SKILL.md): Comandi dev, build e debug in VS Code.
+23 -3
View File
@@ -1,13 +1,33 @@
{ {
"name": "common", "name": "@elixforms/common",
"version": "0.0.1", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "common", "name": "@elixforms/common",
"version": "0.0.1", "version": "0.0.1",
"license": "ISC" "license": "ISC",
"devDependencies": {
"@types/react": "^19.2.17"
}
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
} }
} }
} }
+5 -2
View File
@@ -1,9 +1,12 @@
{ {
"name": "common", "name": "@elixforms/common",
"version": "0.0.1", "version": "0.0.1",
"description": "Common components to build elixForms custom pages", "description": "Common components to build elixForms custom pages",
"license": "ISC", "license": "ISC",
"author": "", "author": "",
"type": "commonjs", "type": "commonjs",
"main": "index.js" "main": "index.js",
"devDependencies": {
"@types/react": "^19.2.17"
}
} }
+24
View File
@@ -0,0 +1,24 @@
@import url("https://fonts.googleapis.com/css2?family=Titillium+Web:wght@300;400;600;700&display=swap");
@import url("https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css");
@import url("https://cdn.jsdelivr.net/npm/bootstrap-italia@2.18.2/dist/css/bootstrap-italia.min.css");
/* elixForms styles customizations */
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/util.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/themes/blu-italia.css");
/* These are here for future imports...
@import url("https://console-unipr.elixforms.it/elixFormsCustom/css/elixForms.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/bootstrap_adaptation.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/bootstrap_overrides.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/console_user.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/designitalia_adaptation.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/fonts.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/isipcss_overrides.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/iulib.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/print.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/responsive.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/style-bs.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/design-bs/style.css");
@import url("https://console-unipr.elixforms.it/rwe2/css/plugins/modaldisplay.css");
@import url("https://console-unipr.elixforms.it/rwe2/isipcss/css/grid_fluid.css");
*/
+17 -23
View File
@@ -1,34 +1,28 @@
@import '~@fluentui/react/dist/sass/References.scss';
.elixFormsComponent { .elixFormsComponent {
overflow: hidden; overflow: hidden;
padding: 1em; padding: 1em;
color: "[theme:bodyText, default: #323130]";
color: var(--bodyText);
&.teams {
font-family: $ms-font-family-fallbacks;
}
} }
.welcome { .autocompleteDropdown {
text-align: center; margin-top: 0.5rem;
border: 1px solid #c9d1d9;
border-radius: 0.5rem;
background: #fff;
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08);
} }
.welcomeImage { .autocompleteItem {
width: 100%; padding: 0.75rem 1rem;
max-width: 420px; cursor: pointer;
} }
.links { .autocompleteItem:hover,
a { .autocompleteItem.isHighlighted {
text-decoration: none; background-color: #f5f7fa;
color: "[theme:link, default:#03787c]"; }
color: var(--link); // note: CSS Custom Properties support is limited to modern browsers only
&:hover { .autocompleteStatus {
text-decoration: underline; margin-top: 0.5rem;
color: "[theme:linkHovered, default: #014446]"; font-size: 0.95rem;
color: var(--linkHovered); // note: CSS Custom Properties support is limited to modern browsers only color: #5c6f82;
}
}
} }
+295 -246
View File
@@ -1,12 +1,13 @@
import * as FluentUI from '@fluentui/react'; import './App.css';
import type { IElixFormsComponentFormState } from './IElixFormsComponentFormState'; import type { IElixFormsComponentFormState } from './IElixFormsComponentFormState';
import type { IElixFormsComponentProperties } from './IElixFormsComponentProperties'; import type { IElixFormsComponentProperties } from './IElixFormsComponentProperties';
import { ElixFormsElement } from './ElixFormsElement';
import { QueryParamHelper } from './QueryParamHelper';
import { Component, type FormEvent, type JSX, useState } from 'react';
import React from 'react';
import type { IElixFormsComponentCustomFormFieldFactory } from './IElixFormsComponentCustomFormFieldFactory'; import type { IElixFormsComponentCustomFormFieldFactory } from './IElixFormsComponentCustomFormFieldFactory';
import type { ElixFormsCheckboxOption, ElixFormsDropdownOption, ElixFormsRadioOption } from './ElixFormsTypes'; import type { ElixFormsCheckboxOption, ElixFormsDropdownOption, ElixFormsRadioOption } from './ElixFormsTypes';
import { ElixFormsElement } from './ElixFormsElement';
import { QueryParamHelper } from './QueryParamHelper';
import { Component, type JSX } from 'react';
import React from 'react';
export default abstract class ElixFormsComponentAbstract extends Component<IElixFormsComponentProperties, IElixFormsComponentFormState> { export default abstract class ElixFormsComponentAbstract extends Component<IElixFormsComponentProperties, IElixFormsComponentFormState> {
constructor(props: IElixFormsComponentProperties) { constructor(props: IElixFormsComponentProperties) {
@@ -15,57 +16,26 @@ export default abstract class ElixFormsComponentAbstract extends Component<IElix
this.state = { this.state = {
formData: {} formData: {}
}; };
// const [formData, setFormData] = useState({});
// const handleAnyInputChange = (name: string) => (event: any, valueOrOption: any) => { //ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> | SyntheticEvent<HTMLElement, Event>
// let value: any = null;
// // Fluent UI Dropdown passes (event, option)
// if (valueOrOption && valueOrOption.key !== undefined) {
// value = valueOrOption.key;
// }
// // Fluent UI TextField passes (event, newValue)
// else if (typeof valueOrOption === "string") {
// value = valueOrOption;
// }
// // Fluent UI Checkbox passes (event, checked)
// else if (typeof valueOrOption === "boolean") {
// value = valueOrOption;
// }
// // Native HTML inputs
// else if (event?.target) {
// value = event.target.type === "checkbox"
// ? event.target.checked
// : event.target.value;
// }
// setFormData(prev => ({
// ...prev,
// [name]: value
// }));
// console.log(`Input ${name} changed to ${value}. Current form data:`, JSON.stringify(formData));
// };
} }
private checkboxValues = new Map<string, number[]>(); private checkboxValues = new Map<string, number[]>();
private mandatoryFormFieldNames: string[] = [ private mandatoryFormFieldNames: string[] = [
"RWE2_MODULE_ID", 'RWE2_MODULE_ID',
"RWE2_REQUEST_ID", 'RWE2_REQUEST_ID',
"custom-workflow-back-url", 'custom-workflow-back-url',
"custom-workflow-generic-id", 'custom-workflow-generic-id',
"custom-workflow-current-tabrel-genid", 'custom-workflow-current-tabrel-genid',
"custom-workflow-source-field", 'custom-workflow-source-field',
"crc", 'crc',
"MODULE_TESTMODE_KEY", 'MODULE_TESTMODE_KEY',
"ELANG" 'ELANG'
]; ];
private createMandatoryFormFields(queryParams: URLSearchParams): JSX.Element { private createMandatoryFormFields(queryParams: URLSearchParams): JSX.Element {
const hiddenInputs: JSX.Element[] = []; const hiddenInputs: JSX.Element[] = [];
// Create a hidden input for each mandatory Elix parameter
this.mandatoryFormFieldNames.forEach(paramName => { this.mandatoryFormFieldNames.forEach(paramName => {
const value = queryParams.get(paramName) ?? "";
// Create the input element
hiddenInputs.push(this.createHiddenInput(paramName)); hiddenInputs.push(this.createHiddenInput(paramName));
}); });
@@ -73,7 +43,7 @@ export default abstract class ElixFormsComponentAbstract extends Component<IElix
} }
private createAdditionalFormFields(): React.ReactElement { private createAdditionalFormFields(): React.ReactElement {
const schema = JSON.parse(this.props.additionalFieldsJson || "[]"); const schema = JSON.parse(this.props.additionalFieldsJson || '[]');
return ( return (
<> <>
@@ -123,16 +93,18 @@ export default abstract class ElixFormsComponentAbstract extends Component<IElix
public override render(): React.ReactElement<IElixFormsComponentProperties> { public override render(): React.ReactElement<IElixFormsComponentProperties> {
const { const {
description,
isDarkTheme, isDarkTheme,
environmentMessage,
hasTeamsContext,
userDisplayName, userDisplayName,
additionalFieldsJson, moduleName,
moduleTitle, headerTitle,
pageTitle, headerHeroImageSrc: headerTitleImageSrc,
pageDescription, cardTitle,
heroImageSrc cardDescription,
alertInfoTitle,
alertInfoMessage,
instructionsTitle,
instructionsMessage,
submitText,
} = this.props; } = this.props;
const queryParams = new URLSearchParams(window.location.search); const queryParams = new URLSearchParams(window.location.search);
@@ -140,63 +112,138 @@ export default abstract class ElixFormsComponentAbstract extends Component<IElix
const hasAuthenticationError = missingMandatoryParams.length > 0; const hasAuthenticationError = missingMandatoryParams.length > 0;
return ( return (
<div className="container_12" id="pageBody"> <div>
<div className="grid_12"> <header className="it-header-wrapper text-white">
<div id="userConsole" className="fe-behaviour"> {userDisplayName &&
<header> <div className="it-header-slim-wrapper">
<div className="neutral"> <div className="container">
<div className="grid_12"> <div className="row">
<div className="console_header"> <div className="col-12">
<div className="leftDiv"> <div className="it-header-slim-wrapper-content">
<h1>elixForms</h1> {/* <a className="d-none d-lg-block navbar-brand" href="#">Ente appartenenza</a> */}
<div className="logo"><a href="https://www.unipr.it/" title="Torna alla homepage dell'Università: di Parma"><img src="https://console-unipr.elixforms.it/elixFormsCustom/images/logo.png" alt="Logo Università: di Parma" /></a> <div className="nav-mobile">
{/* <nav aria-label="Navigazione accessoria">
<a className="it-opener d-lg-none" data-bs-toggle="collapse" href="#menu1a" role="button"
aria-expanded="false" aria-controls="menu4">
<span>Ente appartenenza</span>
<svg className="icon" aria-hidden="true">
<use href="/bootstrap-italia/dist/svg/sprites.svg#it-expand"></use>
</svg>
</a>
<div className="link-list-wrapper collapse" id="menu1a">
<ul className="link-list">
<li><a className="dropdown-item list-item" href="#">Link 1</a></li>
<li><a className="list-item active" href="#" aria-current="page">Link 2 (Attivo)</a></li>
</ul>
</div>
</nav> */}
</div>
<div className="it-header-slim-right-zone">
{/* <div className="nav-item dropdown">
<a className="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
<span className="visually-hidden">Selezione lingua: lingua selezionata</span>
<span>ITA</span>
<svg className="icon d-none d-lg-block">
<use href="/bootstrap-italia/dist/svg/sprites.svg#it-expand"></use>
</svg>
</a>
<div className="dropdown-menu">
<div className="row">
<div className="col-12">
<div className="link-list-wrapper">
<ul className="link-list">
<li><a className="dropdown-item list-item" href="#"><span>ITA <span
className="visually-hidden">selezionata</span></span></a></li>
<li><a className="dropdown-item list-item" href="#"><span>ENG</span></a></li>
</ul>
</div> </div>
</div> </div>
<div className="rightDiv">
<div className="rightDivInside info padding_r">
<div className="clear"></div>
</div> </div>
</div> </div>
<div className="clear"></div> </div> */}
<div className="it-access-top-wrapper">
{userDisplayName}
</div>
</div>
</div>
</div>
</div>
</div>
</div>}
<div className="it-header-center-wrapper">
<div className="container">
<div className="it-header-center-content-wrapper">
<div className="it-brand-wrapper">
<a href="https://www.unipr.it/" title="Torna alla homepage dell'Università di Parma">
<img className='icon' src="https://procedure.unipr.it/UploadImgs/12_marchio_UNIPR_neg_b1_squareLogo.png" alt="Università di Parma" />
<div className="it-brand-text">
<p className="h2 no_toc">Università di Parma</p>
{/* <p className="h3 no_toc d-none d-md-block">Procedure Online</p> */}
</div>
</a>
</div>
<div className="it-right-zone">
{/* <div className="it-search-wrapper">
<span className="d-none d-md-block">Cerca</span>
<button className="search-link" aria-label="Cerca">
<span className="it-search"></span>
</button>
</div> */}
</div>
</div> </div>
</div> </div>
<div className="clear"></div>
</div> </div>
</header> </header>
<div className="grid_12 console_main"> <div className="container my-2 mx-4">
<div className="inside"> <div className='cmp-breadcrumb' role='navigation'>
{moduleTitle && ( <nav className="breadcrumb-container" aria-label="breadcrumb">
<div> <ol className="breadcrumb">
<div className="fe-module-header-container"> <li className='breadcrumb-item'>Procedure Online<span className="separator">&gt;</span></li>
<h2>{moduleTitle}</h2> <li className="breadcrumb-item active" aria-current="page">{moduleName}</li>
</ol>
</nav>
</div> </div>
</div>
)}
<div className="receipt">
<div className="container">
{pageTitle && <h1>{pageTitle}</h1>}
{heroImageSrc && <img src={heroImageSrc} alt="Hero Image" className="hero-image" />}
{pageDescription && <p>{pageDescription}</p>}
{environmentMessage && <div>{environmentMessage}</div>} {headerTitleImageSrc && <img src={headerTitleImageSrc} alt="Hero Image" className="hero-image img-fluid mb-3" />}
{description && <div>{description}</div>} {headerTitle && (<p className='h2'>{headerTitle}</p>)}
<div className="container">
<div className="card shadow-sm mb-4">
<div className="card-body">
{cardTitle && <p className="h3 mb-3">{cardTitle}</p>}
{cardDescription && <p className="lead text-secondary">{cardDescription}</p>}
{(alertInfoTitle || alertInfoMessage) &&
<div className="custom-alert custom-alert-warning" role="status">
{alertInfoTitle && <div><b>{alertInfoTitle}</b></div>}
{alertInfoMessage && <div className='small'>{alertInfoMessage}</div>}
</div>}
{hasAuthenticationError ? ( {hasAuthenticationError ? (
<FluentUI.MessageBar messageBarType={FluentUI.MessageBarType.error} isMultiline={false}> <div className="alert alert-danger" role="alert">Accesso non autorizzato!</div>
Accesso non autorizzato. Parametri obbligatori mancanti ({missingMandatoryParams.join(', ')}).
</FluentUI.MessageBar>
) : ( ) : (
<> <>
{this.renderExtraContentPre()} {this.renderExtraContentPre()}
<form action="https://procedure.unipr.it/rwe2/ComeBackToElixAndSave" method="post" acceptCharset="ISO-8859-1"> {(instructionsTitle || instructionsMessage) &&
<div className="custom-alert custom-alert-info" role="status">
{instructionsTitle && <div><b>{instructionsTitle}</b></div>}
{instructionsMessage && <div className='small'>{instructionsMessage}</div>}
</div>
}
<form action="https://procedure.unipr.it/rwe2/ComeBackToElixAndSave" method="post" acceptCharset="ISO-8859-1" className="mt-3">
{this.createMandatoryFormFields(queryParams)} {this.createMandatoryFormFields(queryParams)}
{this.createAdditionalFormFields()} {this.createAdditionalFormFields()}
{this.renderCustomFormFields()} {this.renderCustomFormFields()}
<input type="submit" value="Submit" /> <div className="mt-4 d-flex justify-content-end">
<button type="submit" className="btn btn-primary">{submitText ?? 'INVIA'} <span className="it-arrow-right text-white ms-2"></span></button>
</div>
</form> </form>
{this.renderExtraContentPost()} {this.renderExtraContentPost()}
</> </>
)} )}
@@ -205,26 +252,23 @@ export default abstract class ElixFormsComponentAbstract extends Component<IElix
</div> </div>
</div> </div>
<div className="clear"></div> <footer className="it-footer text-white">
<div className="it-footer-small-prints clearfix">
<footer className="it-footer">
<div className="it-footer-main">
<div className="container"> <div className="container">
<section>
<div className="row clearfix"> <div className="row clearfix">
<div className="col-sm-12"> <div className="col-sm-12">
<div className="footer-content"> <div className="footer-content text-center p-2">
<div className="subfooter-top">powered by <span className="highlight">elixForms</span></div> <div className="subfooter-top">powered by <span className="highlight">elixForms</span></div>
<div className="subfooter-bottom"><div>versione 1.24.0</div></div> {/* <div className="subfooter-bottom"><div>versione 1.24.0</div></div> */}
</div> </div>
</div> </div>
</div> </div>
</section>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
<div className="clear"></div>
</div>
</div>
); );
} }
@@ -235,158 +279,180 @@ export default abstract class ElixFormsComponentAbstract extends Component<IElix
this.checkboxValues.set(paramName, checkedValues); this.checkboxValues.set(paramName, checkedValues);
} }
const checkboxes: React.ReactElement[] = [];
const stackTokens = { childrenGap: 10 };
values.forEach((entry, entryIndex) => {
const id = `${paramName}_${entryIndex}`;
checkboxes.push(
<FluentUI.Checkbox title={label} key={id} id={id} label={entry.label} required={entry.required ?? false} defaultChecked={checkedValues.indexOf(entryIndex) !== -1} className='isiportalPartialAdminCheckboxFieldItem'
onChange={(event, checked) => {
const currentCheckboxValues = this.checkboxValues.get(paramName)!;
const checkboxIndex = currentCheckboxValues.indexOf(entryIndex);
if (checked && checkboxIndex === -1) {
currentCheckboxValues.push(entryIndex);
} else if (!checked && checkboxIndex !== -1) {
currentCheckboxValues.splice(checkboxIndex, 1);
}
const newValue = currentCheckboxValues.join(',').trim();
this.setState({ formData: { ...this.state.formData, [paramName]: newValue } }, () => {
console.log(`Checkbox ${id} changed to ${checked}. Current values for ${paramName}: ${this.state.formData[paramName]}`);
});
}}
/>
);
});
const currentValue = this.state.formData[paramName] !== undefined const currentValue = this.state.formData[paramName] !== undefined
? this.state.formData[paramName] ? this.state.formData[paramName]
: (this.checkboxValues.get(paramName)?.join(',').trim() ?? ''); : (this.checkboxValues.get(paramName)?.join(',').trim() ?? '');
const selectedValues = (currentValue || '').split(',').filter(Boolean).map((value: string) => Number(value));
return new ElixFormsElement( return new ElixFormsElement(
<><FluentUI.Label htmlFor={paramName}>{label}</FluentUI.Label></>, <label className="form-label fw-semibold" htmlFor={paramName}>{label}</label>,
<><FluentUI.Stack tokens={stackTokens}> <>
{checkboxes} <div className="d-flex flex-column gap-2" role="group" aria-label={label}>
</FluentUI.Stack> {values.map((entry, entryIndex) => {
<input type='hidden' id={paramName} name={paramName} value={currentValue} /> const id = `${paramName}_${entryIndex}`;
const isChecked = selectedValues.includes(entryIndex);
return (
<div className="form-check" key={id}>
<input
id={id}
className="form-check-input"
type="checkbox"
checked={isChecked}
onChange={(event) => {
const currentCheckboxValues = this.checkboxValues.get(paramName) ?? [];
const checkboxIndex = currentCheckboxValues.indexOf(entryIndex);
const nextValues = [...currentCheckboxValues];
if (event.target.checked && checkboxIndex === -1) {
nextValues.push(entryIndex);
} else if (!event.target.checked && checkboxIndex !== -1) {
nextValues.splice(checkboxIndex, 1);
}
this.checkboxValues.set(paramName, nextValues);
const newValue = nextValues.join(',').trim();
this.setState({ formData: { ...this.state.formData, [paramName]: newValue } });
}}
/>
<label className="form-check-label" htmlFor={id}>{entry.label}</label>
</div>
);
})}
</div>
<input type="hidden" id={paramName} name={paramName} value={currentValue} />
</> </>
).render(); ).render();
} }
private createRadioInput(paramName: string, label: string, values: Array<ElixFormsRadioOption>, required: boolean = false): JSX.Element { private createRadioInput(paramName: string, label: string, values: Array<ElixFormsRadioOption>, required: boolean = false): JSX.Element {
const radioValue = QueryParamHelper.getOptionFromQuery(paramName); const currentValue = this.state.formData[paramName] !== undefined
const radios: FluentUI.IChoiceGroupOption[] = []; ? String(this.state.formData[paramName])
: String(QueryParamHelper.getOptionFromQuery(paramName) ?? '');
values.forEach((entry, _) => {
const id = `${paramName}_${entry.value}`;
radios.push(
{
key: id,
text: entry.label,
id: id,
name: paramName,
value: entry.value,
className: 'isiportalPartialAdminFormFieldRadio'
}
);
});
return new ElixFormsElement( return new ElixFormsElement(
<></>, <span className="form-label fw-semibold">{label}</span>,
<FluentUI.ChoiceGroup <div className="d-flex flex-column gap-2" role="radiogroup" aria-label={label}>
{values.map((entry) => {
const id = `${paramName}_${entry.value}`;
return (
<div className="form-check" key={id}>
<input
id={id}
className="form-check-input"
type="radio"
name={paramName} name={paramName}
label={label} value={entry.value}
options={radios} checked={currentValue === String(entry.value)}
required={required} onChange={() => {
defaultSelectedKey={`${paramName}_${radioValue}`} this.setState({ formData: { ...this.state.formData, [paramName]: String(entry.value) } });
onChange={(event, option) => {
const value = option?.value ?? '';
this.setState({ formData: { ...this.state.formData, [paramName]: value } }, () =>
console.log(`Radio ${option?.id} changed to ${value}. Current value for ${paramName}: ${this.state.formData[paramName]}`)
);
}} }}
required={required}
/> />
<label className="form-check-label" htmlFor={id}>{entry.label}</label>
</div>
);
})}
</div>
).render(); ).render();
} }
private createBooleanInput(paramName: string, label: string, required: boolean = false): JSX.Element { private createBooleanInput(paramName: string, label: string, required: boolean = false): JSX.Element {
const boolValue = QueryParamHelper.getOptionFromQuery(paramName); const currentValue = this.state.formData[paramName] !== undefined
const radios: FluentUI.IChoiceGroupOption[] = []; ? String(this.state.formData[paramName])
: String(QueryParamHelper.getOptionFromQuery(paramName) ?? '');
[{ "value": "true", "label": "Sì" }, { "value": "false", "label": "No" }].forEach((val) => {
const id = `${paramName}_${val.value}`;
radios.push(
{
key: id,
text: val.label,
id: id,
name: paramName,
value: val.value,
className: 'isiportalPartialAdminFormFieldRadio'
}
);
});
return new ElixFormsElement( return new ElixFormsElement(
<></>, <span className="form-label fw-semibold">{label}</span>,
<FluentUI.ChoiceGroup <div className="d-flex flex-column gap-2" role="radiogroup" aria-label={label}>
{[{ value: 'true', label: 'Sì' }, { value: 'false', label: 'No' }].map((val) => {
const id = `${paramName}_${val.value}`;
return (
<div className="form-check" key={id}>
<input
id={id}
className="form-check-input"
type="radio"
name={paramName} name={paramName}
label={label} value={val.value}
options={radios} checked={currentValue === val.value}
required={required} onChange={() => {
defaultSelectedKey={`${paramName}_${boolValue}`} this.setState({ formData: { ...this.state.formData, [paramName]: val.value } });
onChange={(event, option) => {
const value = option?.value ?? '';
this.setState({ formData: { ...this.state.formData, [paramName]: value } }, () =>
console.log(`Boolean ${option?.id} changed to ${value}. Current value for ${paramName}: ${this.state.formData[paramName]}`)
);
}} }}
required={required}
/> />
<label className="form-check-label" htmlFor={id}>{val.label}</label>
</div>
);
})}
</div>
).render(); ).render();
} }
private createTextAreaInput(paramName: string, label: string, required: boolean = false): JSX.Element { private createTextAreaInput(paramName: string, label: string, required: boolean = false): JSX.Element {
const textareaValue = QueryParamHelper.getDecodedTextFromQuery(paramName) ?? ""; const currentValue = this.state.formData[paramName] !== undefined
? String(this.state.formData[paramName])
: (QueryParamHelper.getDecodedTextFromQuery(paramName) ?? '');
return new ElixFormsElement( return new ElixFormsElement(
<FluentUI.Label htmlFor={paramName}>{label}</FluentUI.Label>, <label className="form-label fw-semibold" htmlFor={paramName}>{label}</label>,
<FluentUI.TextField id={paramName} name={paramName} defaultValue={textareaValue} multiline rows={4} required={required} className='isiportalPartialAdminFormFieldMultiLineText' <textarea
onChange={(event, value) => { id={paramName}
this.setState({ formData: { ...this.state.formData, [paramName]: value } }, () => name={paramName}
console.log(`TextArea ${paramName} changed to ${value}. Current value for ${paramName}: ${this.state.formData[paramName]}`) className="form-control"
); rows={4}
required={required}
value={currentValue}
onChange={(event) => {
this.setState({ formData: { ...this.state.formData, [paramName]: event.target.value } });
}} }}
/> />
).render(); ).render();
} }
private createTextInput(paramName: string, label: string, required: boolean = false): JSX.Element { private createTextInput(paramName: string, label: string, required: boolean = false): JSX.Element {
const textValue = QueryParamHelper.getDecodedTextFromQuery(paramName) ?? ""; const currentValue = this.state.formData[paramName] !== undefined
? String(this.state.formData[paramName])
: (QueryParamHelper.getDecodedTextFromQuery(paramName) ?? '');
return new ElixFormsElement( return new ElixFormsElement(
<FluentUI.Label htmlFor={paramName}>{label}</FluentUI.Label>, <label className="form-label fw-semibold" htmlFor={paramName}>{label}</label>,
<FluentUI.TextField id={paramName} name={paramName} defaultValue={textValue} required={required} className='isiportalPartialAdminFormFieldSingleLineText' <input
onChange={(event, value) => { id={paramName}
this.setState({ formData: { ...this.state.formData, [paramName]: value } }, () => name={paramName}
console.log(`TextInput ${paramName} changed to ${value}. Current value for ${paramName}: ${this.state.formData[paramName]}`) className="form-control"
); type="text"
required={required}
value={currentValue}
onChange={(event) => {
this.setState({ formData: { ...this.state.formData, [paramName]: event.target.value } });
}} }}
/> />
).render(); ).render();
} }
private createHiddenInput(paramName: string): JSX.Element { private createHiddenInput(paramName: string): JSX.Element {
const textValue = QueryParamHelper.getDecodedTextFromQuery(paramName) ?? ""; const textValue = QueryParamHelper.getDecodedTextFromQuery(paramName) ?? '';
return <input type="hidden" id={`${paramName}_hidden`} name={paramName} defaultValue={textValue} />; return <input type="hidden" id={`${paramName}_hidden`} name={paramName} value={textValue} />;
} }
private createNumberInput(paramName: string, label: string, required: boolean = false): JSX.Element { private createNumberInput(paramName: string, label: string, required: boolean = false): JSX.Element {
const textValue = QueryParamHelper.getDecodedTextFromQuery(paramName) ?? ""; const currentValue = this.state.formData[paramName] !== undefined
? String(this.state.formData[paramName])
: (QueryParamHelper.getDecodedTextFromQuery(paramName) ?? '');
return new ElixFormsElement( return new ElixFormsElement(
<FluentUI.Label htmlFor={paramName}>{label}</FluentUI.Label>, <label className="form-label fw-semibold" htmlFor={paramName}>{label}</label>,
<FluentUI.TextField id={paramName} name={paramName} defaultValue={textValue} type="number" required={required} className='isiportalPartialAdminFormFieldSingleLineText' <input
onChange={(event, value) => { id={paramName}
this.setState({ formData: { ...this.state.formData, [paramName]: value } }, () => name={paramName}
console.log(`NumberInput ${paramName} changed to ${value}. Current value for ${paramName}: ${this.state.formData[paramName]}`) className="form-control"
); type="number"
required={required}
value={currentValue}
onChange={(event) => {
this.setState({ formData: { ...this.state.formData, [paramName]: event.target.value } });
}} }}
/> />
).render(); ).render();
@@ -395,72 +461,55 @@ export default abstract class ElixFormsComponentAbstract extends Component<IElix
private createDropdownInput(paramName: string, label: string, values: Array<ElixFormsDropdownOption>, required: boolean = false): JSX.Element { private createDropdownInput(paramName: string, label: string, values: Array<ElixFormsDropdownOption>, required: boolean = false): JSX.Element {
const queryValue = QueryParamHelper.getOptionFromQuery(paramName); const queryValue = QueryParamHelper.getOptionFromQuery(paramName);
const currentValue = this.state.formData[paramName] !== undefined const currentValue = this.state.formData[paramName] !== undefined
? this.state.formData[paramName] ? String(this.state.formData[paramName])
: (queryValue?.toString() ?? ''); : (queryValue?.toString() ?? '');
const options: FluentUI.IDropdownOption[] = [];
values.forEach(entry => {
options.push(
{
key: entry.value.toString(),
text: entry.label
}
);
});
return new ElixFormsElement( return new ElixFormsElement(
<FluentUI.Label htmlFor={paramName}>{label}</FluentUI.Label>, <label className="form-label fw-semibold" htmlFor={paramName}>{label}</label>,
<> <select
<FluentUI.Dropdown
id={paramName} id={paramName}
options={options} name={paramName}
selectedKey={currentValue} className="form-select"
placeholder='---'
required={required} required={required}
className='isiportalPartialAdminFormFieldSelect' value={currentValue}
onChange={(event, option) => { onChange={(event) => {
if (option) { this.setState({ formData: { ...this.state.formData, [paramName]: event.target.value } });
const value = option.key.toString();
this.setState({ formData: { ...this.state.formData, [paramName]: value } }, () =>
console.log(`Dropdown ${paramName} changed to ${value}. Current value for ${paramName}: ${this.state.formData[paramName]}`)
);
}
}} }}
/> >
<input type="hidden" name={paramName} id={paramName + '_hidden'} value={currentValue} /> <option value="">Seleziona...</option>
</> {values.map(entry => (
<option key={entry.value} value={entry.value}>{entry.label}</option>
))}
</select>
).render(); ).render();
} }
private renderField(field: any): JSX.Element { private renderField(field: any): JSX.Element {
//const value = this.state.formData[field.key] || "";
switch (field.type) { switch (field.type) {
case "text": case 'text':
return this.createTextInput(field.key, field.label, field.required ?? false); return this.createTextInput(field.key, field.label, field.required ?? false);
case "textarea": case 'textarea':
return this.createTextAreaInput(field.key, field.label, field.required ?? false); return this.createTextAreaInput(field.key, field.label, field.required ?? false);
case "radio": case 'radio':
return this.createRadioInput(field.key, field.label, return this.createRadioInput(field.key, field.label,
new Array<ElixFormsRadioOption>().concat(...field.options.map((o: any) => [[o.value, o.label]])), field.options.map((o: any) => ({ value: o.value, label: o.label })),
field.required ?? false); field.required ?? false);
case "boolean": case 'boolean':
return this.createBooleanInput(field.key, field.label, field.required ?? false); return this.createBooleanInput(field.key, field.label, field.required ?? false);
case "checkbox": case 'checkbox':
return this.createCheckboxInput(field.key, field.label, return this.createCheckboxInput(field.key, field.label,
new Array<ElixFormsCheckboxOption>().concat(...field.options.map((o: any) => [[o.value, o.label, o.required ?? false]]))); field.options.map((o: any) => ({ value: o.value, label: o.label, required: o.required ?? false })));
case "number": case 'number':
return this.createNumberInput(field.key, field.label, field.required ?? false); return this.createNumberInput(field.key, field.label, field.required ?? false);
case "dropdown": case 'dropdown':
return this.createDropdownInput(field.key, field.label, return this.createDropdownInput(field.key, field.label,
new Array<ElixFormsDropdownOption>().concat(...field.options.map((o: any) => [[o.value, o.label]])), field.options.map((o: any) => ({ value: o.value, label: o.label })),
field.required ?? false); field.required ?? false);
default: default:
+15 -13
View File
@@ -1,14 +1,13 @@
import React, { type JSX } from "react"; import React, { type JSX } from 'react';
// Class to encapsulate the label and input elements
export class ElixFormsElement { export class ElixFormsElement {
label: JSX.Element; label: JSX.Element;
input: JSX.Element; input: JSX.Element;
separator: string; separator: string;
constructor(labelElement: React.ReactElement, inputElement: React.ReactElement, separator: string = "")
{ constructor(labelElement: React.ReactElement, inputElement: React.ReactElement, separator: string = '') {
if (!labelElement || !inputElement) { if (!labelElement || !inputElement) {
throw new Error("labelElement and inputElement are required."); throw new Error('labelElement and inputElement are required.');
} }
this.label = labelElement; this.label = labelElement;
@@ -16,15 +15,18 @@ export class ElixFormsElement {
this.separator = separator; this.separator = separator;
} }
// Optional: render both elements together render(): JSX.Element {
render() : JSX.Element { return (
return (<><div className="iuFieldContainer"> <div className="row mb-3 align-items-start">
<div className="attrDisplay_left attrDisplay_label"><div className="iuLabelContainer"><div className="attrDisplay_center"><div className="attrDisplay_middle"> <div className="col-12 col-md-4">
<div className="text-md-end pe-md-3 mt-2">
{this.label}{this.separator} {this.label}{this.separator}
</div></div><div className="iuClearContainer">&nbsp;</div></div></div> </div>
<div className="attrDisplay_right attrDisplay_input"><div className="iuInputContainer iuTypeString"><div className="attrDisplay_center"><div className="attrDisplay_middle"> </div>
<div className="col-12 col-md-8">
{this.input} {this.input}
</div></div><div className="iuClearContainer">&nbsp;</div></div></div> </div>
</div></>); </div>
);
} }
} }
@@ -1,19 +1,19 @@
import type { JSX } from "react";
import type { IElixFormsComponentCustomFormFieldFactory } from "./IElixFormsComponentCustomFormFieldFactory";
export interface IElixFormsComponentProperties { export interface IElixFormsComponentProperties {
description: string; isDarkTheme?: boolean;
isDarkTheme: boolean; userDisplayName?: string;
environmentMessage: string;
hasTeamsContext: boolean;
userDisplayName: string;
additionalFieldsJson?: string; additionalFieldsJson?: string;
// Layout Properties // Layout Properties
moduleName: string;
headerTitle?: string; headerTitle?: string;
pageTitle?: string; headerHeroImageSrc?: string;
pageDescription?: string; cardTitle: string;
heroImageSrc?: string; cardDescription?: string;
alertInfoTitle?: string;
alertInfoMessage?: string;
instructionsTitle?: string;
instructionsMessage?: string;
submitText?: string;
} }
// Additional Fields JSON Schema // Additional Fields JSON Schema
+3
View File
@@ -0,0 +1,3 @@
declare module "*.css";
declare module "*.svg";
declare module "*.png";
+1 -1
View File
@@ -9,7 +9,7 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"isolatedModules": true, "isolatedModules": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"skipLibCheck": true, "skipLibCheck": true
}, },
"include": [ "include": [
"**/*" "**/*"
+325 -245
View File
@@ -5,271 +5,285 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@fluentui/react": "^8.125.6", "bootstrap-italia": "^2.18.2",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
} }
}, },
"node_modules/@fluentui/date-time-utilities": { "node_modules/@babel/runtime": {
"version": "8.6.11", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@fluentui/date-time-utilities/-/date-time-utilities-8.6.11.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-zq49tveFzmzwgaJ73rVvxu9+rqhPBIAJSbevciIQnmvv6dlh2GzZcL14Zevk9QV+q6CWaF6yzvhT11E2TpAv8Q==", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT", "license": "MIT",
"dependencies": { "engines": {
"@fluentui/set-version": "^8.2.24", "node": ">=6.9.0"
"tslib": "^2.1.0"
} }
}, },
"node_modules/@fluentui/dom-utilities": { "node_modules/@popperjs/core": {
"version": "2.3.10", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@fluentui/dom-utilities/-/dom-utilities-2.3.10.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-6WDImiLqTOpkEtfUKSStcTDpzmJfL6ZammomcjawN9xH/8u8G3Hx72CIt2MNck9giw/oUlNLJFdWRAjeP3rmPQ==", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT", "license": "MIT",
"dependencies": { "funding": {
"@fluentui/set-version": "^8.2.24", "type": "opencollective",
"tslib": "^2.1.0" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/@fluentui/font-icons-mdl2": { "node_modules/@splidejs/splide": {
"version": "8.5.73", "version": "4.1.4",
"resolved": "https://registry.npmjs.org/@fluentui/font-icons-mdl2/-/font-icons-mdl2-8.5.73.tgz", "resolved": "https://registry.npmjs.org/@splidejs/splide/-/splide-4.1.4.tgz",
"integrity": "sha512-pyR9OE8LJMEVDAUTaTu/rGXaMCsyRqpp5124DhBoFNf6q5kI660IgbzuQadCpyMBEQadYI5/GLkLN7b1Lgou9Q==", "integrity": "sha512-5I30evTJcAJQXt6vJ26g2xEkG+l1nXcpEw4xpKh0/FWQ8ozmAeTbtniVtVmz2sH1Es3vgfC4SS8B2X4o5JMptA==",
"license": "MIT",
"dependencies": {
"@fluentui/set-version": "^8.2.24",
"@fluentui/style-utilities": "^8.15.1",
"@fluentui/utilities": "^8.17.2",
"tslib": "^2.1.0"
}
},
"node_modules/@fluentui/foundation-legacy": {
"version": "8.6.6",
"resolved": "https://registry.npmjs.org/@fluentui/foundation-legacy/-/foundation-legacy-8.6.6.tgz",
"integrity": "sha512-PIcMLOymLvIunp9DrBHUT+dZqwyslKsIOPi+5g/fLU/ySzCg3fs6wQyWHxN3esI5FsSfWi+yCpp+6u5+N00kJQ==",
"license": "MIT",
"dependencies": {
"@fluentui/merge-styles": "^8.6.14",
"@fluentui/set-version": "^8.2.24",
"@fluentui/style-utilities": "^8.15.1",
"@fluentui/utilities": "^8.17.2",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@types/react": ">=16.8.0 <20.0.0",
"react": ">=16.8.0 <20.0.0"
}
},
"node_modules/@fluentui/keyboard-key": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/@fluentui/keyboard-key/-/keyboard-key-0.4.23.tgz",
"integrity": "sha512-9GXeyUqNJUdg5JiQUZeGPiKnRzMRi9YEUn1l9zq6X/imYdMhxHrxpVZS12129cBfgvPyxt9ceJpywSfmLWqlKA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/@fluentui/merge-styles": {
"version": "8.6.14",
"resolved": "https://registry.npmjs.org/@fluentui/merge-styles/-/merge-styles-8.6.14.tgz",
"integrity": "sha512-vghuHFAfQgS9WLIIs4kgDOCh/DHd5vGIddP4/bzposhlAVLZR6wUBqldm9AuCdY88r5LyCRMavVJLV+Up3xdvA==",
"license": "MIT",
"dependencies": {
"@fluentui/set-version": "^8.2.24",
"tslib": "^2.1.0"
}
},
"node_modules/@fluentui/react": {
"version": "8.125.6",
"resolved": "https://registry.npmjs.org/@fluentui/react/-/react-8.125.6.tgz",
"integrity": "sha512-uvq0PdAL+Tznikek54zbS31JefTqcPaCkwjHjJz0t8NAC2ZFHozKnkApaQo2+sf0390K3k1ErGWr/+3Tvc6+JQ==",
"license": "MIT",
"dependencies": {
"@fluentui/date-time-utilities": "^8.6.11",
"@fluentui/font-icons-mdl2": "^8.5.73",
"@fluentui/foundation-legacy": "^8.6.6",
"@fluentui/merge-styles": "^8.6.14",
"@fluentui/react-focus": "^8.10.6",
"@fluentui/react-hooks": "^8.10.2",
"@fluentui/react-portal-compat-context": "^9.0.15",
"@fluentui/react-window-provider": "^2.3.2",
"@fluentui/set-version": "^8.2.24",
"@fluentui/style-utilities": "^8.15.1",
"@fluentui/theme": "^2.7.2",
"@fluentui/utilities": "^8.17.2",
"@microsoft/load-themed-styles": "^1.10.26",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@types/react": ">=16.8.0 <20.0.0",
"@types/react-dom": ">=16.8.0 <20.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@fluentui/react-focus": {
"version": "8.10.6",
"resolved": "https://registry.npmjs.org/@fluentui/react-focus/-/react-focus-8.10.6.tgz",
"integrity": "sha512-hMQw7AETttfex3XEAB/XQalJSL8/RgzOdJrL1RPKcrxdkBal0C1MRQ43Bbmw/4WtI0vK0wI4+jHvCIlM0lkodA==",
"license": "MIT",
"dependencies": {
"@fluentui/keyboard-key": "^0.4.23",
"@fluentui/merge-styles": "^8.6.14",
"@fluentui/set-version": "^8.2.24",
"@fluentui/style-utilities": "^8.15.1",
"@fluentui/utilities": "^8.17.2",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@types/react": ">=16.8.0 <20.0.0",
"react": ">=16.8.0 <20.0.0"
}
},
"node_modules/@fluentui/react-hooks": {
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/@fluentui/react-hooks/-/react-hooks-8.10.2.tgz",
"integrity": "sha512-HAd5cX50yKW/LljWlwt+FpSpdS/pNJutk9kMb7FyzxfoGBulL7sj6vX2HvxhSKyJMRKuTstXTdfJmsh22+3W3w==",
"license": "MIT",
"dependencies": {
"@fluentui/react-window-provider": "^2.3.2",
"@fluentui/set-version": "^8.2.24",
"@fluentui/utilities": "^8.17.2",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@types/react": ">=16.8.0 <20.0.0",
"react": ">=16.8.0 <20.0.0"
}
},
"node_modules/@fluentui/react-portal-compat-context": {
"version": "9.0.15",
"resolved": "https://registry.npmjs.org/@fluentui/react-portal-compat-context/-/react-portal-compat-context-9.0.15.tgz",
"integrity": "sha512-DpV+qtFvM3dmH1j8ZD+YcM5vaTvmQPHUAx6tQnnmIoYJWs2R0wU/L5p2EajXy7zSg74jrDbDRxzaziamoOaJdg==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.1"
},
"peerDependencies": {
"@types/react": ">=16.14.0 <20.0.0",
"react": ">=16.14.0 <20.0.0"
}
},
"node_modules/@fluentui/react-window-provider": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.3.2.tgz",
"integrity": "sha512-T15zFPIWr9De8hNkapne7YyvcxclyTK2bMXXHZwbWLkVeH/lGHRG0CIy/calNGKa86wuzMJhq8iqFW2W6+EwVQ==",
"license": "MIT",
"dependencies": {
"@fluentui/set-version": "^8.2.24",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@types/react": ">=16.8.0 <20.0.0",
"react": ">=16.8.0 <20.0.0"
}
},
"node_modules/@fluentui/set-version": {
"version": "8.2.24",
"resolved": "https://registry.npmjs.org/@fluentui/set-version/-/set-version-8.2.24.tgz",
"integrity": "sha512-8uNi2ThvNgF+6d3q2luFVVdk/wZV0AbRfJ85kkvf2+oSRY+f6QVK0w13vMorNhA5puumKcZniZoAfUF02w7NSg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/@fluentui/style-utilities": {
"version": "8.15.1",
"resolved": "https://registry.npmjs.org/@fluentui/style-utilities/-/style-utilities-8.15.1.tgz",
"integrity": "sha512-EwjJE7P7XWViNIN+Klm4rFCaADujsfwHB4nzkYeD0zjMokrznsiAvqERxaGZMCRH7tO+DLKITt7kaoQstNLCVQ==",
"license": "MIT",
"dependencies": {
"@fluentui/merge-styles": "^8.6.14",
"@fluentui/set-version": "^8.2.24",
"@fluentui/theme": "^2.7.2",
"@fluentui/utilities": "^8.17.2",
"@microsoft/load-themed-styles": "^1.10.26",
"tslib": "^2.1.0"
}
},
"node_modules/@fluentui/theme": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@fluentui/theme/-/theme-2.7.2.tgz",
"integrity": "sha512-UXGNfGa/1bLmYrOpmHXdvyc7CzlNSKUQAADweTncbNoMF1DvscWEjPj5kxFgCmOU8wVtvvn4GraNNUSWtNxeeA==",
"license": "MIT",
"dependencies": {
"@fluentui/merge-styles": "^8.6.14",
"@fluentui/set-version": "^8.2.24",
"@fluentui/utilities": "^8.17.2",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@types/react": ">=16.8.0 <20.0.0",
"react": ">=16.8.0 <20.0.0"
}
},
"node_modules/@fluentui/utilities": {
"version": "8.17.2",
"resolved": "https://registry.npmjs.org/@fluentui/utilities/-/utilities-8.17.2.tgz",
"integrity": "sha512-TmeWVtGN+Lk0mch7tuRcbkeMdrBwltI68fvQbPwcNLo4igFtTInMmjEnVJGa7pBQN5lQAmHYqB9IJI6RZU/t6w==",
"license": "MIT",
"dependencies": {
"@fluentui/dom-utilities": "^2.3.10",
"@fluentui/merge-styles": "^8.6.14",
"@fluentui/react-window-provider": "^2.3.2",
"@fluentui/set-version": "^8.2.24",
"tslib": "^2.1.0"
},
"peerDependencies": {
"@types/react": ">=16.8.0 <20.0.0",
"react": ">=16.8.0 <20.0.0"
}
},
"node_modules/@microsoft/load-themed-styles": {
"version": "1.10.295",
"resolved": "https://registry.npmjs.org/@microsoft/load-themed-styles/-/load-themed-styles-1.10.295.tgz",
"integrity": "sha512-W+IzEBw8a6LOOfRJM02dTT7BDZijxm+Z7lhtOAz1+y9vQm1Kdz9jlAO+qCEKsfxtUOmKilW8DIRqFw2aUgKeGg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": { "node_modules/@types/bootstrap": {
"version": "0.5.23", "version": "5.2.11",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.11.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "integrity": "sha512-69ykbLiNzQxi6Q/y/aYJLrlzI5QkLQjhIYW2U+cxujBxAmaBtmXKtxnFes59Ttfpt936as6xJU0dYnmVpcwIwg==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.2"
}
},
"node_modules/@videojs/http-streaming": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.5.tgz",
"integrity": "sha512-PRRQVu0hYW5h0XvRWpIyIpEatpMZzZtf7L1avV89Hnu+69f+0c7oLsSL9jwpg2nbvzGkdX5rQxQkz5Vc7tFR9w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "@babel/runtime": "^7.12.5",
} "@videojs/vhs-utils": "^4.1.2",
"aes-decrypter": "^4.0.2",
"global": "^4.4.0",
"m3u8-parser": "^7.2.0",
"mpd-parser": "^1.4.0",
"mux.js": "7.1.0",
"video.js": "^7 || ^8"
}, },
"node_modules/@types/react": { "engines": {
"version": "19.2.17", "node": ">=8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "npm": ">=5"
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
}, },
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "video.js": "^8.19.0"
} }
}, },
"node_modules/csstype": { "node_modules/@videojs/vhs-utils": {
"version": "3.2.3", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.2.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-1Dsv5dtWOq0LP1G37O46FQFA2tKAZON5UMe6dFPxT7JvPhEx8QIXcFQxan8+hHI3fX6aomFRFrUBJNgRYQDSXA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"global": "^4.4.0"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/@videojs/xhr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz",
"integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.5.5",
"global": "~4.4.0",
"is-function": "^1.0.1"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/aes-decrypter": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
"integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.1.1",
"global": "^4.4.0",
"pkcs7": "^1.0.4"
}
},
"node_modules/animejs": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/animejs/-/animejs-3.2.2.tgz",
"integrity": "sha512-Ao95qWLpDPXXM+WrmwcKbl6uNlC5tjnowlaRYtuVDHHoygjtIPfDUoK9NthrlZsQSKjZXlmji2TrBUAVbiH0LQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/bootstrap-italia": {
"version": "2.18.2",
"resolved": "https://registry.npmjs.org/bootstrap-italia/-/bootstrap-italia-2.18.2.tgz",
"integrity": "sha512-qoOrq8lDfmELVS46PJekZVacWCkVqG+PF4g41QBevbIWgw2LII6mESrE+ntADMrEKhDAVYPHt0Z9viHhabzXFw==",
"license": "BSD-3-Clause",
"dependencies": {
"@popperjs/core": "^2.11.6",
"@splidejs/splide": "^4.1.4",
"@types/bootstrap": "^5.2.6",
"animejs": "^3.2.1",
"design-tokens-italia": "^1.3.3",
"just-validate": "^4.3.0",
"minimasonry": "^1.3.2",
"progressbar.js": "^1.1.0",
"uuid": "^14.0.0",
"video.js": "^8.23.7"
}
},
"node_modules/design-tokens-italia": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/design-tokens-italia/-/design-tokens-italia-1.3.3.tgz",
"integrity": "sha512-9vdoT91ln2H/mppLO1LkTD9q18YPbT+BX9seJxzAC8r7gPlDRmbPJwsmt8dqXnULaH8Lqkp1KEdL8HW3Ah6K6A==",
"license": "BSD-3-Clause"
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/global": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"license": "MIT",
"dependencies": {
"min-document": "^2.19.0",
"process": "^0.11.10"
}
},
"node_modules/is-function": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
"license": "MIT"
},
"node_modules/just-validate": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/just-validate/-/just-validate-4.3.0.tgz",
"integrity": "sha512-9yS7ZnFk95vq+O7CAa7XZEJvZN5i6jTQWNhLwOjtgkSqiLxo7GZJhK8ViylP76fXPfHLs8tYWqi81XgxPF3OGA==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/m3u8-parser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
"integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.1.1",
"global": "^4.4.0"
}
},
"node_modules/min-document": {
"version": "2.19.2",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
"license": "MIT",
"dependencies": {
"dom-walk": "^0.1.0"
}
},
"node_modules/minimasonry": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/minimasonry/-/minimasonry-1.3.2.tgz",
"integrity": "sha512-ygvf5JIR4NT1LbM6o8z1oz1icTkta72gXKwidvSqIVxuzVd/P21UI0Wbq1J+MStAu50Vvr8qWYNSz1W4uycqMQ==",
"license": "MIT"
},
"node_modules/mpd-parser": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.4.0.tgz",
"integrity": "sha512-blbA7XpAaOdC/PR0Tu8GiUxlx6CpBuRowQPYV7lhi9Yr9G9+dkPXIUjqjzc/NCn/uZZPopluaJlIYntIEI/VLw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@videojs/vhs-utils": "^4.1.2",
"@xmldom/xmldom": "^0.8.3",
"global": "^4.4.0"
},
"bin": {
"mpd-to-m3u8-json": "bin/parse.js"
}
},
"node_modules/mux.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz",
"integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.11.2",
"global": "^4.4.0"
},
"bin": {
"muxjs-transmux": "bin/transmux.js"
},
"engines": {
"node": ">=8",
"npm": ">=5"
}
},
"node_modules/pkcs7": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
"integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.5.5"
},
"bin": {
"pkcs7": "bin/cli.js"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/progressbar.js": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/progressbar.js/-/progressbar.js-1.1.1.tgz",
"integrity": "sha512-FBsw3BKsUbb+hNeYfiP3xzvAAQrPi4DnGDw66bCmfuRCDLcslxyxv2GyYUdBSKFGSIBa73CUP5WMcl6F8AAXlw==",
"license": "MIT",
"dependencies": {
"lodash.merge": "^4.6.2",
"shifty": "^2.8.3"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -279,7 +293,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -293,11 +306,78 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tslib": { "node_modules/shifty": {
"version": "2.8.1", "version": "2.20.4",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/shifty/-/shifty-2.20.4.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-4Y0qRkg8ME5XN8yGNAwmFOmsIURGFKT9UQfNL6DDJQErYtN5HsjyoBuJn41ZQfTkuu2rIbRMn9qazjKsDpO2TA==",
"license": "0BSD" "license": "MIT",
"optionalDependencies": {
"fsevents": "^2.3.2"
}
},
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/video.js": {
"version": "8.23.9",
"resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.9.tgz",
"integrity": "sha512-3UCqyFW4wIusQu4TuhgRUxqtbzbj0WPT9hps0XIEXST43QBD11zb+bG7tBPrinrZztwVYdXCiOBo2pcGj9L05w==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.28.4",
"@videojs/http-streaming": "^3.17.5",
"@videojs/vhs-utils": "^4.1.2",
"@videojs/xhr": "2.7.0",
"aes-decrypter": "^4.0.2",
"global": "4.4.0",
"m3u8-parser": "^7.2.0",
"mpd-parser": "^1.4.0",
"mux.js": "^7.0.1",
"videojs-contrib-quality-levels": "4.1.0",
"videojs-font": "4.2.0",
"videojs-vtt.js": "0.15.5"
}
},
"node_modules/videojs-contrib-quality-levels": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
"integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
"license": "Apache-2.0",
"dependencies": {
"global": "^4.4.0"
},
"engines": {
"node": ">=16",
"npm": ">=8"
},
"peerDependencies": {
"video.js": "^8"
}
},
"node_modules/videojs-font": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz",
"integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==",
"license": "Apache-2.0"
},
"node_modules/videojs-vtt.js": {
"version": "0.15.5",
"resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
"integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==",
"license": "Apache-2.0",
"dependencies": {
"global": "^4.3.1"
}
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"dependencies": { "dependencies": {
"@fluentui/react": "^8.125.6", "bootstrap-italia": "^2.18.2",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
} }
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>recupero-proposta-cct-da-contratti</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
{
"name": "@elixforms/recupero-proposta-cct-da-contratti",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"vite-plugin-css-injected-by-js": "^5.0.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"sass-embedded": "^1.99.0",
"vite": "^8.0.10"
}
}
@@ -0,0 +1,104 @@
@import url('https://cdn.jsdelivr.net/npm/bootstrap-italia@2.0.0/dist/css/bootstrap-italia.min.css');
/* ===== Autocomplete Component Styles ===== */
.autocomplete-wrapper {
position: relative;
width: 100%;
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: #ffffff;
border: 1px solid #a19f9d;
border-top: none;
border-radius: 0 0 2px 2px;
max-height: 250px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.autocomplete-dropdown-item {
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
border-bottom: 1px solid #f3f2f1;
transition: background-color 0.1s ease;
}
.autocomplete-dropdown-item:last-child {
border-bottom: none;
}
.autocomplete-dropdown-item:hover,
.autocomplete-dropdown-item.highlighted {
background-color: #edebe9;
}
.autocomplete-dropdown-item .contract-id {
font-weight: 600;
color: #323130;
}
.autocomplete-dropdown-item .contract-title {
color: #605e5c;
}
.autocomplete-loading {
padding: 12px;
text-align: center;
color: #605e5c;
font-size: 14px;
font-style: italic;
}
.autocomplete-no-results {
padding: 12px;
text-align: center;
color: #a19f9d;
font-size: 14px;
}
.autocomplete-error {
padding: 12px;
text-align: center;
color: #a4262c;
font-size: 14px;
}
/* ===== Selected Contract Summary ===== */
.selected-contract-summary {
margin-top: 8px;
padding: 10px 12px;
background-color: #f3f2f1;
border: 1px solid #e1dfdd;
border-radius: 2px;
font-size: 13px;
color: #323130;
}
.selected-contract-summary .summary-row {
display: flex;
gap: 8px;
margin-bottom: 4px;
}
.selected-contract-summary .summary-row:last-child {
margin-bottom: 0;
}
.selected-contract-summary .summary-label {
font-weight: 600;
min-width: 120px;
color: #605e5c;
}
.selected-contract-summary .summary-value {
color: #323130;
}
@@ -0,0 +1,22 @@
import './App.css'
import RecuperoPropostaCctDaContrattiComponent from './RecuperoPropostaCctDaContrattiComponent';
import config from './config.json';
function App() {
return (
<RecuperoPropostaCctDaContrattiComponent
moduleName={config.component.moduleName}
headerTitle={config.component.headerTitle}
headerHeroImageSrc={config.component.headerHeroImageSrc}
cardTitle={config.component.cardTitle}
cardDescription={config.component.cardDescription}
alertInfoTitle={config.component.alertInfoTitle}
alertInfoMessage={config.component.alertInfoMessage}
instructionsTitle={config.component.instructionsTitle}
instructionsMessage={config.component.instructionsMessage}
submitText={config.component.submitText}
/>
);
}
export default App
@@ -0,0 +1,305 @@
import React, { type JSX } from 'react';
import ElixFormsComponentAbstract from '@common/src/ElixFormsComponentAbstract';
import type { IElixFormsComponentCustomFormFieldFactory } from '@common/src/IElixFormsComponentCustomFormFieldFactory';
import { ElixFormsElement } from '@common/src/ElixFormsElement';
import { QueryParamHelper } from '@common/src/QueryParamHelper';
import config from './config.json';
interface ContrattoResult {
idContratto: string;
titoloContratto: string;
idDomanda: string;
idRicevuta: string;
}
interface RecuperoPropostaState {
searchText: string;
searchResults: ContrattoResult[];
isLoading: boolean;
isDropdownOpen: boolean;
selectedContract: ContrattoResult | null;
highlightedIndex: number;
errorMessage: string;
}
export default class RecuperoPropostaCctDaContrattiComponent extends ElixFormsComponentAbstract {
private autocompleteState: RecuperoPropostaState = {
searchText: '',
searchResults: [],
isLoading: false,
isDropdownOpen: false,
selectedContract: null,
highlightedIndex: -1,
errorMessage: '',
};
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private autocompleteRef: React.RefObject<HTMLDivElement | null> = React.createRef();
private static readonly formFieldKeys = {
codiceContratto: config.elixForms.outputs.codiceContratto,
titoloContratto: config.elixForms.outputs.titoloContratto,
idDomanda: config.elixForms.outputs.idDomanda,
idRicevuta: config.elixForms.outputs.idRicevuta,
} as const;
private codiceFiscale: string;
constructor(props: any) {
super(props);
this.codiceFiscale = QueryParamHelper.getDecodedTextFromQuery(config.elixForms.inputs.codiceFiscale) ?? '';
}
override componentDidMount(): void {
document.addEventListener('mousedown', this.handleClickOutside);
}
override componentWillUnmount(): void {
document.removeEventListener('mousedown', this.handleClickOutside);
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
}
private handleClickOutside = (event: MouseEvent): void => {
if (this.autocompleteRef.current && !this.autocompleteRef.current.contains(event.target as Node)) {
this.autocompleteState.isDropdownOpen = false;
this.forceUpdate();
}
};
private async fetchContratti(searchTerm: string): Promise<void> {
this.autocompleteState.isLoading = true;
this.autocompleteState.errorMessage = '';
this.forceUpdate();
try {
const url = new URL(config.contrattiWS.apiUrl);
url.searchParams.set('cod_fis', this.codiceFiscale);
url.searchParams.set('term', searchTerm);
const credentials = btoa(`${config.contrattiWS.apiUsername}:${config.contrattiWS.apiPassword}`);
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: `Basic ${credentials}`,
'X-Api-Key': config.contrattiWS.apiKey,
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`Errore API: ${response.status} ${response.statusText}`);
}
const data: ContrattoResult[] = await response.json();
this.autocompleteState.searchResults = data;
this.autocompleteState.isDropdownOpen = data.length > 0;
this.autocompleteState.highlightedIndex = -1;
this.autocompleteState.isLoading = false;
this.forceUpdate();
} catch (error) {
console.error('Errore durante la ricerca contratti:', error);
this.autocompleteState.searchResults = [];
this.autocompleteState.isDropdownOpen = false;
this.autocompleteState.isLoading = false;
this.autocompleteState.errorMessage = error instanceof Error ? error.message : 'Errore durante la ricerca';
this.forceUpdate();
}
}
private handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const text = event.target.value;
this.autocompleteState.searchText = text;
if (text.length === 0) {
this.autocompleteState.searchResults = [];
this.autocompleteState.isDropdownOpen = false;
this.autocompleteState.highlightedIndex = -1;
this.autocompleteState.errorMessage = '';
this.clearSelection();
this.forceUpdate();
return;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (text.length >= config.component.minCharsForSearch) {
this.debounceTimer = setTimeout(() => {
this.fetchContratti(text);
}, 300);
} else {
this.autocompleteState.searchResults = [];
this.autocompleteState.isDropdownOpen = false;
this.autocompleteState.highlightedIndex = -1;
this.forceUpdate();
}
};
private handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
const { searchResults, isDropdownOpen, highlightedIndex } = this.autocompleteState;
if (!isDropdownOpen || searchResults.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.autocompleteState.highlightedIndex = Math.min(highlightedIndex + 1, searchResults.length - 1);
this.forceUpdate();
break;
case 'ArrowUp':
event.preventDefault();
this.autocompleteState.highlightedIndex = Math.max(highlightedIndex - 1, 0);
this.forceUpdate();
break;
case 'Enter':
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < searchResults.length) {
this.selectContract(searchResults[highlightedIndex]);
}
break;
case 'Escape':
event.preventDefault();
this.autocompleteState.isDropdownOpen = false;
this.forceUpdate();
break;
}
};
private selectContract(contract: ContrattoResult): void {
this.autocompleteState.selectedContract = contract;
this.autocompleteState.searchText = `[${contract.idContratto}] ${contract.titoloContratto}`;
this.autocompleteState.isDropdownOpen = false;
this.autocompleteState.searchResults = [];
this.autocompleteState.highlightedIndex = -1;
this.setState({
formData: {
...this.state.formData,
[RecuperoPropostaCctDaContrattiComponent.formFieldKeys.codiceContratto]: contract.idContratto,
[RecuperoPropostaCctDaContrattiComponent.formFieldKeys.titoloContratto]: contract.titoloContratto,
[RecuperoPropostaCctDaContrattiComponent.formFieldKeys.idDomanda]: contract.idDomanda,
[RecuperoPropostaCctDaContrattiComponent.formFieldKeys.idRicevuta]: contract.idRicevuta,
},
});
}
private clearSelection(): void {
this.autocompleteState.selectedContract = null;
this.setState({
formData: {
...this.state.formData,
[RecuperoPropostaCctDaContrattiComponent.formFieldKeys.codiceContratto]: '',
[RecuperoPropostaCctDaContrattiComponent.formFieldKeys.titoloContratto]: '',
[RecuperoPropostaCctDaContrattiComponent.formFieldKeys.idDomanda]: '',
[RecuperoPropostaCctDaContrattiComponent.formFieldKeys.idRicevuta]: '',
},
});
}
private renderAutocomplete(): JSX.Element {
const { searchResults, isLoading, isDropdownOpen, highlightedIndex, errorMessage, searchText } = this.autocompleteState;
const autocompleteInput = (
<>
<div className="position-relative" ref={this.autocompleteRef}>
<input
id="autocomplete-search"
name="autocomplete-search"
className="form-control"
type="text"
placeholder="Digita almeno 3 caratteri per cercare..."
value={searchText}
onChange={this.handleSearchChange}
onKeyDown={this.handleKeyDown}
autoComplete="off"
/>
{isLoading && (
<div className="autocompleteDropdown mt-2 p-3 text-muted">
<span className="me-2">
<span className="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</span>
Ricerca in corso...
</div>
)}
{!isLoading && isDropdownOpen && searchResults.length > 0 && (
<div className="autocompleteDropdown mt-2">
{searchResults.map((contract, index) => (
<div
key={`${contract.idContratto}_${index}`}
className={`autocompleteItem${index === highlightedIndex ? ' isHighlighted' : ''}`}
onMouseDown={(event) => {
event.preventDefault();
this.selectContract(contract);
}}
onMouseEnter={() => {
this.autocompleteState.highlightedIndex = index;
this.forceUpdate();
}}
>
<span className="fw-semibold">[{contract.idContratto}]</span>{' '}
<span>{contract.titoloContratto}</span>
</div>
))}
</div>
)}
{!isLoading && isDropdownOpen && searchResults.length === 0 && (
<div className="autocompleteDropdown mt-2 p-3 text-muted">Nessun risultato trovato</div>
)}
{errorMessage && (
<div className="custom-alert custom-alert-danger mt-2">{errorMessage}</div>
)}
</div>
{this.autocompleteState.selectedContract && (
<div className="card mt-3">
<div className="card-body">
<div className="row g-2">
<div className="col-12 col-md-6"><strong>ID Contratto:</strong> {this.autocompleteState.selectedContract.idContratto}</div>
<div className="col-12 col-md-6"><strong>Titolo:</strong> {this.autocompleteState.selectedContract.titoloContratto}</div>
<div className="col-12 col-md-6"><strong>ID Domanda:</strong> {this.autocompleteState.selectedContract.idDomanda}</div>
<div className="col-12 col-md-6"><strong>ID Ricevuta:</strong> {this.autocompleteState.selectedContract.idRicevuta}</div>
</div>
</div>
</div>
)}
</>
);
return new ElixFormsElement(
<label className="form-label fw-semibold" htmlFor="autocomplete-search">Cerca Contratto</label>,
autocompleteInput
).render();
}
protected override createCustomFormFields(formFieldFactory: IElixFormsComponentCustomFormFieldFactory): JSX.Element {
const fieldKeys = RecuperoPropostaCctDaContrattiComponent.formFieldKeys;
const idContratto = this.state.formData[fieldKeys.codiceContratto] ?? '';
const titoloContratto = this.state.formData[fieldKeys.titoloContratto] ?? '';
const idDomanda = this.state.formData[fieldKeys.idDomanda] ?? '';
const idRicevuta = this.state.formData[fieldKeys.idRicevuta] ?? '';
return (
<>
{this.renderAutocomplete()}
<input type="hidden" id="idContratto_hidden" name={fieldKeys.codiceContratto} value={idContratto} />
<input type="hidden" id="titoloContratto_hidden" name={fieldKeys.titoloContratto} value={titoloContratto} />
<input type="hidden" id="idDomanda_hidden" name={fieldKeys.idDomanda} value={idDomanda} />
<input type="hidden" id="idRicevuta_hidden" name={fieldKeys.idRicevuta} value={idRicevuta} />
</>
);
}
}
@@ -0,0 +1,32 @@
{
"component": {
"minCharsForSearch": 3,
"moduleName": "Ripartizione Utili / Compensi - Presentazione Proposta",
"headerTitle": "",
"headerHeroImageSrc": "",
"cardTitle": "Recupero Proposta CCT da Contratti",
"cardDescription": "",
"alertInfoTitle": "",
"alertInfoMessage": "",
"instructionsTitle": "Istruzioni di compilazione",
"instructionsMessage": "Completa i campi richiesti per procedere con la richiesta.",
"submitText": "CONFERMA E PROSEGUI"
},
"elixForms": {
"inputs": {
"codiceFiscale": "COL000A"
},
"outputs": {
"idDomanda": "COL00B1",
"idRicevuta": "COL00B2",
"codiceContratto": "COL00B3",
"titoloContratto": "COL00B4"
}
},
"contrattiWS": {
"apiUrl": "http://localhost:8000/contratti/cerca",
"apiUsername": "username",
"apiPassword": "password",
"apiKey": "apitoken"
}
}
@@ -0,0 +1,17 @@
{
"folders": [
{
"name": "elixforms-custom-pages",
"path": "../../.."
},
{
"name": "elixforms-webservices",
"path": "../../../../elixforms-webservices"
},
{
"name": "SSH FS - Dev Server",
"uri": "ssh://dev.unipr.it/home/dev68/dev"
}
],
"settings": {}
}
@@ -0,0 +1,3 @@
declare module "*.css";
declare module "*.svg";
declare module "*.png";
@@ -0,0 +1 @@
/* Global styles - currently empty */
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Root element not found')
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.base.json",
"references": [
{
"path": "../common"
}
],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"allowJs": true,
"checkJs": true,
//"resolveJsonModule": true
},
"include": [
"src/**/*"
]
}
@@ -0,0 +1,40 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import path from "path";
const ASSETS_DIR = 'assets';
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
cssInjectedByJsPlugin() // forza l'iniezione del CSS nel JS
],
base: './',
build: {
outDir: 'dist',
cssCodeSplit: false, // evita file CSS separati
rollupOptions: {
output: {
codeSplitting: false, // forza tutto in un singolo bundle
manualChunks: undefined, // disabilita la creazione di chunk multipli
entryFileNames: `${ASSETS_DIR}/recupero-proposta-cct-da-contratti.js`,
assetFileNames: `${ASSETS_DIR}/[name].[ext]`,
},
},
sourcemap: true,
},
resolve: {
alias: {
"@common": path.resolve(__dirname, "../common"),
}
},
server: {
fs: {
allow: [
".." // permette import fuori dalla cartella del progetto
]
}
}
})
+363 -382
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -1,3 +1,14 @@
@import url('https://fonts.googleapis.com/css2?family=Titillium+Web:wght@300;400;600;700&display=swap');
@import url('https://cdn.jsdelivr.net/npm/bootstrap-italia@2.0.0/dist/css/bootstrap-italia.min.css');
:root {
--font-family-sans-serif: "Titillium Web", "Segoe UI", Arial, sans-serif;
}
body {
font-family: "Titillium Web", "Segoe UI", Arial, sans-serif;
}
/*
@import url('https://console-unipr.elixforms.it/elixFormsCustom/css/elixForms.css'); @import url('https://console-unipr.elixforms.it/elixFormsCustom/css/elixForms.css');
@import url('https://console-unipr.elixforms.it/rwe2/css/design-bs/bootstrap_adaptation.css'); @import url('https://console-unipr.elixforms.it/rwe2/css/design-bs/bootstrap_adaptation.css');
@import url('https://console-unipr.elixforms.it/rwe2/css/design-bs/bootstrap_overrides.css'); @import url('https://console-unipr.elixforms.it/rwe2/css/design-bs/bootstrap_overrides.css');
@@ -14,6 +25,7 @@
@import url('https://console-unipr.elixforms.it/rwe2/css/plugins/modaldisplay.css'); @import url('https://console-unipr.elixforms.it/rwe2/css/plugins/modaldisplay.css');
@import url('https://console-unipr.elixforms.it/rwe2/css/themes/blu-italia.css'); @import url('https://console-unipr.elixforms.it/rwe2/css/themes/blu-italia.css');
@import url('https://console-unipr.elixforms.it/rwe2/isipcss/css/grid_fluid.css'); @import url('https://console-unipr.elixforms.it/rwe2/isipcss/css/grid_fluid.css');
*/
/* /*
.counter { .counter {
+7 -14
View File
@@ -1,26 +1,19 @@
import { useState } from 'react'
// import reactLogo from './assets/react.svg'
// import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png' import heroImg from './assets/hero.png'
import './App.css' import './App.css'
import SceltaCarrieraComponent from './SceltaCarrieraComponent'; import SceltaCarrieraComponent from './SceltaCarrieraComponent';
function App() { function App() {
const [count, setCount] = useState(0)
return ( return (
<SceltaCarrieraComponent <SceltaCarrieraComponent
description="" moduleName=""
isDarkTheme={false} alertInfoMessage=""
environmentMessage=""
hasTeamsContext={false}
userDisplayName="John Doe" userDisplayName="John Doe"
moduleTitle="Modulo A/13 - Richiesta di Certificato" headerTitle="Modulo A/13 - Richiesta di Certificato"
pageTitle="Scelta Carriera" cardTitle="Scelta Carriera"
pageDescription="Benvenuto nella piattaforma di scelta carriera! Esplora le tue opzioni e trova la strada giusta per te." cardDescription="Benvenuto nella piattaforma di scelta carriera! Esplora le tue opzioni e trova la strada giusta per te."
heroImageSrc={heroImg} headerHeroImageSrc={heroImg}
/> />
); )
} }
export default App export default App
@@ -6,44 +6,44 @@ export default class SceltaCarrieraComponent extends ElixFormsComponentAbstract
protected override createCustomFormFields(formFieldFactory: IElixFormsComponentCustomFormFieldFactory): JSX.Element { protected override createCustomFormFields(formFieldFactory: IElixFormsComponentCustomFormFieldFactory): JSX.Element {
return ( return (
<> <>
{formFieldFactory.createDropdownInput("COL0015", "COL0015 Goals", [ {formFieldFactory.createDropdownInput('COL0015', 'COL0015 Goals', [
{ "value": 0, "label": "Not applicable" }, { value: 0, label: 'Not applicable' },
{ "value": 1, "label": "Goal 1: No poverty" }, { value: 1, label: 'Goal 1: No poverty' },
{ "value": 2, "label": "Goal 2: Zero hunger" }, { value: 2, label: 'Goal 2: Zero hunger' },
{ "value": 3, "label": "Goal 3: Good health and well-being" }, { value: 3, label: 'Goal 3: Good health and well-being' },
{ "value": 4, "label": "Goal 4: Quality education" }, { value: 4, label: 'Goal 4: Quality education' },
{ "value": 5, "label": "Goal 5: Gender equality" }, { value: 5, label: 'Goal 5: Gender equality' },
{ "value": 6, "label": "Goal 6: Clean water and sanitation" }, { value: 6, label: 'Goal 6: Clean water and sanitation' },
{ "value": 7, "label": "Goal 7: Affordable and clean energy" }, { value: 7, label: 'Goal 7: Affordable and clean energy' },
{ "value": 8, "label": "Goal 8: Decent work and economic growth" }, { value: 8, label: 'Goal 8: Decent work and economic growth' },
{ "value": 9, "label": "Goal 9: Industry, Innovation, and Infrastructure" }, { value: 9, label: 'Goal 9: Industry, Innovation, and Infrastructure' },
{ "value": 10, "label": "Goal 10: Reduced inequalities" }, { value: 10, label: 'Goal 10: Reduced inequalities' },
{ "value": 11, "label": "Goal 11: Sustainable cities and communities" }, { value: 11, label: 'Goal 11: Sustainable cities and communities' },
{ "value": 12, "label": "Goal 12: Responsible consumption and production" }, { value: 12, label: 'Goal 12: Responsible consumption and production' },
{ "value": 13, "label": "Goal 13: Climate action" }, { value: 13, label: 'Goal 13: Climate action' },
{ "value": 14, "label": "Goal 14: Life below water" }, { value: 14, label: 'Goal 14: Life below water' },
{ "value": 15, "label": "Goal 15: Life on land" }, { value: 15, label: 'Goal 15: Life on land' },
{ "value": 16, "label": "Goal 16: Peace, justice and strong institutions" }, { value: 16, label: 'Goal 16: Peace, justice and strong institutions' },
{ "value": 17, "label": "Goal 17: Partnerships for the goals" } { value: 17, label: 'Goal 17: Partnerships for the goals' }
], true)} ], true)}
{formFieldFactory.createTextInput("COL0002", "Campo STRING", true)} {formFieldFactory.createTextInput('COL0002', 'Campo STRING', true)}
{formFieldFactory.createTextAreaInput("COL0003", "Campo TEXTAREA", true)} {formFieldFactory.createTextAreaInput('COL0003', 'Campo TEXTAREA', true)}
{formFieldFactory.createBooleanInput("COL0004", "Campo BOOLEAN", true)} {formFieldFactory.createBooleanInput('COL0004', 'Campo BOOLEAN', true)}
{formFieldFactory.createRadioInput("COL0005", "Campo RADIO", [ {formFieldFactory.createRadioInput('COL0005', 'Campo RADIO', [
{ "value": 1, "label": "Opzione 1" }, { value: 1, label: 'Opzione 1' },
{ "value": 2, "label": "Opzione 2" }, { value: 2, label: 'Opzione 2' },
{ "value": 3, "label": "Altra opzione" } { value: 3, label: 'Altra opzione' }
], true)} ], true)}
{formFieldFactory.createCheckboxInput("COL0006", "Campo CHECKBOX", [ {formFieldFactory.createCheckboxInput('COL0006', 'Campo CHECKBOX', [
{ "value": 4, "label": "Check 1" }, { value: 4, label: 'Check 1' },
{ "value": 5, "label": "Check 2" }, { value: 5, label: 'Check 2' },
{ "value": 6, "label": "Altro check" } { value: 6, label: 'Altro check' }
])} ])}
</> </>
); );
} }
protected override renderExtraContentPost(): JSX.Element { protected override renderExtraContentPost(): JSX.Element {
return <p>Altre cose</p>; return <div className="alert alert-info mt-4">Questa pagina è stata ristrutturata con i componenti Bootstrap Italia.</div>;
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"files": [], //"files": [],
"references": [ "references": [
{ {
"path": "./common" "path": "./common"
+2
View File
@@ -0,0 +1,2 @@
$ErrorActionPreference = 'Stop'
& (Join-Path $PSScriptRoot 'composer-run-command.ps1') -Command dump-autoload -Arguments @('--optimize')
+2
View File
@@ -0,0 +1,2 @@
$ErrorActionPreference = 'Stop'
& (Join-Path $PSScriptRoot 'composer-run-command.ps1') -Command install -Arguments @('--optimize-autoloader')
+47
View File
@@ -0,0 +1,47 @@
param(
[Parameter(Mandatory)][string]$Command,
[string[]]$Arguments = @()
)
$ErrorActionPreference = 'Stop'
$root = (Resolve-Path (Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) '..')).Path
$useDockerComposer = $false
$dockerRoot = $null
if (-not (Get-Command composer -ErrorAction SilentlyContinue)) {
Write-Host "Composer non trovato localmente. Verifico se Docker è disponibile..."
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw "Composer non trovato e Docker mancante! Installa Composer o Docker prima di continuare."
}
$dockerRoot = $root -replace '\\', '/'
if ($dockerRoot -match '^([A-Za-z]):') {
$dockerRoot = "/$($Matches[1].ToLower())$($dockerRoot.Substring(2))"
}
Write-Host "Docker trovato. Verifico Composer tramite container..."
docker run --rm -v "$($dockerRoot):/app" -w /app composer:2 composer --version 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Docker presente, ma Composer non riesce ad avviarsi tramite container. Installa Composer localmente o verifica Docker."
}
$useDockerComposer = $true
}
Push-Location $root
try {
if ($useDockerComposer) {
docker run --rm -v "$($dockerRoot):/app" -w /app composer:2 composer $Command @Arguments
}
else {
composer $Command @Arguments
}
if ($LASTEXITCODE -ne 0) {
throw "Composer ha restituito il codice di uscita $LASTEXITCODE."
}
}
finally {
Pop-Location
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use ElixForms\Common\HttpClient;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
final class HttpClientTest extends TestCase
{
public function testGetUsesQueryHeadersAuthenticationAndTimeout(): void
{
$history = [];
$stack = HandlerStack::create(new MockHandler([new Response(200, [], '{"ok":true}')]));
$stack->push(Middleware::history($history));
$client = new HttpClient(
['Accept' => 'application/json', 'X-Default' => 'default'],
new Client(['handler' => $stack])
);
$client->setBasicAuth('user', 'secret')->setTimeout(5);
$result = $client->get(
'https://example.test/resource',
['term' => 'Mario Rossi'],
['X-Default' => 'override']
);
self::assertSame('{"ok":true}', $result);
self::assertCount(1, $history);
self::assertSame('GET', $history[0]['request']->getMethod());
self::assertSame('term=Mario%20Rossi', $history[0]['request']->getUri()->getQuery());
self::assertSame('application/json', $history[0]['request']->getHeaderLine('Accept'));
self::assertSame('override', $history[0]['request']->getHeaderLine('X-Default'));
self::assertSame(['user', 'secret'], $history[0]['options']['auth']);
self::assertSame(5, $history[0]['options']['timeout']);
self::assertSame(5, $history[0]['options']['connect_timeout']);
}
public function testPostEncodesArrayAsFormData(): void
{
$history = [];
$stack = HandlerStack::create(new MockHandler([new Response(200, [], 'saved')]));
$stack->push(Middleware::history($history));
$client = new HttpClient([], new Client(['handler' => $stack]));
self::assertSame('saved', $client->post('https://example.test/resource', ['name' => 'Mario Rossi']));
self::assertSame('POST', $history[0]['request']->getMethod());
self::assertSame('name=Mario+Rossi', (string)$history[0]['request']->getBody());
}
public function testHttpErrorsAreExposedWithoutResponseBody(): void
{
$client = new HttpClient([], new Client([
'handler' => HandlerStack::create(new MockHandler([
new Response(503, [], 'sensitive upstream response'),
])),
]));
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Richiesta fallita con codice di stato HTTP 503.');
$client->get('https://example.test/resource');
}
public function testTimeoutMustBePositive(): void
{
$this->expectException(\InvalidArgumentException::class);
(new HttpClient())->setTimeout(0);
}
}