Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé) Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
This commit is contained in:
@@ -50,7 +50,21 @@ test.describe('Page creation', () => {
|
|||||||
expect(created?.nodeId).toBe(seeded.rootFolderId);
|
expect(created?.nodeId).toBe(seeded.rootFolderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('submit is disabled until title, template and folder are set', async ({ page }) => {
|
test('submit is disabled until title, template and folder are set', async ({ page, request }) => {
|
||||||
|
// On seed un 2ᵉ template pour empêcher l'auto-sélection (qui se déclenche
|
||||||
|
// quand un seul template a un defaultNodeId valide). Avec deux candidats,
|
||||||
|
// l'utilisateur doit choisir explicitement → on retrouve le comportement
|
||||||
|
// initial du test : submit disabled tant qu'un template n'est pas cliqué.
|
||||||
|
const secondFolderRes = await request.post('/api/lore-nodes', {
|
||||||
|
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
|
||||||
|
});
|
||||||
|
const secondFolderId = (await secondFolderRes.json()).id;
|
||||||
|
await seedTemplate(request, {
|
||||||
|
loreId: seeded.id,
|
||||||
|
defaultNodeId: secondFolderId,
|
||||||
|
name: `Second template ${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto(`/lore/${seeded.id}/pages/create`);
|
await page.goto(`/lore/${seeded.id}/pages/create`);
|
||||||
|
|
||||||
const submit = page.getByRole('button', { name: /^Créer la page$/i });
|
const submit = page.getByRole('button', { name: /^Créer la page$/i });
|
||||||
@@ -66,6 +80,8 @@ test.describe('Page creation', () => {
|
|||||||
test('entering on a folder-scoped route preselects that folder', async ({ page, request }) => {
|
test('entering on a folder-scoped route preselects that folder', async ({ page, request }) => {
|
||||||
const pageTitle = `Page scoped ${Date.now()}`;
|
const pageTitle = `Page scoped ${Date.now()}`;
|
||||||
|
|
||||||
|
// Dossier sans template par défaut → pas d'auto-sélection de template,
|
||||||
|
// l'utilisateur clique manuellement (ce qu'on veut tester ici).
|
||||||
const secondFolderRes = await request.post('/api/lore-nodes', {
|
const secondFolderRes = await request.post('/api/lore-nodes', {
|
||||||
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
|
data: { loreId: seeded.id, name: 'Autre dossier', icon: 'folder', description: '' },
|
||||||
});
|
});
|
||||||
@@ -87,4 +103,31 @@ test.describe('Page creation', () => {
|
|||||||
const pages = await getPagesForLore(request, seeded.id);
|
const pages = await getPagesForLore(request, seeded.id);
|
||||||
expect(pages.find((p) => p.title === pageTitle)?.nodeId).toBe(secondFolderId);
|
expect(pages.find((p) => p.title === pageTitle)?.nodeId).toBe(secondFolderId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('auto-selects the template on free route when it is the only candidate', async ({ page }) => {
|
||||||
|
// Le seed donne EXACTEMENT 1 template avec defaultNodeId valide → la
|
||||||
|
// logique d'auto-sélection doit s'enclencher au chargement.
|
||||||
|
await page.goto(`/lore/${seeded.id}/pages/create`);
|
||||||
|
|
||||||
|
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
|
||||||
|
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
|
||||||
|
|
||||||
|
// Conséquence : juste taper un titre suffit pour activer le submit.
|
||||||
|
const submit = page.getByRole('button', { name: /^Créer la page$/i });
|
||||||
|
await expect(submit).toBeDisabled();
|
||||||
|
await page.getByLabel(/Titre de la page/i).fill('Auto');
|
||||||
|
await expect(submit).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-selects the template on folder-scoped route when its defaultNodeId matches', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Le template seedé pointe sur seeded.rootFolderId — entrer sur la route
|
||||||
|
// folder-scoped de ce dossier doit auto-sélectionner ce template.
|
||||||
|
await page.goto(`/lore/${seeded.id}/nodes/${seeded.rootFolderId}/pages/create`);
|
||||||
|
|
||||||
|
await expect(page.locator('.template-card.selected', { hasText: template.name })).toBeVisible();
|
||||||
|
await expect(page.locator('#page-node')).toHaveValue(seeded.rootFolderId);
|
||||||
|
await expect(page.locator('#page-node')).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
65
web/src/app/interceptors/session-expired.interceptor.ts
Normal file
65
web/src/app/interceptors/session-expired.interceptor.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { catchError, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecte la perte de session demo (orchestrateur) via les codes 401/502 sur
|
||||||
|
* les appels /api/*, affiche un overlay puis force un rechargement de la page.
|
||||||
|
* Le reload renvoie l'utilisateur sur la page "Preparation" pour creer une
|
||||||
|
* nouvelle session sans qu'il ait a faire Ctrl+Shift+R.
|
||||||
|
*
|
||||||
|
* Cet interceptor est inerte en mode normal (non-demo) : si le backend natif
|
||||||
|
* renvoie un 401 legitime, ca declenche aussi le reload, ce qui est sans
|
||||||
|
* consequence puisqu'aucun flux d'auth utilisateur n'existe encore cote app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Module-level flag : evite de declencher overlay + reload plusieurs fois si
|
||||||
|
// plusieurs appels echouent en parallele juste apres l'expiration.
|
||||||
|
let alreadyTriggered = false;
|
||||||
|
|
||||||
|
export const sessionExpiredInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
return next(req).pipe(
|
||||||
|
catchError((err) => {
|
||||||
|
const isApiCall = req.url.includes('/api/');
|
||||||
|
const isSessionLoss =
|
||||||
|
err instanceof HttpErrorResponse && (err.status === 401 || err.status === 502);
|
||||||
|
|
||||||
|
if (isApiCall && isSessionLoss && !alreadyTriggered) {
|
||||||
|
alreadyTriggered = true;
|
||||||
|
showExpiredOverlay();
|
||||||
|
setTimeout(() => window.location.reload(), 2500);
|
||||||
|
}
|
||||||
|
return throwError(() => err);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function showExpiredOverlay(): void {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.setAttribute('data-session-expired', 'true');
|
||||||
|
overlay.style.cssText = [
|
||||||
|
'position:fixed', 'inset:0',
|
||||||
|
'background:rgba(26,22,37,0.96)',
|
||||||
|
'color:#e4def5',
|
||||||
|
'display:flex', 'flex-direction:column',
|
||||||
|
'align-items:center', 'justify-content:center',
|
||||||
|
'gap:1rem',
|
||||||
|
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
||||||
|
'z-index:99999',
|
||||||
|
'text-align:center', 'padding:2rem',
|
||||||
|
].join(';');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div style="font-size:1.5rem;color:#b794f4;">✦ Votre session démo a expiré</div>
|
||||||
|
<div style="color:#aaa0c5;max-width:420px;line-height:1.5;">
|
||||||
|
Une nouvelle session va être préparée automatiquement.<br>
|
||||||
|
Vos données précédentes ne sont pas conservées.
|
||||||
|
</div>
|
||||||
|
<div style="
|
||||||
|
width:32px;height:32px;margin-top:0.5rem;
|
||||||
|
border:3px solid rgba(183,148,244,0.2);
|
||||||
|
border-top-color:#b794f4;border-radius:50%;
|
||||||
|
animation:sex-spin 1s linear infinite;
|
||||||
|
"></div>
|
||||||
|
<style>@keyframes sex-spin{to{transform:rotate(360deg)}}</style>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
@@ -42,7 +42,8 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
fields: TemplateField[] = [
|
fields: TemplateField[] = [
|
||||||
{ name: 'Nom', type: 'TEXT' },
|
{ name: 'Nom', type: 'TEXT' },
|
||||||
{ name: 'Description', type: 'TEXT' }
|
{ name: 'Description', type: 'TEXT' },
|
||||||
|
{ name: 'Illustration', type: 'IMAGE', layout: 'GALLERY' }
|
||||||
];
|
];
|
||||||
/** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */
|
/** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */
|
||||||
newFieldName = '';
|
newFieldName = '';
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
<option value="TEXT">Texte</option>
|
<option value="TEXT">Texte</option>
|
||||||
<option value="IMAGE">Image</option>
|
<option value="IMAGE">Image</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn-add" (click)="addField()">
|
<button type="button" class="btn-add" (click)="addField()" title="Ajouter le champ">
|
||||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -198,18 +198,7 @@
|
|||||||
&::placeholder { color: #6b7280; }
|
&::placeholder { color: #6b7280; }
|
||||||
}
|
}
|
||||||
|
|
||||||
&.add-row {
|
&.add-row { margin-top: 0.5rem; }
|
||||||
margin-top: 0.25rem;
|
|
||||||
border: 1px dashed #2a2a3d;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
input {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
&:focus { border: none; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.reorder-stack {
|
.reorder-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { bootstrapApplication } from '@angular/platform-browser';
|
|||||||
import { AppComponent } from './app/app.component';
|
import { AppComponent } from './app/app.component';
|
||||||
import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router';
|
import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router';
|
||||||
import { routes } from './app/app.routes';
|
import { routes } from './app/app.routes';
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { APP_INITIALIZER } from '@angular/core';
|
import { APP_INITIALIZER } from '@angular/core';
|
||||||
import { ConfigService } from './app/services/config.service';
|
import { ConfigService } from './app/services/config.service';
|
||||||
|
import { sessionExpiredInterceptor } from './app/interceptors/session-expired.interceptor';
|
||||||
|
|
||||||
// withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular
|
// withPreloading(PreloadAllModules) : une fois l'app initiale rendue, Angular
|
||||||
// telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la
|
// telecharge en arriere-plan tous les chunks lazy-loades. Consequence : la
|
||||||
@@ -14,7 +15,7 @@ import { ConfigService } from './app/services/config.service';
|
|||||||
bootstrapApplication(AppComponent, {
|
bootstrapApplication(AppComponent, {
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter(routes, withPreloading(PreloadAllModules)),
|
provideRouter(routes, withPreloading(PreloadAllModules)),
|
||||||
provideHttpClient(),
|
provideHttpClient(withInterceptors([sessionExpiredInterceptor])),
|
||||||
{
|
{
|
||||||
provide: APP_INITIALIZER,
|
provide: APP_INITIALIZER,
|
||||||
useFactory: (config: ConfigService) => () => config.load(),
|
useFactory: (config: ConfigService) => () => config.load(),
|
||||||
|
|||||||
Reference in New Issue
Block a user