fix: correct real bugs and remove dead code found in a full-project quality review

Bugs:
- blocks.ts/containers.ts: reset toInit on a failed initial catalog load so
  the next call retries instead of leaving the catalog permanently empty.
- task-step-node.ts: align isEmptyDisplayValue with generic-node's
  isMissingValue so a field renders consistently between the editor and the
  execution view for the same schema.
- flows-call.fake.ts/assistant-call.fake.ts: wrap synchronous throws in
  defer() so catchError() on the caller side actually intercepts them.
- utilities/rete-editor.ts: remove a no-op ternary and a leftover debug log.
- stores/flow-editor.ts: save() now returns a handled error instead of
  crashing on a non-null assertion when no flow is loaded.
- Removed leftover console.log statements (title-toolbar, flow-item,
  editor-sidebar).

Dead code removed: the unused admin-users page, editor-sidebar's unused
createNewBlock(), rete-editor's unused flowChanged output, and the broken,
uncalled ListStateViewHolder.create().

Refactors (duplication called out by the same review):
- New session-guard.ts factory backing authGuard/adminGuard.
- tasks-executor's formatDuration now reuses the shared util.
- New services/shared/http-error.util.ts replacing the duplicated
  extractHttpErrorMessage/toHttpError in admin-call.ts and authorization-call.ts.
- New services/bias/bias-error.util.ts unifying the three different ad hoc
  error-message extractions across the bias-* dialogs.
- New pages/admin/admin-access.util.ts and utilities/temporary-signal.ts
  replacing the duplicated redirectOnAdminAccessDenied and auto-dismiss-toast
  patterns.
- New shared ModalShellComponent adopted by the three bias-* dialogs (their
  backdrop/header/footer CSS was already byte-identical); new
  password-form-validators.ts and a shared password-dialog-chrome.css
  collapsing admin-reset-password-dialog and change-password-dialog, which
  duplicated their entire validation logic and CSS.
- New services/shared/{catalog-store,empty-node-cache,pending-sync-counter,
  deep-clone,flow-node-mapping}.ts: BlocksService/ContainersService and their
  *-call.ts mappers were near line-for-line duplicates (which is exactly how
  the toInit bug ended up in both).
- New shared/nodes/node-focus-modal-controller.ts: generic-node.ts and
  container-node.ts had ~150 identical lines of focus-modal/body-scroll-lock
  plumbing.

Confirm-dialog, node-settings-dialog, human-interaction-dialog and
subflow-preview-dialog were deliberately left out of the modal-shell
extraction: each has a meaningfully different structure and no way to verify
visually here, so forcing them into a shared shell was judged higher risk
than the cosmetic-only bias-*/password dialogs, whose CSS was already
byte-identical.

61 test files / 230 tests passing; `ng build` and `ng test` green throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-07-21 14:27:34 +02:00
parent 94ce814a6f
commit af246dab65
72 changed files with 1710 additions and 1886 deletions

View File

@ -1,20 +1,9 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
import { map } from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { createSessionGuard } from './session-guard';
export const adminGuard: CanActivateFn = (_route, state) => {
const authorization = inject(Authorization);
const router = inject(Router);
return authorization.validateSession().pipe(
map((user) => {
if (user?.role === 'ADMIN') {
return true;
}
console.warn(`[adminGuard] Access denied to ${state.url} — user is not admin, redirecting to /`);
return router.parseUrl('/');
})
);
};
export const adminGuard: CanActivateFn = createSessionGuard(
(user) => user?.role === 'ADMIN',
'/',
'adminGuard',
'user is not admin'
);

View File

@ -1,20 +1,9 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
import { map } from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { createSessionGuard } from './session-guard';
export const authGuard: CanActivateFn = (_route, state) => {
const authService = inject(Authorization);
const router = inject(Router);
return authService.validateSession().pipe(
map((user) => {
if (user) {
return true;
}
console.warn(`[authGuard] Access denied to ${state.url} — user not logged in, redirecting to /login`);
return router.parseUrl('/login');
})
);
};
export const authGuard: CanActivateFn = createSessionGuard(
(user) => !!user,
'/login',
'authGuard',
'user not logged in'
);

View File

@ -0,0 +1,33 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
import { User } from '@models/user';
import { map } from 'rxjs';
/**
* Builds a route guard that validates the session and redirects when `predicate`
* rejects the logged-in user (or there is none). Shared by authGuard/adminGuard
* so the two only differ in who is allowed through and where they land otherwise.
*/
export function createSessionGuard(
predicate: (user: User | null) => boolean,
redirectUrl: string,
logLabel: string,
denialReason: string
): CanActivateFn {
return (_route, state) => {
const authorization = inject(Authorization);
const router = inject(Router);
return authorization.validateSession().pipe(
map((user) => {
if (predicate(user)) {
return true;
}
console.warn(`[${logLabel}] Access denied to ${state.url} — ${denialReason}, redirecting to ${redirectUrl}`);
return router.parseUrl(redirectUrl);
})
);
};
}

View File

@ -10,6 +10,7 @@ import { Authorization } from '@services/authorization/authorization';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { ChangePasswordDialogComponent } from '@shared/change-password-dialog/change-password-dialog';
import { scheduleSignalClear } from '@utilities/temporary-signal';
@Component({
selector: 'app-app-layout',
@ -79,9 +80,7 @@ logout() {
this.changePasswordOpen.set(false);
this.changePasswordError.set(null);
this.changePasswordSuccess.set('Password changed successfully.');
setTimeout(() => {
this.changePasswordSuccess.set(null);
}, 3000);
scheduleSignalClear(this.changePasswordSuccess);
},
error: (error) => {
this.changePasswordSaving.set(false);

View File

@ -9,6 +9,7 @@ import {
TasksExecutionsListComponent
} from '@shared/tasks-executions-list/tasks-executions-list';
import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer';
import { formatDuration } from '@shared/task-execution-viewer/execution-viewer.utils';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
@ -151,7 +152,7 @@ export class TasksExecutor {
creationTime: execution.creationTime,
runNumber: typeof execution.runNumber === 'number' ? execution.runNumber : fallbackRunNumber,
rerunOfExecutionId: execution.rerunOfExecutionId ?? null,
duration: this.formatDuration(execution.context.startTime ?? null, execution.context.endTime ?? null),
duration: this.formatExecutionDuration(execution.context.startTime ?? null, execution.context.endTime ?? null),
simulated: execution.interactionSimulationEnabled === true
};
}
@ -166,29 +167,8 @@ export class TasksExecutor {
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
private formatDuration(startTime: number | null, endTime: number | null): string {
private formatExecutionDuration(startTime: number | null, endTime: number | null): string {
if (!startTime || !endTime) return '0 sec';
const diffMs = Math.max(0, endTime - startTime);
const totalSeconds = Math.floor(diffMs / 1000);
const totalMinutes = Math.floor(totalSeconds / 60);
const totalHours = Math.floor(totalMinutes / 60);
const totalDays = Math.floor(totalHours / 24);
if (totalSeconds < 60) {
return `${totalSeconds} sec`;
}
if (totalMinutes < 60) {
const seconds = totalSeconds % 60;
return seconds > 0 ? `${totalMinutes} min ${seconds} sec` : `${totalMinutes} min`;
}
if (totalHours < 24) {
const minutes = totalMinutes % 60;
return minutes > 0 ? `${totalHours} h ${minutes} min` : `${totalHours} h`;
}
const hours = totalHours % 24;
return hours > 0 ? `${totalDays} gg ${hours} h` : `${totalDays} gg`;
return formatDuration(startTime, endTime);
}
}

View File

@ -0,0 +1,27 @@
import { Router } from '@angular/router';
import { vi } from 'vitest';
import { redirectOnAdminAccessDenied } from './admin-access.util';
describe('redirectOnAdminAccessDenied', () => {
it('resets busy state and redirects to /editor on "Admin access required."', () => {
const router = { navigateByUrl: vi.fn() } as unknown as Router;
const onRedirect = vi.fn();
const handled = redirectOnAdminAccessDenied(new Error('Admin access required.'), router, onRedirect);
expect(handled).toBe(true);
expect(onRedirect).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith('/editor');
});
it('leaves other errors untouched', () => {
const router = { navigateByUrl: vi.fn() } as unknown as Router;
const onRedirect = vi.fn();
const handled = redirectOnAdminAccessDenied(new Error('Unable to create user.'), router, onRedirect);
expect(handled).toBe(false);
expect(onRedirect).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,22 @@
import { Router } from '@angular/router';
/**
* Admin API calls fail with this exact message when the caller's session
* lost admin privileges mid-flow (e.g. role changed in another tab).
*/
const ADMIN_ACCESS_REQUIRED_MESSAGE = 'Admin access required.';
/**
* Detects the "admin access required" failure from an admin API call and, if
* matched, resets whatever busy-state `onRedirect` clears and navigates away.
* Returns whether the error was handled, so callers can bail out of their own
* error handling with `if (redirectOnAdminAccessDenied(...)) return;`.
*/
export function redirectOnAdminAccessDenied(error: unknown, router: Router, onRedirect: () => void): boolean {
const message = error instanceof Error ? error.message : '';
if (message !== ADMIN_ACCESS_REQUIRED_MESSAGE) return false;
onRedirect();
router.navigateByUrl('/editor');
return true;
}

View File

@ -11,7 +11,9 @@ import { MatSelectModule } from '@angular/material/select';
import { AdminCreateUserRequest, UserRole } from '@models/user';
import { Router } from '@angular/router';
import { AdminService } from '@services/admin/admin';
import { redirectOnAdminAccessDenied } from '@pages/admin/admin-access.util';
import { FormUtility } from '@utilities/form-utility';
import { scheduleSignalClear } from '@utilities/temporary-signal';
import { hasValidPasswordComplexity, evaluatePasswordChecks, initialPasswordChecks, PASSWORD_MIN_LENGTH } from '@utilities/password-validation';
@Component({
@ -102,10 +104,10 @@ export class AdminCreateUserPage extends FormUtility {
password: '',
role: 'USER'
});
setTimeout(() => this.successMessage.set(null), 3000);
scheduleSignalClear(this.successMessage);
},
error: (error) => {
if (this.redirectOnAdminAccessDenied(error)) return;
if (redirectOnAdminAccessDenied(error, this.router, () => this.createSaving.set(false))) return;
this.createSaving.set(false);
const message = error instanceof Error ? error.message : 'Unable to create user.';
if (message === 'INVALID_EMAIL') {
@ -120,13 +122,4 @@ export class AdminCreateUserPage extends FormUtility {
}
});
}
private redirectOnAdminAccessDenied(error: unknown): boolean {
const message = error instanceof Error ? error.message : '';
if (message !== 'Admin access required.') return false;
this.createSaving.set(false);
this.router.navigateByUrl('/editor');
return true;
}
}

View File

@ -11,6 +11,8 @@ import { Router } from '@angular/router';
import { AdminService } from '@services/admin/admin';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-dialog/admin-reset-password-dialog';
import { redirectOnAdminAccessDenied } from '@pages/admin/admin-access.util';
import { scheduleSignalClear } from '@utilities/temporary-signal';
@Component({
selector: 'app-admin-users-list-page',
@ -64,7 +66,7 @@ export class AdminUsersListPage {
this.loading.set(false);
},
error: (error) => {
if (this.redirectOnAdminAccessDenied(error)) return;
if (this.redirectOnAccessDenied(error)) return;
this.pageError.set(error instanceof Error ? error.message : 'Unable to load users.');
this.loading.set(false);
}
@ -91,10 +93,10 @@ export class AdminUsersListPage {
this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false }));
this.successMessage.set(`Role updated for ${user.username}.`);
this.loadUsers();
setTimeout(() => this.successMessage.set(null), 3000);
scheduleSignalClear(this.successMessage);
},
error: (error) => {
if (this.redirectOnAdminAccessDenied(error)) return;
if (this.redirectOnAccessDenied(error)) return;
this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false }));
this.roleErrorByUser.update((current) => ({
...current,
@ -123,10 +125,10 @@ export class AdminUsersListPage {
this.resetPasswordSaving.set(false);
this.resetPasswordDialogUser.set(null);
this.successMessage.set(`Password updated for ${event.username}.`);
setTimeout(() => this.successMessage.set(null), 3000);
scheduleSignalClear(this.successMessage);
},
error: (error) => {
if (this.redirectOnAdminAccessDenied(error)) return;
if (this.redirectOnAccessDenied(error)) return;
this.resetPasswordSaving.set(false);
this.resetPasswordError.set(error instanceof Error ? error.message : 'Unable to update password.');
}
@ -143,10 +145,10 @@ export class AdminUsersListPage {
this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false }));
this.successMessage.set(`User ${user.username} deleted.`);
this.loadUsers();
setTimeout(() => this.successMessage.set(null), 3000);
scheduleSignalClear(this.successMessage);
},
error: (error) => {
if (this.redirectOnAdminAccessDenied(error)) return;
if (this.redirectOnAccessDenied(error)) return;
this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false }));
this.roleErrorByUser.update((current) => ({
...current,
@ -156,13 +158,10 @@ export class AdminUsersListPage {
});
}
private redirectOnAdminAccessDenied(error: unknown): boolean {
const message = error instanceof Error ? error.message : '';
if (message !== 'Admin access required.') return false;
this.loading.set(false);
this.resetPasswordSaving.set(false);
this.router.navigateByUrl('/editor');
return true;
private redirectOnAccessDenied(error: unknown): boolean {
return redirectOnAdminAccessDenied(error, this.router, () => {
this.loading.set(false);
this.resetPasswordSaving.set(false);
});
}
}

View File

@ -1,187 +0,0 @@
.admin-users-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
padding: 8px 4px 16px;
overflow: auto;
background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%);
}
.admin-users-page__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.admin-users-page__eyebrow {
color: #2563eb;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-users-page__title {
margin: 4px 0 0;
color: #0f172a;
font-size: 28px;
font-weight: 800;
}
.admin-users-page__subtitle {
margin: 6px 0 0;
color: #64748b;
font-size: 14px;
}
.admin-users-page__success,
.admin-users-card__error,
.admin-users-row__error {
border-radius: 14px;
padding: 12px 14px;
font-size: 13px;
font-weight: 600;
}
.admin-users-page__success {
border: 1px solid #86efac;
background: #f0fdf4;
color: #166534;
}
.admin-users-card {
border-radius: 22px;
padding: 20px;
}
.admin-users-card__title {
margin-bottom: 14px;
color: #0f172a;
font-size: 18px;
font-weight: 700;
}
.admin-users-card__error,
.admin-users-row__error {
border: 1px solid #fecaca;
background: #fff1f2;
color: #b91c1c;
}
.admin-users-create {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-users-create__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.admin-users-create__password {
width: 100%;
}
.admin-users-checklist {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
}
.admin-users-check {
display: flex;
align-items: center;
gap: 8px;
color: #b91c1c;
font-size: 12px;
}
.admin-users-check--ok {
color: #15803d;
}
.admin-users-create__actions {
display: flex;
justify-content: flex-end;
}
.admin-users-state {
color: #64748b;
font-size: 14px;
padding: 12px 4px;
}
.admin-users-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-users-row {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1fr) auto;
gap: 16px;
align-items: center;
border: 1px solid #e2e8f0;
border-radius: 18px;
background: #f8fbff;
padding: 16px;
}
.admin-users-row__identity {
min-width: 0;
}
.admin-users-row__username {
color: #0f172a;
font-size: 15px;
font-weight: 700;
}
.admin-users-row__email {
color: #64748b;
font-size: 13px;
margin-top: 4px;
word-break: break-word;
}
.admin-users-row__role {
display: flex;
align-items: center;
gap: 10px;
}
.admin-users-row__role-field {
min-width: 150px;
}
.admin-users-row__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.admin-users-row__error {
grid-column: 1 / -1;
}
@media (max-width: 1100px) {
.admin-users-create__grid,
.admin-users-row {
grid-template-columns: 1fr;
}
.admin-users-checklist {
grid-template-columns: 1fr;
}
.admin-users-row__actions {
justify-content: flex-start;
}
}

View File

@ -1,140 +0,0 @@
<div class="admin-users-page">
<div class="admin-users-page__header">
<div>
<div class="admin-users-page__eyebrow">Admin</div>
<h2 class="admin-users-page__title">User Management</h2>
<p class="admin-users-page__subtitle">Create users, change roles, reset passwords and remove accounts.</p>
</div>
<button type="button" mat-stroked-button (click)="loadUsers()">
<mat-icon fontIcon="refresh"></mat-icon>
Refresh
</button>
</div>
@if (successMessage()) {
<div class="admin-users-page__success">{{ successMessage() }}</div>
}
<mat-card class="admin-users-card">
<div class="admin-users-card__title">Create User</div>
<form class="admin-users-create" (ngSubmit)="onCreateUser()">
<div class="admin-users-create__grid">
<mat-form-field appearance="outline">
<mat-label>Username</mat-label>
<input matInput [field]="createForm.username" placeholder="manageduser" />
@if (isInvalid(createForm.username())) {
<mat-error>{{ createForm.username().errors()[0].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput [field]="createForm.email" placeholder="manageduser@example.com" />
@if (createEmailError()) {
<mat-error>{{ createEmailError() }}</mat-error>
} @else if (isInvalid(createForm.email())) {
<mat-error>{{ createForm.email().errors()[0].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Role</mat-label>
<mat-select [ngModel]="createModel().role" (ngModelChange)="setCreateRole($event)" [ngModelOptions]="{ standalone: true }">
<mat-option value="USER">USER</mat-option>
<mat-option value="ADMIN">ADMIN</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="admin-users-create__password">
<mat-label>Password</mat-label>
<input matInput type="password" [field]="createForm.password" placeholder="Managedpass1!" />
@if (createPasswordError()) {
<mat-error>{{ createPasswordError() }}</mat-error>
} @else if (isInvalid(createForm.password())) {
<mat-error>{{ createForm.password().errors()[0].message }}</mat-error>
}
</mat-form-field>
<div class="admin-users-checklist">
@for (check of createPasswordChecks(); track check.label) {
<div class="admin-users-check" [class.admin-users-check--ok]="check.satisfied">
<mat-icon [fontIcon]="check.satisfied ? 'check_circle' : 'radio_button_unchecked'"></mat-icon>
<span>{{ check.label }}</span>
</div>
}
</div>
@if (createError()) {
<div class="admin-users-card__error">{{ createError() }}</div>
}
<div class="admin-users-create__actions">
<button type="submit" mat-flat-button [disabled]="createForm().invalid() || createSaving()">
@if (createSaving()) {
Creating...
} @else {
Create user
}
</button>
</div>
</form>
</mat-card>
<mat-card class="admin-users-card">
<div class="admin-users-card__title">Users</div>
@if (loading()) {
<div class="admin-users-state">Loading users...</div>
} @else if (pageError()) {
<div class="admin-users-card__error">{{ pageError() }}</div>
} @else if (!users().length) {
<div class="admin-users-state">No users found.</div>
} @else {
<div class="admin-users-list">
@for (user of users(); track user.username) {
<div class="admin-users-row">
<div class="admin-users-row__identity">
<div class="admin-users-row__username">{{ user.username }}</div>
<div class="admin-users-row__email">{{ user.email || 'No email' }}</div>
</div>
<div class="admin-users-row__role">
<mat-form-field appearance="outline" class="admin-users-row__role-field">
<mat-label>Role</mat-label>
<mat-select [ngModel]="roleDraft(user.username)" (ngModelChange)="setRoleDraft(user.username, $event)" [ngModelOptions]="{ standalone: true }">
<mat-option value="USER">USER</mat-option>
<mat-option value="ADMIN">ADMIN</mat-option>
</mat-select>
</mat-form-field>
<button type="button" mat-stroked-button [disabled]="roleSavingByUser()[user.username] || roleDraft(user.username) === user.role" (click)="saveRole(user)">
Save role
</button>
</div>
<div class="admin-users-row__actions">
<button type="button" mat-stroked-button (click)="openResetPasswordDialog(user)">Reset password</button>
<button type="button" mat-stroked-button color="warn" [disabled]="deleteBusyByUser()[user.username]" (click)="deleteUser(user)">
Delete
</button>
</div>
@if (roleErrorByUser()[user.username]) {
<div class="admin-users-row__error">{{ roleErrorByUser()[user.username] }}</div>
}
</div>
}
</div>
}
</mat-card>
@if (resetPasswordDialogUser(); as user) {
<app-admin-reset-password-dialog
[username]="user.username"
[saving]="resetPasswordSaving()"
[submitError]="resetPasswordError()"
(closed)="closeResetPasswordDialog()"
(submitted)="submitResetPassword($event)">
</app-admin-reset-password-dialog>
}
</div>

View File

@ -1,258 +0,0 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Field, form, minLength, required, validate } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import {
AdminCreateUserRequest,
AdminUser,
UserRole
} from '@models/user';
import { AdminService } from '@services/admin/admin';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-dialog/admin-reset-password-dialog';
import { FormUtility } from '@utilities/form-utility';
import { hasValidPasswordComplexity, evaluatePasswordChecks, initialPasswordChecks, PASSWORD_MIN_LENGTH } from '@utilities/password-validation';
@Component({
selector: 'app-admin-users',
imports: [
CommonModule,
FormsModule,
Field,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
AdminResetPasswordDialogComponent
],
templateUrl: './admin-users.html',
styleUrl: './admin-users.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AdminUsersPage extends FormUtility {
private adminService = inject(AdminService);
private confirmDialog = inject(ConfirmDialogService);
readonly users = signal<AdminUser[]>([]);
readonly loading = signal(true);
readonly pageError = signal<string | null>(null);
readonly createError = signal<string | null>(null);
readonly createEmailError = signal<string | null>(null);
readonly createPasswordError = signal<string | null>(null);
readonly createSaving = signal(false);
readonly roleSavingByUser = signal<Record<string, boolean>>({});
readonly roleErrorByUser = signal<Record<string, string | null>>({});
readonly deleteBusyByUser = signal<Record<string, boolean>>({});
readonly resetPasswordDialogUser = signal<AdminUser | null>(null);
readonly resetPasswordSaving = signal(false);
readonly resetPasswordError = signal<string | null>(null);
readonly successMessage = signal<string | null>(null);
readonly roleDraftByUser = signal<Record<string, UserRole>>({});
readonly createModel = signal<AdminCreateUserRequest>({
username: '',
email: '',
password: '',
role: 'USER'
});
readonly createForm = form(this.createModel, (model) => {
required(model.username, { message: 'Username is required' });
minLength(model.username, 3, { message: 'Username must be at least 3 characters' });
required(model.email, { message: 'Email is required' });
validate(model.email, ({ value }) => {
const email = value();
if (!email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
return {
kind: 'invalidEmail',
message: 'Invalid email address'
};
});
required(model.password, { message: 'Password is required' });
minLength(model.password, PASSWORD_MIN_LENGTH, { message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` });
validate(model.password, ({ value }) => {
const password = value();
if (!password || hasValidPasswordComplexity(password)) return null;
return {
kind: 'passwordComplexity',
message: 'Password must include uppercase, lowercase, number and special character, with no spaces.'
};
});
});
readonly createPasswordChecks = signal(initialPasswordChecks());
constructor() {
super();
effect(() => {
const password = this.createModel().password;
this.createPasswordChecks.set(evaluatePasswordChecks(password));
});
}
ngOnInit() {
this.loadUsers();
}
loadUsers() {
this.loading.set(true);
this.pageError.set(null);
this.adminService.listAdminUsers().subscribe({
next: (users) => {
this.users.set(users);
this.roleDraftByUser.set(
users.reduce<Record<string, UserRole>>((acc, user) => {
acc[user.username] = user.role;
return acc;
}, {})
);
this.loading.set(false);
},
error: (error) => {
this.pageError.set(error instanceof Error ? error.message : 'Unable to load users.');
this.loading.set(false);
}
});
}
onCreateUser() {
if (this.createForm().invalid()) return;
this.createSaving.set(true);
this.createError.set(null);
this.createEmailError.set(null);
this.createPasswordError.set(null);
this.adminService.createAdminUser(this.createModel()).subscribe({
next: () => {
this.createSaving.set(false);
this.successMessage.set('User created successfully.');
this.createModel.set({
username: '',
email: '',
password: '',
role: 'USER'
});
this.loadUsers();
setTimeout(() => this.successMessage.set(null), 3000);
},
error: (error) => {
this.createSaving.set(false);
const message = error instanceof Error ? error.message : 'Unable to create user.';
if (message === 'INVALID_EMAIL') {
this.createEmailError.set('Invalid email address');
return;
}
if (message === 'INVALID_PASSWORD') {
this.createPasswordError.set('Password does not satisfy the required policy.');
return;
}
this.createError.set(message);
}
});
}
roleDraft(username: string): UserRole {
return this.roleDraftByUser()[username] ?? 'USER';
}
setCreateRole(role: UserRole) {
this.createModel.update((current) => ({
...current,
role
}));
}
setRoleDraft(username: string, role: UserRole) {
this.roleDraftByUser.update((current) => ({
...current,
[username]: role
}));
this.roleErrorByUser.update((current) => ({
...current,
[username]: null
}));
}
saveRole(user: AdminUser) {
const nextRole = this.roleDraft(user.username);
if (nextRole === user.role) return;
this.roleSavingByUser.update((current) => ({ ...current, [user.username]: true }));
this.roleErrorByUser.update((current) => ({ ...current, [user.username]: null }));
this.adminService.changeAdminUserRole(user.username, { role: nextRole }).subscribe({
next: () => {
this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false }));
this.successMessage.set(`Role updated for ${user.username}.`);
this.loadUsers();
setTimeout(() => this.successMessage.set(null), 3000);
},
error: (error) => {
this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false }));
this.roleErrorByUser.update((current) => ({
...current,
[user.username]: error instanceof Error ? error.message : 'Unable to update role.'
}));
}
});
}
openResetPasswordDialog(user: AdminUser) {
this.resetPasswordError.set(null);
this.resetPasswordDialogUser.set(user);
}
closeResetPasswordDialog() {
if (this.resetPasswordSaving()) return;
this.resetPasswordDialogUser.set(null);
this.resetPasswordError.set(null);
}
submitResetPassword(event: { username: string; newPassword: string }) {
this.resetPasswordSaving.set(true);
this.resetPasswordError.set(null);
this.adminService.changeAdminUserPassword(event.username, { newPassword: event.newPassword }).subscribe({
next: () => {
this.resetPasswordSaving.set(false);
this.resetPasswordDialogUser.set(null);
this.successMessage.set(`Password updated for ${event.username}.`);
setTimeout(() => this.successMessage.set(null), 3000);
},
error: (error) => {
this.resetPasswordSaving.set(false);
this.resetPasswordError.set(error instanceof Error ? error.message : 'Unable to update password.');
}
});
}
async deleteUser(user: AdminUser) {
const confirmed = await this.confirmDialog.open(`Delete user ${user.username}?`);
if (!confirmed) return;
this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: true }));
this.adminService.deleteAdminUser(user.username).subscribe({
next: () => {
this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false }));
this.successMessage.set(`User ${user.username} deleted.`);
this.loadUsers();
setTimeout(() => this.successMessage.set(null), 3000);
},
error: (error) => {
this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false }));
this.roleErrorByUser.update((current) => ({
...current,
[user.username]: error instanceof Error ? error.message : 'Unable to delete user.'
}));
}
});
}
}

View File

@ -7,6 +7,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Router, RouterLink } from "@angular/router";
import { FormUtility } from '@utilities/form-utility';
import { scheduleSignalClear } from '@utilities/temporary-signal';
import { Authorization } from '@services/authorization/authorization';
import { Field, form, required } from '@angular/forms/signals';
@ -40,12 +41,12 @@ export class Login extends FormUtility {
super();
effect(() => {
if (this.error() != null) {
setTimeout(() => this.error.set(null), 3000);
scheduleSignalClear(this.error, 3000);
}
});
effect(() => {
if (this.registeredUser() != null) {
setTimeout(() => this.registeredUser.set(null), 5000);
scheduleSignalClear(this.registeredUser, 5000);
}
});
}

View File

@ -72,12 +72,10 @@ export class EditorSidebar {
}
this.creatingFlow.set(true);
console.log('Creating new flow...');
this.flowService.createNewFlow().pipe(
finalize(() => this.creatingFlow.set(false))
).subscribe({
next: flow => {
console.log('New flow created:', flow);
this.flowState.openDocument(flow);
},
error: err => {
@ -90,8 +88,4 @@ export class EditorSidebar {
this.createWithAiRequested.emit();
}
createNewBlock() {
console.log('Creating new block...');
}
}

View File

@ -7,11 +7,12 @@ import {
UserStatistics,
UserRole
} from "@models/user";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { HttpClient } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import { catchError, map, Observable, throwError } from "rxjs";
import { catchError, map, Observable } from "rxjs";
import { AdminCallServiceBase } from "./admin-call.base";
import { toHttpError } from "@services/shared/http-error.util";
export class AdminCallService extends AdminCallServiceBase {
private readonly http = inject(HttpClient);
@ -21,7 +22,7 @@ export class AdminCallService extends AdminCallServiceBase {
.get<unknown[]>(`${environment.apiUrl}/auth/admin/users`)
.pipe(
map((raw) => Array.isArray(raw) ? raw.map((item) => this.adminUserFromApi(item)) : []),
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
403: 'Admin access required.'
}))
);
@ -31,7 +32,7 @@ export class AdminCallService extends AdminCallServiceBase {
return this.http
.post<void>(`${environment.apiUrl}/auth/admin/users`, request)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
400: 'Unable to create user.',
403: 'Admin access required.'
}))
@ -42,7 +43,7 @@ export class AdminCallService extends AdminCallServiceBase {
return this.http
.put<void>(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}/password`, request)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
400: 'Unable to update password.',
403: 'Admin access required.',
404: `User ${username} not found`
@ -54,7 +55,7 @@ export class AdminCallService extends AdminCallServiceBase {
return this.http
.put<void>(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}/role`, request)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
400: 'Unable to update role.',
403: 'Admin access required.',
404: `User ${username} not found`,
@ -67,7 +68,7 @@ export class AdminCallService extends AdminCallServiceBase {
return this.http
.delete<void>(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}`)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
403: 'Admin access required.',
404: `User ${username} not found`,
409: 'LAST_ADMIN'
@ -80,7 +81,7 @@ export class AdminCallService extends AdminCallServiceBase {
.get<unknown>(`${environment.apiUrl}/stats`)
.pipe(
map((raw) => this.operationsStatisticsFromApi(raw)),
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
401: 'Unauthenticated',
403: 'You are not allowed to view user statistics'
}))
@ -94,7 +95,7 @@ export class AdminCallService extends AdminCallServiceBase {
map((raw) => Array.isArray(raw)
? raw.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter((item) => item.length > 0)
: []),
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
401: 'Unauthenticated',
403: 'You are not allowed to view user statistics'
}))
@ -106,7 +107,7 @@ export class AdminCallService extends AdminCallServiceBase {
.get<unknown>(`${environment.apiUrl}/stats/users/${encodeURIComponent(username)}`)
.pipe(
map((raw) => this.userStatisticsFromApi(raw, username)),
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
401: 'Unauthenticated',
403: 'You are not allowed to view user statistics',
404: 'User not found'
@ -114,29 +115,6 @@ export class AdminCallService extends AdminCallServiceBase {
);
}
private extractHttpErrorMessage(error: HttpErrorResponse): string | null {
const payload = error.error;
if (typeof payload === 'string' && payload.trim().length > 0) {
return payload.trim();
}
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
const directMessage = record['message'];
if (typeof directMessage === 'string' && directMessage.trim().length > 0) {
return directMessage.trim();
}
const errorMessage = record['error'];
if (typeof errorMessage === 'string' && errorMessage.trim().length > 0) {
return errorMessage.trim();
}
const details = record['details'];
if (typeof details === 'string' && details.trim().length > 0) {
return details.trim();
}
}
return null;
}
private normalizeRole(value: unknown): UserRole {
return String(value ?? '').toUpperCase() === 'ADMIN' ? 'ADMIN' : 'USER';
}
@ -187,16 +165,4 @@ export class AdminCallService extends AdminCallServiceBase {
};
}
private toHttpError(error: unknown, fallbackByStatus: Record<number, string>): Observable<never> {
if (error instanceof HttpErrorResponse) {
const message = this.extractHttpErrorMessage(error)
?? fallbackByStatus[error.status]
?? 'Request failed.';
return throwError(() => new Error(message));
}
if (error instanceof Error) {
return throwError(() => error);
}
return throwError(() => new Error('Request failed.'));
}
}

View File

@ -0,0 +1,44 @@
import { catchError, of } from 'rxjs';
import { AssistantCallServiceFake } from './assistant-call.fake';
describe('AssistantCallServiceFake', () => {
let service: AssistantCallServiceFake;
beforeEach(() => {
service = new AssistantCallServiceFake();
});
it('surfaces "call not found" as an observable error catchError can intercept, not a synchronous throw', async () => {
expect(() => service.getCall('missing-call')).not.toThrow();
let caught: unknown = null;
await new Promise<void>((resolve) => {
service.getCall('missing-call').pipe(
catchError((error) => {
caught = error;
return of(null);
})
).subscribe(() => resolve());
});
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toContain('missing-call');
});
it('surfaces "session not found" as an observable error catchError can intercept, not a synchronous throw', async () => {
expect(() => service.getSession('missing-session')).not.toThrow();
let caught: unknown = null;
await new Promise<void>((resolve) => {
service.getSession('missing-session').pipe(
catchError((error) => {
caught = error;
return of(null);
})
).subscribe(() => resolve());
});
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toContain('missing-session');
});
});

View File

@ -9,7 +9,7 @@ import {
AssistantValidationIssue
} from '@models/assistant';
import { FlowData } from '@models/flow';
import { Observable, of } from 'rxjs';
import { defer, Observable, of } from 'rxjs';
import { AssistantCallServiceBase } from './assistant-call.base';
type FakeCallRecord = {
@ -62,99 +62,109 @@ export class AssistantCallServiceFake extends AssistantCallServiceBase {
}
override sendMessage(sessionId: string, request: AssistantSendMessageRequest): Observable<{ callId: string }> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Assistant session ${sessionId} not found`);
}
session.messages = [
...session.messages,
{
id: crypto.randomUUID(),
role: 'user',
content: request.message
return defer(() => {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Assistant session ${sessionId} not found`);
}
];
session.messages = [
...session.messages,
{
id: crypto.randomUUID(),
role: 'user',
content: request.message
}
];
const callId = crypto.randomUUID();
const phases = this.buildPhases(request.message);
this.calls.set(callId, {
id: callId,
sessionId,
content: request.message,
phaseIndex: 0,
phases,
completed: false,
failed: false,
cancelled: false
const callId = crypto.randomUUID();
const phases = this.buildPhases(request.message);
this.calls.set(callId, {
id: callId,
sessionId,
content: request.message,
phaseIndex: 0,
phases,
completed: false,
failed: false,
cancelled: false
});
session.lastCallId = callId;
return of({ callId });
});
session.lastCallId = callId;
return of({ callId });
}
override getCall(callId: string): Observable<AssistantCallState> {
const call = this.calls.get(callId);
if (!call) {
throw new Error(`Assistant call ${callId} not found`);
}
if (!call.completed && !call.failed && !call.cancelled) {
if (call.phaseIndex < call.phases.length - 1) {
call.phaseIndex += 1;
} else {
call.completed = true;
this.applyCallResult(call);
return defer(() => {
const call = this.calls.get(callId);
if (!call) {
throw new Error(`Assistant call ${callId} not found`);
}
}
const phase = call.completed
? 'completed'
: call.failed
? 'failed'
: call.cancelled
? 'cancelled'
: call.phases[call.phaseIndex];
if (!call.completed && !call.failed && !call.cancelled) {
if (call.phaseIndex < call.phases.length - 1) {
call.phaseIndex += 1;
} else {
call.completed = true;
this.applyCallResult(call);
}
}
return of({
id: call.id,
sessionId: call.sessionId,
status: call.failed
? 'FAILED'
: call.cancelled
? 'CANCELLED'
: call.completed
? 'COMPLETED'
: call.phaseIndex === 0
? 'QUEUED'
: 'RUNNING',
phase,
progressMessage: call.cancelled ? 'Assistant request cancelled' : undefined,
errorMessage: call.failed ? 'Fake assistant call failed.' : undefined
const phase = call.completed
? 'completed'
: call.failed
? 'failed'
: call.cancelled
? 'cancelled'
: call.phases[call.phaseIndex];
const result: AssistantCallState = {
id: call.id,
sessionId: call.sessionId,
status: call.failed
? 'FAILED'
: call.cancelled
? 'CANCELLED'
: call.completed
? 'COMPLETED'
: call.phaseIndex === 0
? 'QUEUED'
: 'RUNNING',
phase,
progressMessage: call.cancelled ? 'Assistant request cancelled' : undefined,
errorMessage: call.failed ? 'Fake assistant call failed.' : undefined
};
return of(result);
});
}
override cancelCall(callId: string): Observable<AssistantCallState> {
const call = this.calls.get(callId);
if (!call) {
throw new Error(`Assistant call ${callId} not found`);
}
return defer(() => {
const call = this.calls.get(callId);
if (!call) {
throw new Error(`Assistant call ${callId} not found`);
}
call.cancelled = true;
return of({
id: call.id,
sessionId: call.sessionId,
status: 'CANCELLED',
phase: 'cancelled',
progressMessage: 'Assistant request cancelled'
call.cancelled = true;
const result: AssistantCallState = {
id: call.id,
sessionId: call.sessionId,
status: 'CANCELLED',
phase: 'cancelled',
progressMessage: 'Assistant request cancelled'
};
return of(result);
});
}
override getSession(sessionId: string): Observable<AssistantSessionState> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Assistant session ${sessionId} not found`);
}
return of(structuredClone(session));
return defer(() => {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Assistant session ${sessionId} not found`);
}
return of(structuredClone(session));
});
}
private buildPhases(content: string): AssistantCallPhase[] {

View File

@ -9,6 +9,7 @@ import { catchError, map, Observable, of, throwError } from "rxjs";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import { extractHttpErrorMessage, toHttpError } from "@services/shared/http-error.util";
export class AuthorizationCallService extends AuthorizationCallServiceBase {
private readonly http = inject(HttpClient);
@ -35,7 +36,7 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
.get<unknown>(`${environment.apiUrl}/auth/me`)
.pipe(
map((raw) => this.userFromApi(raw)),
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
401: 'Unauthenticated'
}))
);
@ -44,7 +45,7 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
override register(userRegistration: UserRegistration): Observable<void> {
return this.http.post<void>(`${environment.apiUrl}/auth/register`, userRegistration)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
catchError((error: unknown) => toHttpError(error, {
400: 'Unable to register user.'
}))
);
@ -57,7 +58,7 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
map(() => undefined),
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse) {
const message = this.extractHttpErrorMessage(error)
const message = extractHttpErrorMessage(error)
?? (error.status === 400 ? 'Missing required fields.' : null)
?? (error.status === 401 ? 'Current password is invalid' : null)
?? (error.status === 404 ? 'User not found' : null)
@ -69,29 +70,6 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
);
}
private extractHttpErrorMessage(error: HttpErrorResponse): string | null {
const payload = error.error;
if (typeof payload === 'string' && payload.trim().length > 0) {
return payload.trim();
}
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
const directMessage = record['message'];
if (typeof directMessage === 'string' && directMessage.trim().length > 0) {
return directMessage.trim();
}
const errorMessage = record['error'];
if (typeof errorMessage === 'string' && errorMessage.trim().length > 0) {
return errorMessage.trim();
}
const details = record['details'];
if (typeof details === 'string' && details.trim().length > 0) {
return details.trim();
}
}
return null;
}
private userFromApi(raw: unknown, fallbackUsername?: string): User {
const payload = (raw ?? {}) as Record<string, unknown>;
const userSource =
@ -118,14 +96,4 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
catchError(() => of(undefined))
);
}
private toHttpError(error: unknown, fallbackByStatus: Record<number, string>): Observable<never> {
if (error instanceof HttpErrorResponse) {
const message = this.extractHttpErrorMessage(error)
?? fallbackByStatus[error.status]
?? 'Request failed.';
return throwError(() => new Error(message));
}
return throwError(() => error);
}
}

View File

@ -0,0 +1,28 @@
import { BiasSideEffectError } from '@models/bias-impact';
/**
* Extracts a human-readable message for a bias-related request failure.
* Handles, in order: the typed side-effect conflict shape produced by
* `TaskExecutionsService.toBiasOperationError` (409s), the backend's
* `application/problem+json` body (`errors[].message`/`detail`, used by the
* bias endpoints for 400s), a plain `Error`, then `fallback`.
*/
export function extractBiasErrorMessage(error: unknown, fallback: string): string {
const sideEffectError = error as Partial<BiasSideEffectError>;
if (sideEffectError.reason === 'SIDE_EFFECT_BLOCKED' || sideEffectError.reason === 'CONFIRMATION_REQUIRED') {
return sideEffectError.message ?? fallback;
}
const body = (error as { error?: unknown })?.error;
if (body && typeof body === 'object') {
const record = body as Record<string, unknown>;
const errors = Array.isArray(record['errors']) ? record['errors'] : [];
const first = errors[0];
if (first && typeof first === 'object' && typeof (first as Record<string, unknown>)['message'] === 'string') {
return (first as Record<string, unknown>)['message'] as string;
}
if (typeof record['detail'] === 'string' && record['detail']) return record['detail'];
}
return error instanceof Error ? error.message : fallback;
}

View File

@ -5,6 +5,7 @@ import { inject } from "@angular/core";
import { environment } from "@environment";
import { catchError, map, Observable, of, switchMap, take, throwError } from "rxjs";
import { BlockDraftContext, BlocksCallServiceBase } from "./block-call.base";
import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping";
export class BlocksCallService extends BlocksCallServiceBase {
private readonly http = inject(HttpClient);
@ -88,7 +89,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
const descriptor = types.find((type) => type.type === blockType);
const payload = this.buildBlockConfigurationPayload(
blockType,
this.toRecord(configuration?.specificConfiguration ?? configuration),
toRecord(configuration?.specificConfiguration ?? configuration),
descriptor?.schema ?? null
);
@ -106,8 +107,8 @@ export class BlocksCallService extends BlocksCallServiceBase {
map((raw) =>
this.flowBlockFromApi(
{
...(this.toRecord(raw)),
id: this.toRecord(raw)["id"] ?? blockId
...(toRecord(raw)),
id: toRecord(raw)["id"] ?? blockId
},
blockType,
payload
@ -127,20 +128,20 @@ export class BlocksCallService extends BlocksCallServiceBase {
}
private parseCatalogResponse(raw: unknown): BlockType[] {
const value = this.toRecord(raw);
const value = toRecord(raw);
const descriptors = value["descriptors"];
if (!Array.isArray(descriptors)) {
throw new Error('Invalid block catalog response: expected reduced catalog format with a descriptors array');
}
const sharedDefinitions = this.toSchema(value["sharedDefinitions"]);
const sharedDefinitions = toSchema(value["sharedDefinitions"]);
return descriptors.map((descriptor) => this.blockTypeFromApi(descriptor, sharedDefinitions));
}
private blockTypeFromApi(raw: unknown, sharedDefinitions?: Record<string, unknown> | null): BlockType {
const value = this.toRecord(raw);
const schema = this.attachSharedDefinitions(
this.toSchema(value["schema"] ?? value["configurationSchema"] ?? null),
const value = toRecord(raw);
const schema = attachSharedDefinitions(
toSchema(value["schema"] ?? value["configurationSchema"] ?? null),
sharedDefinitions ?? null
);
@ -151,27 +152,27 @@ export class BlocksCallService extends BlocksCallServiceBase {
userInteractive: Boolean(value["userInteractive"] ?? value["interactive"] ?? false),
interactionContract: this.toInteractionContract(value["interactionContract"]),
hasExampleBlock: Boolean(value["hasExampleBlock"] ?? false),
exampleBlockEndpoint: this.toApiPath(value["exampleBlockEndpoint"]),
configurationType: this.toNullableString(value["configurationType"]),
configurationClass: this.toNullableString(value["configurationClass"]),
exampleBlockEndpoint: toApiPath(value["exampleBlockEndpoint"]),
configurationType: toNullableString(value["configurationType"]),
configurationClass: toNullableString(value["configurationClass"]),
schema
};
}
private flowBlockFromApi(raw: unknown, fallbackTypeName = "LLMBlock", fallbackConfig?: Record<string, unknown>): FlowBlock {
const root = this.toRecord(raw);
const value = this.toRecord(root["block"] ?? root["node"] ?? root["data"] ?? root);
const root = toRecord(raw);
const value = toRecord(root["block"] ?? root["node"] ?? root["data"] ?? root);
const specificConfigurationRaw = value["specificConfiguration"] ?? value["configuration"] ?? value["blockConfiguration"] ?? fallbackConfig ?? {};
const specificConfiguration = this.toRecord(specificConfigurationRaw);
const specificConfiguration = toRecord(specificConfigurationRaw);
const typeName = String(value["typeName"] ?? value["blockType"] ?? specificConfiguration["typeName"] ?? fallbackTypeName);
const io = this.defaultIOForBlockType(typeName);
return {
id: String(value["id"] ?? crypto.randomUUID()),
name: String(value["name"] ?? specificConfiguration["name"] ?? typeName),
position: this.toPosition(value["position"]),
inputs: this.toPorts(value["inputs"], io.inputs),
outputs: this.toPorts(value["outputs"], io.outputs),
position: toPosition(value["position"]),
inputs: toPorts(value["inputs"], io.inputs),
outputs: toPorts(value["outputs"], io.outputs),
specificConfiguration,
typeName,
nodeFamily: 'block',
@ -182,13 +183,13 @@ export class BlocksCallService extends BlocksCallServiceBase {
}
private biasAnnotationsDescriptorFromApi(raw: unknown): BiasAnnotationsDescriptor {
const value = this.toRecord(raw);
const rawOptions = this.toRecord(value["options"]);
const value = toRecord(raw);
const rawOptions = toRecord(value["options"]);
const options: Record<string, BiasAnnotationOption[]> = {};
for (const [field, entries] of Object.entries(rawOptions)) {
if (!Array.isArray(entries)) continue;
options[field] = entries
.map((entry) => this.toRecord(entry))
.map((entry) => toRecord(entry))
.filter((entry) => typeof entry["value"] === "string")
.map((entry) => ({
value: String(entry["value"]),
@ -203,9 +204,9 @@ export class BlocksCallService extends BlocksCallServiceBase {
blockProperty: String(value["blockProperty"] ?? "biasAnnotations"),
multiple: value["multiple"] !== false,
maxItems: Number.isFinite(maxItems) && maxItems >= 0 ? maxItems : null,
schema: this.toRecord(value["schema"]),
schema: toRecord(value["schema"]),
options,
defaults: this.toRecord(value["defaults"]),
defaults: toRecord(value["defaults"]),
serverGeneratedFields: Array.isArray(value["serverGeneratedFields"])
? value["serverGeneratedFields"].map(String)
: []
@ -213,7 +214,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
}
private biasCapabilitiesFromApi(raw: unknown, fallbackBlockType: string): BiasCapabilities {
const value = this.toRecord(raw);
const value = toRecord(raw);
const activationModes = Array.isArray(value['activationModes'])
? value['activationModes']
.filter((mode): mode is string => typeof mode === 'string')
@ -231,69 +232,6 @@ export class BlocksCallService extends BlocksCallServiceBase {
};
}
private toPorts(raw: unknown, fallback: Array<{ name: string; type: string; multiple: boolean }>) {
if (!Array.isArray(raw)) return fallback;
return raw
.map((port) => this.toRecord(port))
.filter((port) => typeof port["name"] === "string" && (port["name"] as string).length > 0)
.map((port) => {
const type = String(port["type"] ?? "TEXT");
const multiple = Boolean(port["multiple"] ?? false);
return {
...port,
name: String(port["name"]),
type,
multiple,
valueKinds: this.toValueKinds(port["valueKinds"], { type, multiple })
};
});
}
private toValueKinds(raw: unknown, fallback: { type: string; multiple: boolean }) {
if (!Array.isArray(raw)) {
return [{ type: fallback.type, multiple: fallback.multiple }];
}
const kinds = raw
.map((item) => this.toRecord(item))
.filter((item) => typeof item["type"] === "string")
.map((item) => ({
type: String(item["type"] ?? fallback.type),
multiple: Boolean(item["multiple"] ?? false)
}));
return kinds.length ? kinds : [{ type: fallback.type, multiple: fallback.multiple }];
}
private toPosition(raw: unknown): { x: number; y: number } | undefined {
const value = this.toRecord(raw);
const x = value["x"];
const y = value["y"];
if (typeof x !== "number" || typeof y !== "number") return undefined;
return { x, y };
}
private toSchema(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as Record<string, unknown>;
}
private attachSharedDefinitions(
schema: Record<string, unknown> | null,
sharedDefinitions: Record<string, unknown> | null
): Record<string, unknown> | null {
if (!schema) return null;
if (!sharedDefinitions || !Object.keys(sharedDefinitions).length) return schema;
return {
...schema,
sharedDefinitions: {
...sharedDefinitions,
...this.toRecord(schema["sharedDefinitions"])
}
};
}
private toInteractionContract(raw: unknown): BlockType["interactionContract"] {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const value = raw as Record<string, unknown>;
@ -315,21 +253,6 @@ export class BlocksCallService extends BlocksCallServiceBase {
};
}
private toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
private toNullableString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
private toApiPath(value: unknown): string | null {
if (typeof value !== "string" || value.length === 0) return null;
if (/^https?:\/\//.test(value)) return value;
return `${environment.apiUrl}${value.startsWith("/") ? value : `/${value}`}`;
}
private buildBlockConfigurationPayload(
blockType: string,
configuration: Record<string, unknown>,
@ -394,8 +317,8 @@ export class BlocksCallService extends BlocksCallServiceBase {
private buildObjectFromSchema(node: unknown, root: unknown): Record<string, unknown> {
const resolved = this.resolveRef(node, root);
const resolvedRecord = this.toRecord(resolved);
const properties = this.toRecord(resolvedRecord["properties"]);
const resolvedRecord = toRecord(resolved);
const properties = toRecord(resolvedRecord["properties"]);
const result: Record<string, unknown> = {};
for (const [key, propSchema] of Object.entries(properties)) {
@ -406,7 +329,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
private buildValueFromSchema(node: unknown, root: unknown): unknown {
const resolved = this.resolveRef(node, root);
const value = this.toRecord(resolved);
const value = toRecord(resolved);
if (Object.prototype.hasOwnProperty.call(value, "default")) {
return value["default"];
@ -437,8 +360,8 @@ export class BlocksCallService extends BlocksCallServiceBase {
if (!schemaNode || !schemaRoot) return { ...configuration };
const resolved = this.resolveRef(schemaNode, schemaRoot);
const schemaRecord = this.toRecord(resolved);
const properties = this.toRecord(schemaRecord["properties"]);
const schemaRecord = toRecord(resolved);
const properties = toRecord(schemaRecord["properties"]);
if (!Object.keys(properties).length) {
return { ...configuration };
}
@ -446,7 +369,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(configuration)) {
if (!Object.prototype.hasOwnProperty.call(properties, key)) continue;
const propertySchema = this.toRecord(properties[key]);
const propertySchema = toRecord(properties[key]);
sanitized[key] = this.sanitizeSchemaValue(value, propertySchema, schemaRoot);
}
@ -461,15 +384,15 @@ export class BlocksCallService extends BlocksCallServiceBase {
if (!schemaNode || !schemaRoot || value == null) return value;
const resolved = this.resolveRef(schemaNode, schemaRoot);
const schemaRecord = this.toRecord(resolved);
const schemaRecord = toRecord(resolved);
const type = schemaRecord["type"];
if ((type === "object" || schemaRecord["properties"]) && value && typeof value === "object" && !Array.isArray(value)) {
return this.sanitizeConfigurationBySchema(this.toRecord(value), schemaRecord, schemaRoot);
return this.sanitizeConfigurationBySchema(toRecord(value), schemaRecord, schemaRoot);
}
if (type === "array" && Array.isArray(value)) {
const itemSchema = this.toRecord(schemaRecord["items"]);
const itemSchema = toRecord(schemaRecord["items"]);
return value.map((item) => this.sanitizeSchemaValue(item, itemSchema, schemaRoot));
}
@ -477,7 +400,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
}
private resolveRef(node: unknown, root: unknown): unknown {
const value = this.toRecord(node);
const value = toRecord(node);
const ref = value["$ref"];
if (typeof ref !== "string" || !ref.startsWith("#/")) return node;

View File

@ -1,4 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { throwError, of } from 'rxjs';
import { vi } from 'vitest';
import { BlocksService } from './blocks';
@ -13,4 +15,17 @@ describe('BlocksService', () => {
it('should be created', () => {
expect(service).toBeTruthy();
});
it('retries loading the block catalog after a failed initial fetch', async () => {
const retrieveAllBlocksTypes = vi.fn()
.mockReturnValueOnce(throwError(() => new Error('network down')))
.mockReturnValueOnce(of([]));
service.blocksCallService = { retrieveAllBlocksTypes } as unknown as typeof service.blocksCallService;
await expect(service.getAllBlocksTypes()).rejects.toThrow('network down');
expect(retrieveAllBlocksTypes).toHaveBeenCalledTimes(1);
await service.getAllBlocksTypes();
expect(retrieveAllBlocksTypes).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,30 +1,31 @@
import { computed, Injectable, signal } from '@angular/core';
import { Injectable, Signal, signal } from '@angular/core';
import { environment } from '@environment';
import { BiasAnnotationsDescriptor, BlockType, BlockTypeName, FlowBlock } from '@models/flow';
import { BiasCapabilities } from '@models/bias-impact';
import { BlockDraftContext, BlocksCallServiceBase } from './block-call.base';
import { catchError, finalize, firstValueFrom, map, Observable, of, shareReplay, tap, throwError } from 'rxjs';
import { CatalogStore } from '@services/shared/catalog-store';
import { EmptyNodeCache } from '@services/shared/empty-node-cache';
import { PendingSyncCounter } from '@services/shared/pending-sync-counter';
import { catchError, firstValueFrom, Observable, of, tap, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class BlocksService {
export class BlocksService extends CatalogStore<BlockType> {
blocksCallService: BlocksCallServiceBase = new environment.blocksCallService();
toInit: boolean = true;
private loadingPromise: Promise<void> | null = null;
private readonly _catalogLoading = signal(false);
private readonly emptyBlockCache = new Map<string, FlowBlock>();
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowBlock>>();
private readonly pendingServerSyncCount = signal(0);
protected readonly loadErrorLabel = 'Retrieve blocks types failed';
private readonly emptyBlockCache = new EmptyNodeCache<FlowBlock>();
private readonly serverSync = new PendingSyncCounter();
private _blockTypes = signal<BlockType[]>([]);
private readonly _biasAnnotationsDescriptor = signal<BiasAnnotationsDescriptor | null>(null);
private readonly _biasCapabilities = signal<Record<string, BiasCapabilities>>({});
private biasDescriptorPromise: Promise<BiasAnnotationsDescriptor> | null = null;
readonly hasPendingServerSync = computed(() => this.pendingServerSyncCount() > 0);
readonly blockTypes = this._blockTypes.asReadonly();
readonly catalogLoading = this._catalogLoading.asReadonly();
readonly hasPendingServerSync = this.serverSync.active;
readonly blockTypes = this.types;
readonly catalogLoading = this.loading;
readonly biasAnnotationsDescriptor = this._biasAnnotationsDescriptor.asReadonly();
readonly biasCapabilities = this._biasCapabilities.asReadonly();
@ -58,97 +59,26 @@ export class BlocksService {
}
hasLoadedBlockTypes() {
return this._blockTypes().length > 0 || (!this.toInit && !this.loadingPromise);
return this.hasLoadedTypes();
}
async getAllBlocksTypes() {
if (this.toInit) {
this.toInit = false;
await this.refresh();
} else if (this.loadingPromise) {
await this.loadingPromise;
}
return this._blockTypes.asReadonly();
getAllBlocksTypes(): Promise<Signal<BlockType[]>> {
return this.getAllTypes();
}
async refresh(force = false): Promise<void> {
if (this.loadingPromise && !force) {
return this.loadingPromise;
}
this.loadingPromise = firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes())
.finally(() => {
this._catalogLoading.set(false);
})
.then((blockTypes) => {
this._blockTypes.set(blockTypes);
this.clearEmptyBlockCache();
})
.catch((err) => {
console.error('Retrieve blocks types failed', err);
throw err;
})
.finally(() => {
this.loadingPromise = null;
});
this._catalogLoading.set(true);
return this.loadingPromise;
async getBlockType(typeName: BlockTypeName): Promise<BlockType | undefined> {
return this.getTypeOrFetch((blockType) => blockType.type === typeName);
}
async getBlockType(typeName: BlockTypeName) {
const current = this._blockTypes().find((blockType) => blockType.type === typeName);
if (current) return current;
if (this.loadingPromise) {
await this.loadingPromise;
return this._blockTypes().find((blockType) => blockType.type === typeName);
}
this._catalogLoading.set(true);
const blockTypes = await firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes())
.finally(() => {
this._catalogLoading.set(false);
});
this._blockTypes.set(blockTypes);
this.clearEmptyBlockCache();
return blockTypes.find((blockType) => blockType.type === typeName);
}
peekBlockType(typeName: BlockTypeName) {
return this._blockTypes().find((blockType) => blockType.type === typeName) ?? null;
peekBlockType(typeName: BlockTypeName): BlockType | null {
return this.peekType((blockType) => blockType.type === typeName);
}
createEmptyBlock(blockType: BlockTypeName, context?: BlockDraftContext) {
const flowId = typeof context?.flowId === 'string' && context.flowId.trim().length > 0 ? context.flowId.trim() : '';
const cacheKey = `${String(blockType)}::${flowId}`;
const cached = this.emptyBlockCache.get(cacheKey);
if (cached) {
return of(this.cloneEmptyBlock(cached));
}
const pending = this.pendingEmptyBlockRequests.get(cacheKey);
if (pending) {
return pending.pipe(map((block) => this.cloneEmptyBlock(block)));
}
const request = this.blocksCallService.createEmptyBlock(blockType, context).pipe(
map((block) => {
this.emptyBlockCache.set(cacheKey, this.cloneEmptyBlock(block));
return block;
}),
finalize(() => {
this.pendingEmptyBlockRequests.delete(cacheKey);
}),
shareReplay(1)
);
this.pendingEmptyBlockRequests.set(cacheKey, request);
return request.pipe(
map((block) => this.cloneEmptyBlock(block)),
return this.emptyBlockCache.getOrCreate(cacheKey, () => this.blocksCallService.createEmptyBlock(blockType, context)).pipe(
catchError((err) => {
console.error('Create empty block failed', err);
return throwError(() => err);
@ -157,11 +87,7 @@ export class BlocksService {
}
updateBlock(blockId: string, configuration: any, context?: BlockDraftContext) {
this.pendingServerSyncCount.update((count) => count + 1);
return this.blocksCallService.updateBlock(blockId, configuration, context).pipe(
finalize(() => {
this.pendingServerSyncCount.update((count) => Math.max(0, count - 1));
}),
return this.serverSync.track(this.blocksCallService.updateBlock(blockId, configuration, context)).pipe(
catchError((err) => {
console.error('Update block failed', err);
return throwError(() => err);
@ -169,28 +95,11 @@ export class BlocksService {
);
}
private clearEmptyBlockCache() {
protected fetchAll(): Observable<BlockType[]> {
return this.blocksCallService.retrieveAllBlocksTypes();
}
protected override onLoaded(): void {
this.emptyBlockCache.clear();
this.pendingEmptyBlockRequests.clear();
}
private cloneEmptyBlock(block: FlowBlock): FlowBlock {
const clone = this.deepClone(block);
return {
...clone,
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
position: undefined
};
}
private deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
try {
return globalThis.structuredClone(value);
} catch {
// Some cached payloads may carry non-cloneable runtime fields.
}
}
return JSON.parse(JSON.stringify(value)) as T;
}
}

View File

@ -9,6 +9,7 @@ import {
} from "@models/flow";
import { map, Observable } from "rxjs";
import { ContainersCallServiceBase } from "./container-call.base";
import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping";
export class ContainersCallService extends ContainersCallServiceBase {
private readonly http = inject(HttpClient);
@ -39,7 +40,7 @@ export class ContainersCallService extends ContainersCallServiceBase {
const containerType = String(configuration?.typeName ?? configuration?.type ?? "GenericContainer");
const payload = this.buildContainerConfigurationPayload(
containerType,
this.toRecord(configuration?.specificConfiguration ?? configuration)
toRecord(configuration?.specificConfiguration ?? configuration)
);
return this.http
@ -54,8 +55,8 @@ export class ContainersCallService extends ContainersCallServiceBase {
map((raw) =>
this.flowContainerFromApi(
{
...(this.toRecord(raw)),
id: this.toRecord(raw)["id"] ?? containerId
...(toRecord(raw)),
id: toRecord(raw)["id"] ?? containerId
},
containerType
)
@ -64,7 +65,7 @@ export class ContainersCallService extends ContainersCallServiceBase {
}
override validateContainerSubflow(subFlow: FlowData, validationUrl?: string | null): Observable<FlowSubflowValidationResult> {
const resolvedValidationUrl = this.toApiPath(validationUrl) ?? `${environment.apiUrl}/containers/validate-subflow`;
const resolvedValidationUrl = toApiPath(validationUrl) ?? `${environment.apiUrl}/containers/validate-subflow`;
return this.http
.post<unknown>(
resolvedValidationUrl,
@ -74,20 +75,20 @@ export class ContainersCallService extends ContainersCallServiceBase {
}
private parseCatalogResponse(raw: unknown): BlockType[] {
const value = this.toRecord(raw);
const value = toRecord(raw);
const descriptors = value["descriptors"];
if (!Array.isArray(descriptors)) {
throw new Error('Invalid container catalog response: expected reduced catalog format with a descriptors array');
}
const sharedDefinitions = this.toSchema(value["sharedDefinitions"]);
const sharedDefinitions = toSchema(value["sharedDefinitions"]);
return descriptors.map((descriptor) => this.containerTypeFromApi(descriptor, sharedDefinitions));
}
private containerTypeFromApi(raw: unknown, sharedDefinitions?: Record<string, unknown> | null): BlockType {
const value = this.toRecord(raw);
const schema = this.attachSharedDefinitions(
this.toSchema(value["schema"] ?? value["configurationSchema"] ?? null),
const value = toRecord(raw);
const schema = attachSharedDefinitions(
toSchema(value["schema"] ?? value["configurationSchema"] ?? null),
sharedDefinitions ?? null
);
@ -97,26 +98,26 @@ export class ContainersCallService extends ContainersCallServiceBase {
description: String(value["description"] ?? ""),
userInteractive: Boolean(value["userInteractive"] ?? value["interactive"] ?? false),
hasExampleBlock: Boolean(value["hasExampleBlock"] ?? value["hasExampleContainer"] ?? false),
exampleBlockEndpoint: this.toApiPath(value["exampleBlockEndpoint"] ?? value["exampleContainerEndpoint"]),
configurationType: this.toNullableString(value["configurationType"]),
configurationClass: this.toNullableString(value["configurationClass"]),
exampleBlockEndpoint: toApiPath(value["exampleBlockEndpoint"] ?? value["exampleContainerEndpoint"]),
configurationType: toNullableString(value["configurationType"]),
configurationClass: toNullableString(value["configurationClass"]),
schema
};
}
private flowContainerFromApi(raw: unknown, fallbackTypeName = "GenericContainer"): FlowContainer {
const root = this.toRecord(raw);
const value = this.toRecord(root["container"] ?? root["node"] ?? root["data"] ?? root);
const root = toRecord(raw);
const value = toRecord(root["container"] ?? root["node"] ?? root["data"] ?? root);
const specificConfigurationRaw = value["specificConfiguration"] ?? value["configuration"] ?? value["containerConfiguration"] ?? {};
const specificConfiguration = this.toRecord(specificConfigurationRaw);
const specificConfiguration = toRecord(specificConfigurationRaw);
const typeName = String(value["typeName"] ?? value["containerType"] ?? specificConfiguration["typeName"] ?? fallbackTypeName);
return {
id: String(value["id"] ?? crypto.randomUUID()),
name: String(value["name"] ?? specificConfiguration["name"] ?? typeName),
position: this.toPosition(value["position"]),
inputs: this.toPorts(value["inputs"]),
outputs: this.toPorts(value["outputs"]),
position: toPosition(value["position"]),
inputs: toPorts(value["inputs"]),
outputs: toPorts(value["outputs"]),
specificConfiguration,
typeName,
nodeFamily: 'container'
@ -124,17 +125,17 @@ export class ContainersCallService extends ContainersCallServiceBase {
}
private subflowValidationFromApi(raw: unknown): FlowSubflowValidationResult {
const value = this.toRecord(raw);
const value = toRecord(raw);
const rawErrors = Array.isArray(value['errors']) ? value['errors'] : [];
return {
valid: Boolean(value['valid'] ?? false),
errors: rawErrors
.map((item) => this.toRecord(item))
.map((item) => toRecord(item))
.map((item) => ({
entity: this.toNullableString(item['entity']) ?? undefined,
id: this.toNullableString(item['id']) ?? undefined,
field: this.toNullableString(item['field']) ?? undefined,
entity: toNullableString(item['entity']) ?? undefined,
id: toNullableString(item['id']) ?? undefined,
field: toNullableString(item['field']) ?? undefined,
message: String(item['message'] ?? 'Invalid subflow')
})),
openInputs: this.toOpenInputs(value['openInputs']),
@ -145,17 +146,17 @@ export class ContainersCallService extends ContainersCallServiceBase {
private toOpenInputs(raw: unknown) {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => this.toRecord(item))
.map((item) => toRecord(item))
.map((item) => {
const io = this.toRecord(item['io']);
const port = this.toPorts([Object.keys(io).length ? io : item])[0];
const io = toRecord(item['io']);
const port = toPorts([Object.keys(io).length ? io : item])[0];
if (!port) return null;
return {
...port,
targetBlockId: this.toNullableString(item['targetBlockId'] ?? item['blockId'] ?? item['nodeId']) ?? undefined,
targetInputName: this.toNullableString(item['targetInputName'] ?? item['inputName'] ?? io['name']) ?? undefined,
blockId: this.toNullableString(item['blockId'] ?? item['nodeId']) ?? undefined,
inputName: this.toNullableString(item['inputName'] ?? io['name']) ?? undefined
targetBlockId: toNullableString(item['targetBlockId'] ?? item['blockId'] ?? item['nodeId']) ?? undefined,
targetInputName: toNullableString(item['targetInputName'] ?? item['inputName'] ?? io['name']) ?? undefined,
blockId: toNullableString(item['blockId'] ?? item['nodeId']) ?? undefined,
inputName: toNullableString(item['inputName'] ?? io['name']) ?? undefined
};
})
.filter((item): item is NonNullable<typeof item> => !!item);
@ -164,100 +165,22 @@ export class ContainersCallService extends ContainersCallServiceBase {
private toOpenOutputs(raw: unknown) {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => this.toRecord(item))
.map((item) => toRecord(item))
.map((item) => {
const io = this.toRecord(item['io']);
const port = this.toPorts([Object.keys(io).length ? io : item])[0];
const io = toRecord(item['io']);
const port = toPorts([Object.keys(io).length ? io : item])[0];
if (!port) return null;
return {
...port,
sourceBlockId: this.toNullableString(item['sourceBlockId'] ?? item['blockId'] ?? item['nodeId']) ?? undefined,
sourceOutputName: this.toNullableString(item['sourceOutputName'] ?? item['outputName'] ?? io['name']) ?? undefined,
blockId: this.toNullableString(item['blockId'] ?? item['nodeId']) ?? undefined,
outputName: this.toNullableString(item['outputName'] ?? io['name']) ?? undefined
sourceBlockId: toNullableString(item['sourceBlockId'] ?? item['blockId'] ?? item['nodeId']) ?? undefined,
sourceOutputName: toNullableString(item['sourceOutputName'] ?? item['outputName'] ?? io['name']) ?? undefined,
blockId: toNullableString(item['blockId'] ?? item['nodeId']) ?? undefined,
outputName: toNullableString(item['outputName'] ?? io['name']) ?? undefined
};
})
.filter((item): item is NonNullable<typeof item> => !!item);
}
private toPorts(raw: unknown) {
if (!Array.isArray(raw)) return [];
return raw
.map((port) => this.toRecord(port))
.filter((port) => typeof port["name"] === "string" && (port["name"] as string).length > 0)
.map((port) => {
const type = String(port["type"] ?? "TEXT");
const multiple = Boolean(port["multiple"] ?? false);
return {
...port,
name: String(port["name"]),
type,
multiple,
valueKinds: this.toValueKinds(port["valueKinds"], { type, multiple })
};
});
}
private toValueKinds(raw: unknown, fallback: { type: string; multiple: boolean }) {
if (!Array.isArray(raw)) {
return [{ type: fallback.type, multiple: fallback.multiple }];
}
const kinds = raw
.map((item) => this.toRecord(item))
.filter((item) => typeof item["type"] === "string")
.map((item) => ({
type: String(item["type"] ?? fallback.type),
multiple: Boolean(item["multiple"] ?? false)
}));
return kinds.length ? kinds : [{ type: fallback.type, multiple: fallback.multiple }];
}
private toPosition(raw: unknown): { x: number; y: number } | undefined {
const value = this.toRecord(raw);
const x = value["x"];
const y = value["y"];
if (typeof x !== "number" || typeof y !== "number") return undefined;
return { x, y };
}
private toSchema(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as Record<string, unknown>;
}
private attachSharedDefinitions(
schema: Record<string, unknown> | null,
sharedDefinitions: Record<string, unknown> | null
): Record<string, unknown> | null {
if (!schema) return null;
if (!sharedDefinitions || !Object.keys(sharedDefinitions).length) return schema;
return {
...schema,
sharedDefinitions: {
...sharedDefinitions,
...this.toRecord(schema["sharedDefinitions"])
}
};
}
private toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
private toNullableString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
private toApiPath(value: unknown): string | null {
if (typeof value !== "string" || value.length === 0) return null;
if (/^https?:\/\//.test(value)) return value;
return `${environment.apiUrl}${value.startsWith("/") ? value : `/${value}`}`;
}
private resolveExampleEndpoint(typeName: string, descriptor?: BlockType): string {
if (descriptor?.hasExampleBlock && descriptor.exampleBlockEndpoint) {
return descriptor.exampleBlockEndpoint;
@ -292,10 +215,10 @@ export class ContainersCallService extends ContainersCallServiceBase {
: [];
if (!required.length) return normalized;
const properties = this.toRecord(schema["properties"]);
const properties = toRecord(schema["properties"]);
for (const key of required) {
if (normalized[key] !== undefined) continue;
const propertySchema = this.toRecord(properties[key]);
const propertySchema = toRecord(properties[key]);
const defaultValue = propertySchema["default"];
if (typeof defaultValue === "boolean" || typeof defaultValue === "number" || typeof defaultValue === "string") {
normalized[key] = defaultValue;
@ -310,7 +233,7 @@ export class ContainersCallService extends ContainersCallServiceBase {
}
private resolveConfigurationType(containerType: string, configuration: Record<string, unknown>) {
const explicitType = this.toNullableString(configuration["type"]);
const explicitType = toNullableString(configuration["type"]);
if (explicitType) return explicitType;
const descriptor = this.containerTypesCache
@ -331,7 +254,7 @@ export class ContainersCallService extends ContainersCallServiceBase {
if (!typeProperty || typeof typeProperty !== "object" || Array.isArray(typeProperty)) return null;
const typeSchema = typeProperty as Record<string, unknown>;
const defaultValue = this.toNullableString(typeSchema["default"]);
const defaultValue = toNullableString(typeSchema["default"]);
if (defaultValue) return defaultValue;
const enumValues = Array.isArray(typeSchema["enum"])

View File

@ -0,0 +1,31 @@
import { TestBed } from '@angular/core/testing';
import { throwError, of } from 'rxjs';
import { vi } from 'vitest';
import { ContainersService } from './containers';
describe('ContainersService', () => {
let service: ContainersService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ContainersService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('retries loading the container catalog after a failed initial fetch', async () => {
const retrieveAllContainerTypes = vi.fn()
.mockReturnValueOnce(throwError(() => new Error('network down')))
.mockReturnValueOnce(of([]));
service.containersCallService = { retrieveAllContainerTypes } as unknown as typeof service.containersCallService;
await expect(service.getAllContainerTypes()).rejects.toThrow('network down');
expect(retrieveAllContainerTypes).toHaveBeenCalledTimes(1);
await service.getAllContainerTypes();
expect(retrieveAllContainerTypes).toHaveBeenCalledTimes(2);
});
});

View File

@ -1,118 +1,48 @@
import { computed, Injectable, signal } from '@angular/core';
import { Injectable, Signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType, BlockTypeName, FlowData, FlowNode } from '@models/flow';
import { catchError, finalize, firstValueFrom, map, Observable, of, shareReplay, throwError } from 'rxjs';
import { catchError, Observable, throwError } from 'rxjs';
import { ContainersCallServiceBase } from './container-call.base';
import { CatalogStore } from '@services/shared/catalog-store';
import { EmptyNodeCache } from '@services/shared/empty-node-cache';
import { PendingSyncCounter } from '@services/shared/pending-sync-counter';
import { deepClone } from '@services/shared/deep-clone';
@Injectable({
providedIn: 'root',
})
export class ContainersService {
export class ContainersService extends CatalogStore<BlockType> {
containersCallService: ContainersCallServiceBase = new environment.containersCallService();
toInit = true;
private loadingPromise: Promise<void> | null = null;
private readonly _catalogLoading = signal(false);
private readonly emptyContainerCache = new Map<string, FlowNode>();
private readonly pendingEmptyContainerRequests = new Map<string, Observable<FlowNode>>();
private readonly pendingServerSyncCount = signal(0);
protected readonly loadErrorLabel = 'Retrieve container types failed';
private _containerTypes = signal<BlockType[]>([]);
readonly hasPendingServerSync = computed(() => this.pendingServerSyncCount() > 0);
readonly containerTypes = this._containerTypes.asReadonly();
readonly catalogLoading = this._catalogLoading.asReadonly();
private readonly emptyContainerCache = new EmptyNodeCache<FlowNode>();
private readonly serverSync = new PendingSyncCounter();
readonly hasPendingServerSync = this.serverSync.active;
readonly containerTypes = this.types;
readonly catalogLoading = this.loading;
hasLoadedContainerTypes() {
return this._containerTypes().length > 0 || (!this.toInit && !this.loadingPromise);
return this.hasLoadedTypes();
}
async getAllContainerTypes() {
if (this.toInit) {
this.toInit = false;
await this.refresh();
} else if (this.loadingPromise) {
await this.loadingPromise;
}
return this._containerTypes.asReadonly();
getAllContainerTypes(): Promise<Signal<BlockType[]>> {
return this.getAllTypes();
}
async refresh(force = false): Promise<void> {
if (this.loadingPromise && !force) {
return this.loadingPromise;
}
this.loadingPromise = firstValueFrom(this.containersCallService.retrieveAllContainerTypes())
.finally(() => {
this._catalogLoading.set(false);
})
.then((containerTypes) => {
this._containerTypes.set(containerTypes);
this.clearEmptyContainerCache();
})
.catch((err) => {
console.error('Retrieve container types failed', err);
throw err;
})
.finally(() => {
this.loadingPromise = null;
});
this._catalogLoading.set(true);
return this.loadingPromise;
async getContainerType(typeName: BlockTypeName): Promise<BlockType | undefined> {
return this.getTypeOrFetch((containerType) => containerType.type === typeName);
}
async getContainerType(typeName: BlockTypeName) {
const current = this._containerTypes().find((containerType) => containerType.type === typeName);
if (current) return current;
if (this.loadingPromise) {
await this.loadingPromise;
return this._containerTypes().find((containerType) => containerType.type === typeName);
}
this._catalogLoading.set(true);
const containerTypes = await firstValueFrom(this.containersCallService.retrieveAllContainerTypes())
.finally(() => {
this._catalogLoading.set(false);
});
this._containerTypes.set(containerTypes);
this.clearEmptyContainerCache();
return containerTypes.find((containerType) => containerType.type === typeName);
}
peekContainerType(typeName: BlockTypeName) {
return this._containerTypes().find((containerType) => containerType.type === typeName) ?? null;
peekContainerType(typeName: BlockTypeName): BlockType | null {
return this.peekType((containerType) => containerType.type === typeName);
}
createEmptyContainer(containerType: BlockTypeName) {
const cacheKey = String(containerType);
const cached = this.emptyContainerCache.get(cacheKey);
if (cached) {
return of(this.cloneEmptyNode(cached));
}
const pending = this.pendingEmptyContainerRequests.get(cacheKey);
if (pending) {
return pending.pipe(map((container) => this.cloneEmptyNode(container)));
}
const request = this.containersCallService.createEmptyContainer(containerType).pipe(
map((container) => {
this.emptyContainerCache.set(cacheKey, this.cloneEmptyNode(container));
return container;
}),
finalize(() => {
this.pendingEmptyContainerRequests.delete(cacheKey);
}),
shareReplay(1)
);
this.pendingEmptyContainerRequests.set(cacheKey, request);
return request.pipe(
map((container) => this.cloneEmptyNode(container)),
return this.emptyContainerCache.getOrCreate(cacheKey, () => this.containersCallService.createEmptyContainer(containerType)).pipe(
catchError((err) => {
console.error('Create empty container failed', err);
return throwError(() => err);
@ -121,11 +51,7 @@ export class ContainersService {
}
createContainer(containerId: string, configuration: any) {
this.pendingServerSyncCount.update((count) => count + 1);
return this.containersCallService.createContainer(containerId, configuration).pipe(
finalize(() => {
this.pendingServerSyncCount.update((count) => Math.max(0, count - 1));
}),
return this.serverSync.track(this.containersCallService.createContainer(containerId, configuration)).pipe(
catchError((err) => {
console.error('Create container failed', err);
return throwError(() => err);
@ -134,7 +60,7 @@ export class ContainersService {
}
validateContainerSubflow(subFlow: FlowData, validationUrl?: string | null) {
return this.containersCallService.validateContainerSubflow(this.deepClone(subFlow), validationUrl).pipe(
return this.containersCallService.validateContainerSubflow(deepClone(subFlow), validationUrl).pipe(
catchError((err) => {
console.error('Validate container subflow failed', err);
return throwError(() => err);
@ -142,28 +68,11 @@ export class ContainersService {
);
}
private clearEmptyContainerCache() {
protected fetchAll(): Observable<BlockType[]> {
return this.containersCallService.retrieveAllContainerTypes();
}
protected override onLoaded(): void {
this.emptyContainerCache.clear();
this.pendingEmptyContainerRequests.clear();
}
private cloneEmptyNode(node: FlowNode): FlowNode {
const clone = this.deepClone(node);
return {
...clone,
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
position: undefined
};
}
private deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
try {
return globalThis.structuredClone(value);
} catch {
// Some cached payloads may carry non-cloneable runtime fields.
}
}
return JSON.parse(JSON.stringify(value)) as T;
}
}

View File

@ -0,0 +1,56 @@
import { TestBed } from '@angular/core/testing';
import { catchError, of } from 'rxjs';
import { vi } from 'vitest';
import { Authorization } from '@services/authorization/authorization';
import { FlowsCallServiceFake } from './flows-call.fake';
describe('FlowsCallServiceFake', () => {
let service: FlowsCallServiceFake;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: Authorization, useValue: { loggedInUser: vi.fn().mockReturnValue({ username: 'Alice', email: null, role: 'USER' }) } }
]
});
service = TestBed.runInInjectionContext(() => new FlowsCallServiceFake());
});
it('surfaces "flow not found" as an observable error catchError can intercept, not a synchronous throw', async () => {
let caught: unknown = null;
await new Promise<void>((resolve) => {
service.getFlowById('missing-flow').pipe(
catchError((error) => {
caught = error;
return of(null);
})
).subscribe(() => resolve());
});
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toContain('missing-flow');
});
it('surfaces "flow is finalized" on updateFlow as an observable error, not a synchronous throw', async () => {
const finalizedFlow = {
id: '1', name: 'A Flow', data: { blocks: [], containers: [], connections: [], dependencies: [] },
visibility: 'PUBLIC' as const, author: 'Alice', createdAt: new Date(), status: 'EXECUTABLE' as const,
updatedAt: new Date(), finalized: true
};
expect(() => service.updateFlow(finalizedFlow)).not.toThrow();
let caught: unknown = null;
await new Promise<void>((resolve) => {
service.updateFlow(finalizedFlow).pipe(
catchError((error) => {
caught = error;
return of(null);
})
).subscribe(() => resolve());
});
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toBe('Flow is finalized');
});
});

View File

@ -1,6 +1,6 @@
import { Flow, FlowValidationError } from "@models/flow";
import { FlowsCallServiceBase } from "./flows-call.base";
import { Observable, of } from "rxjs";
import { defer, Observable, of } from "rxjs";
import { Authorization } from "@services/authorization/authorization";
import { inject } from "@angular/core";
import { flowFromApi } from "./flow-mapper";
@ -25,7 +25,7 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
}
override getFlowById(flowId: string): Observable<Flow> {
return of(this.requireFlow(flowId));
return defer(() => of(this.requireFlow(flowId)));
}
authorizationService = inject(Authorization);
@ -41,11 +41,13 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
}
override updateFlow(flow: Flow) {
if (flow.finalized) {
throw new Error('Flow is finalized');
}
this.data[flow.id] = flow;
return of(flow);
return defer(() => {
if (flow.finalized) {
throw new Error('Flow is finalized');
}
this.data[flow.id] = flow;
return of(flow);
});
}
override createFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'>): Observable<Flow> {
@ -75,44 +77,50 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
}
override deleteFlow(flowId: string): Observable<void> {
const flow = this.requireFlow(flowId);
if (flow.finalized) {
throw new Error('Flow is finalized');
}
delete this.data[flowId];
return of(void 0);
return defer(() => {
const flow = this.requireFlow(flowId);
if (flow.finalized) {
throw new Error('Flow is finalized');
}
delete this.data[flowId];
return of(void 0);
});
}
override updatePublished(flowId: string, value: boolean): Observable<Flow> {
const flow = this.requireFlow(flowId);
this.requireOwner(flow);
const updated = {
...flow,
published: value,
visibility: value ? 'PUBLIC' : 'PRIVATE',
updatedAt: new Date()
} satisfies Flow;
this.data[flowId] = updated;
return of(updated);
return defer(() => {
const flow = this.requireFlow(flowId);
this.requireOwner(flow);
const updated = {
...flow,
published: value,
visibility: value ? 'PUBLIC' : 'PRIVATE',
updatedAt: new Date()
} satisfies Flow;
this.data[flowId] = updated;
return of(updated);
});
}
override finalizeFlow(flowId: string): Observable<Flow> {
const flow = this.requireFlow(flowId);
this.requireOwner(flow);
if (flow.finalized) {
return of(flow);
}
const updated = {
...flow,
finalized: true,
updatedAt: new Date()
} satisfies Flow;
this.data[flowId] = updated;
return of(updated);
return defer(() => {
const flow = this.requireFlow(flowId);
this.requireOwner(flow);
if (flow.finalized) {
return of(flow);
}
const updated = {
...flow,
finalized: true,
updatedAt: new Date()
} satisfies Flow;
this.data[flowId] = updated;
return of(updated);
});
}
override getFlowValidation(flowId: string): Observable<FlowValidationError[]> {
return of(this.requireFlow(flowId).validationErrors ?? []);
return defer(() => of(this.requireFlow(flowId).validationErrors ?? []));
}
}
const testDataFlow ={

View File

@ -0,0 +1,96 @@
import { Signal, signal } from '@angular/core';
import { firstValueFrom, Observable } from 'rxjs';
/**
* Shared loading/caching state machine for a "type catalog" (block types,
* container types): a signal holding the last-loaded list, a single in-flight
* load shared across concurrent callers, and automatic retry after a failed
* initial load (a first failed fetch no longer leaves the catalog permanently
* empty — the next call retries instead of silently returning nothing).
*
* Subclasses provide `fetchAll()` (the HTTP call) and the domain-specific
* public method names (`getAllBlocksTypes`, `getAllContainerTypes`, ...) that
* delegate to the protected methods here.
*/
export abstract class CatalogStore<T> {
private toInit = true;
private loadingPromise: Promise<void> | null = null;
private readonly _loading = signal(false);
private readonly _types = signal<T[]>([]);
protected readonly loading = this._loading.asReadonly();
protected readonly types = this._types.asReadonly();
/** Fetches the full catalog from the backend. */
protected abstract fetchAll(): Observable<T[]>;
/** Label used in the `console.error` logged when a fetch fails. */
protected abstract readonly loadErrorLabel: string;
/** Called whenever a fresh catalog is stored, e.g. to invalidate derived caches. */
protected onLoaded(): void {}
protected hasLoadedTypes(): boolean {
return this._types().length > 0 || (!this.toInit && !this.loadingPromise);
}
protected async getAllTypes(): Promise<Signal<T[]>> {
if (this.toInit) {
this.toInit = false;
try {
await this.refresh();
} catch (err) {
this.toInit = true;
throw err;
}
} else if (this.loadingPromise) {
await this.loadingPromise;
}
return this.types;
}
protected async refresh(force = false): Promise<void> {
if (this.loadingPromise && !force) {
return this.loadingPromise;
}
this.loadingPromise = firstValueFrom(this.fetchAll())
.finally(() => {
this._loading.set(false);
})
.then((types) => {
this._types.set(types);
this.onLoaded();
})
.catch((err) => {
console.error(this.loadErrorLabel, err);
throw err;
})
.finally(() => {
this.loadingPromise = null;
});
this._loading.set(true);
return this.loadingPromise;
}
protected async getTypeOrFetch(predicate: (type: T) => boolean): Promise<T | undefined> {
const current = this._types().find(predicate);
if (current) return current;
if (this.loadingPromise) {
await this.loadingPromise;
return this._types().find(predicate);
}
this._loading.set(true);
const types = await firstValueFrom(this.fetchAll()).finally(() => this._loading.set(false));
this._types.set(types);
this.onLoaded();
return types.find(predicate);
}
protected peekType(predicate: (type: T) => boolean): T | null {
return this._types().find(predicate) ?? null;
}
}

View File

@ -0,0 +1,11 @@
/** `structuredClone` with a JSON round-trip fallback for non-cloneable runtime fields. */
export function deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
try {
return globalThis.structuredClone(value);
} catch {
// Some cached payloads may carry non-cloneable runtime fields.
}
}
return JSON.parse(JSON.stringify(value)) as T;
}

View File

@ -0,0 +1,54 @@
import { finalize, map, Observable, of, shareReplay } from 'rxjs';
import { deepClone } from './deep-clone';
/**
* Caches "empty" node templates (an empty block/container fresh from the
* backend) keyed by a cache key (usually the type name), de-duplicating
* concurrent requests for the same key and handing every caller its own
* clone with a fresh id so mutating one instance never leaks into another.
*/
export class EmptyNodeCache<T extends { id?: string; position?: unknown }> {
private readonly cache = new Map<string, T>();
private readonly pendingRequests = new Map<string, Observable<T>>();
getOrCreate(cacheKey: string, request: () => Observable<T>): Observable<T> {
const cached = this.cache.get(cacheKey);
if (cached) {
return of(this.cloneWithNewId(cached));
}
const pending = this.pendingRequests.get(cacheKey);
if (pending) {
return pending.pipe(map((node) => this.cloneWithNewId(node)));
}
const shared = request().pipe(
map((node) => {
this.cache.set(cacheKey, this.cloneWithNewId(node));
return node;
}),
finalize(() => {
this.pendingRequests.delete(cacheKey);
}),
shareReplay(1)
);
this.pendingRequests.set(cacheKey, shared);
return shared.pipe(map((node) => this.cloneWithNewId(node)));
}
clear(): void {
this.cache.clear();
this.pendingRequests.clear();
}
private cloneWithNewId(node: T): T {
const clone = deepClone(node);
return {
...clone,
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
position: undefined
};
}
}

View File

@ -0,0 +1,113 @@
import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from './flow-node-mapping';
describe('toRecord', () => {
it('returns the object as-is', () => {
expect(toRecord({ a: 1 })).toEqual({ a: 1 });
});
it('returns an empty object for arrays, null, primitives', () => {
expect(toRecord([1, 2])).toEqual({});
expect(toRecord(null)).toEqual({});
expect(toRecord('x')).toEqual({});
});
});
describe('toNullableString', () => {
it('passes through non-empty strings and nulls everything else', () => {
expect(toNullableString('hi')).toBe('hi');
expect(toNullableString('')).toBeNull();
expect(toNullableString(42)).toBeNull();
expect(toNullableString(null)).toBeNull();
});
});
describe('toApiPath', () => {
it('passes absolute http(s) URLs through unchanged', () => {
expect(toApiPath('https://example.com/x')).toBe('https://example.com/x');
});
it('prefixes a relative path with the API base URL', () => {
expect(toApiPath('/retriever/x')).toMatch(/\/retriever\/x$/);
expect(toApiPath('retriever/x')).toMatch(/\/retriever\/x$/);
});
it('returns null for anything that is not a non-empty string', () => {
expect(toApiPath('')).toBeNull();
expect(toApiPath(null)).toBeNull();
});
});
describe('toPosition', () => {
it('reads a valid {x,y} pair', () => {
expect(toPosition({ x: 1, y: 2 })).toEqual({ x: 1, y: 2 });
});
it('returns undefined when x/y are missing or not numeric', () => {
expect(toPosition({ x: 1 })).toBeUndefined();
expect(toPosition({ x: '1', y: 2 })).toBeUndefined();
expect(toPosition(null)).toBeUndefined();
});
});
describe('toSchema', () => {
it('accepts a plain object and rejects arrays/primitives/null', () => {
expect(toSchema({ type: 'object' })).toEqual({ type: 'object' });
expect(toSchema([1])).toBeNull();
expect(toSchema('x')).toBeNull();
expect(toSchema(null)).toBeNull();
});
});
describe('attachSharedDefinitions', () => {
it('returns null when there is no schema', () => {
expect(attachSharedDefinitions(null, { a: 1 })).toBeNull();
});
it('returns the schema unchanged when there are no shared definitions', () => {
const schema = { type: 'object' };
expect(attachSharedDefinitions(schema, null)).toBe(schema);
expect(attachSharedDefinitions(schema, {})).toBe(schema);
});
it('merges shared definitions into the schema, letting schema-local ones win', () => {
const schema = { type: 'object', sharedDefinitions: { a: 'local' } };
expect(attachSharedDefinitions(schema, { a: 'shared', b: 'shared' })).toEqual({
type: 'object',
sharedDefinitions: { a: 'local', b: 'shared' }
});
});
});
describe('toValueKinds', () => {
it('falls back to a single kind when raw is not an array', () => {
expect(toValueKinds(null, { type: 'TEXT', multiple: false })).toEqual([{ type: 'TEXT', multiple: false }]);
});
it('maps well-formed entries and drops entries without a string type', () => {
expect(toValueKinds([{ type: 'FILE', multiple: true }, { multiple: true }], { type: 'TEXT', multiple: false }))
.toEqual([{ type: 'FILE', multiple: true }]);
});
it('falls back when the array yields no usable entries', () => {
expect(toValueKinds([{ multiple: true }], { type: 'TEXT', multiple: false })).toEqual([{ type: 'TEXT', multiple: false }]);
});
});
describe('toPorts', () => {
it('maps named ports and derives valueKinds from type/multiple', () => {
expect(toPorts([{ name: 'input', type: 'FILE', multiple: true }])).toEqual([
{ name: 'input', type: 'FILE', multiple: true, valueKinds: [{ type: 'FILE', multiple: true }] }
]);
});
it('drops ports without a usable name', () => {
expect(toPorts([{ type: 'TEXT' }])).toEqual([]);
});
it('returns the fallback (default []) when raw is not an array', () => {
expect(toPorts(null)).toEqual([]);
expect(toPorts(null, [{ name: 'fallback', type: 'TEXT', multiple: false }])).toEqual([
{ name: 'fallback', type: 'TEXT', multiple: false }
]);
});
});

View File

@ -0,0 +1,89 @@
import { environment } from '@environment';
/**
* Response-mapping helpers shared by `blocks-call.ts` and `containers-call.ts`
* (and their `.fake.ts` counterparts): both map the same wire shape — ports,
* value kinds, position, JSON-schema — into the app's `FlowBlock`/`FlowNode`
* domain types.
*/
export function toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
export function toNullableString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
export function toApiPath(value: unknown): string | null {
if (typeof value !== 'string' || value.length === 0) return null;
if (/^https?:\/\//.test(value)) return value;
return `${environment.apiUrl}${value.startsWith('/') ? value : `/${value}`}`;
}
export function toPosition(raw: unknown): { x: number; y: number } | undefined {
const value = toRecord(raw);
const x = value['x'];
const y = value['y'];
if (typeof x !== 'number' || typeof y !== 'number') return undefined;
return { x, y };
}
export function toSchema(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
return raw as Record<string, unknown>;
}
export function attachSharedDefinitions(
schema: Record<string, unknown> | null,
sharedDefinitions: Record<string, unknown> | null
): Record<string, unknown> | null {
if (!schema) return null;
if (!sharedDefinitions || !Object.keys(sharedDefinitions).length) return schema;
return {
...schema,
sharedDefinitions: {
...sharedDefinitions,
...toRecord(schema['sharedDefinitions'])
}
};
}
export function toValueKinds(raw: unknown, fallback: { type: string; multiple: boolean }): Array<{ type: string; multiple: boolean }> {
if (!Array.isArray(raw)) {
return [{ type: fallback.type, multiple: fallback.multiple }];
}
const kinds = raw
.map((item) => toRecord(item))
.filter((item) => typeof item['type'] === 'string')
.map((item) => ({
type: String(item['type'] ?? fallback.type),
multiple: Boolean(item['multiple'] ?? false)
}));
return kinds.length ? kinds : [{ type: fallback.type, multiple: fallback.multiple }];
}
export function toPorts(
raw: unknown,
fallback: Array<{ name: string; type: string; multiple: boolean }> = []
) {
if (!Array.isArray(raw)) return fallback;
return raw
.map((port) => toRecord(port))
.filter((port) => typeof port['name'] === 'string' && (port['name'] as string).length > 0)
.map((port) => {
const type = String(port['type'] ?? 'TEXT');
const multiple = Boolean(port['multiple'] ?? false);
return {
...port,
name: String(port['name']),
type,
multiple,
valueKinds: toValueKinds(port['valueKinds'], { type, multiple })
};
});
}

View File

@ -0,0 +1,46 @@
import { HttpErrorResponse } from '@angular/common/http';
import { lastValueFrom } from 'rxjs';
import { extractHttpErrorMessage, toHttpError } from './http-error.util';
describe('extractHttpErrorMessage', () => {
it('reads a plain string error body', () => {
const error = new HttpErrorResponse({ error: 'Something broke', status: 400 });
expect(extractHttpErrorMessage(error)).toBe('Something broke');
});
it('prefers message, then error, then details from an object body', () => {
expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { message: 'msg' }, status: 400 }))).toBe('msg');
expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { error: 'err' }, status: 400 }))).toBe('err');
expect(extractHttpErrorMessage(new HttpErrorResponse({ error: { details: 'det' }, status: 400 }))).toBe('det');
});
it('returns null when nothing usable is present', () => {
expect(extractHttpErrorMessage(new HttpErrorResponse({ error: {}, status: 500 }))).toBeNull();
expect(extractHttpErrorMessage(new HttpErrorResponse({ error: null, status: 500 }))).toBeNull();
});
});
describe('toHttpError', () => {
it('prefers the backend message over the status fallback', async () => {
const error = new HttpErrorResponse({ error: { message: 'Backend said no' }, status: 400 });
await expect(lastValueFrom(toHttpError(error, { 400: 'Fallback message' }))).rejects.toThrow('Backend said no');
});
it('falls back to the status-keyed message when the backend gives nothing usable', async () => {
const error = new HttpErrorResponse({ error: {}, status: 403 });
await expect(lastValueFrom(toHttpError(error, { 403: 'Admin access required.' }))).rejects.toThrow('Admin access required.');
});
it('falls back to a generic message when the status is not mapped', async () => {
const error = new HttpErrorResponse({ error: {}, status: 418 });
await expect(lastValueFrom(toHttpError(error, {}))).rejects.toThrow('Request failed.');
});
it('passes an existing Error straight through', async () => {
await expect(lastValueFrom(toHttpError(new Error('boom'), {}))).rejects.toThrow('boom');
});
it('wraps a non-Error, non-HttpErrorResponse value in a generic error', async () => {
await expect(lastValueFrom(toHttpError('a raw string', {}))).rejects.toThrow('Request failed.');
});
});

View File

@ -0,0 +1,47 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
/**
* Reads a human-readable message out of a backend error body, trying the
* conventional `message`/`error`/`details` string fields in that order.
*/
export function extractHttpErrorMessage(error: HttpErrorResponse): string | null {
const payload = error.error;
if (typeof payload === 'string' && payload.trim().length > 0) {
return payload.trim();
}
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>;
const directMessage = record['message'];
if (typeof directMessage === 'string' && directMessage.trim().length > 0) {
return directMessage.trim();
}
const errorMessage = record['error'];
if (typeof errorMessage === 'string' && errorMessage.trim().length > 0) {
return errorMessage.trim();
}
const details = record['details'];
if (typeof details === 'string' && details.trim().length > 0) {
return details.trim();
}
}
return null;
}
/**
* Converts any thrown/caught value into an `Observable` error carrying a
* human-readable `Error`: prefers the backend's own message, falls back to
* a status-keyed message, then a generic one. Existing `Error`s pass through.
*/
export function toHttpError(error: unknown, fallbackByStatus: Record<number, string>): Observable<never> {
if (error instanceof HttpErrorResponse) {
const message = extractHttpErrorMessage(error)
?? fallbackByStatus[error.status]
?? 'Request failed.';
return throwError(() => new Error(message));
}
if (error instanceof Error) {
return throwError(() => error);
}
return throwError(() => new Error('Request failed.'));
}

View File

@ -0,0 +1,15 @@
import { computed, signal } from '@angular/core';
import { finalize, Observable } from 'rxjs';
/** Tracks how many "sync this to the server" requests are currently in flight. */
export class PendingSyncCounter {
private readonly count = signal(0);
readonly active = computed(() => this.count() > 0);
track<T>(source: Observable<T>): Observable<T> {
this.count.update((current) => current + 1);
return source.pipe(
finalize(() => this.count.update((current) => Math.max(0, current - 1)))
);
}
}

View File

@ -1,94 +1 @@
.admin-reset-password-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.5);
}
.admin-reset-password-modal {
width: min(520px, calc(100vw - 32px));
max-height: min(80vh, 720px);
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
padding: 24px;
}
.admin-reset-password-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.admin-reset-password-title {
margin: 0;
color: #0f172a;
font-size: 20px;
font-weight: 700;
}
.admin-reset-password-subtitle {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
}
.admin-reset-password-form {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 20px;
}
.admin-reset-password-field {
width: 100%;
}
.admin-reset-password-checklist {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: -6px;
padding: 0 2px;
}
.admin-reset-password-check {
display: flex;
align-items: center;
gap: 8px;
color: #b91c1c;
font-size: 12px;
}
.admin-reset-password-check--ok {
color: #15803d;
}
.admin-reset-password-check-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.admin-reset-password-error {
border: 1px solid #fecaca;
border-radius: 12px;
background: #fff1f2;
color: #b91c1c;
font-size: 13px;
padding: 10px 12px;
}
.admin-reset-password-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
@import '../password-dialog-chrome.css';

View File

@ -1,15 +1,15 @@
<div class="admin-reset-password-backdrop" (click)="close($event)">
<div class="admin-reset-password-modal" (click)="$event.stopPropagation()">
<div class="admin-reset-password-header">
<div class="password-dialog-backdrop" (click)="close($event)">
<div class="password-dialog-modal" (click)="$event.stopPropagation()">
<div class="password-dialog-header">
<div>
<h3 class="admin-reset-password-title">Reset Password</h3>
<p class="admin-reset-password-subtitle">{{ username() }}</p>
<h3 class="password-dialog-title">Reset Password</h3>
<p class="password-dialog-subtitle">{{ username() }}</p>
</div>
<button type="button" mat-stroked-button (click)="close($event)">Close</button>
</div>
<form (ngSubmit)="submit($event)" class="admin-reset-password-form">
<mat-form-field appearance="outline" class="admin-reset-password-field">
<form (ngSubmit)="submit($event)" class="password-dialog-form">
<mat-form-field appearance="outline" class="password-dialog-field">
<mat-label>New password</mat-label>
<input
matInput
@ -20,16 +20,16 @@
</button>
</mat-form-field>
<div class="admin-reset-password-checklist">
<div class="password-dialog-checklist">
@for (check of newPasswordChecks(); track check.label) {
<div class="admin-reset-password-check" [class.admin-reset-password-check--ok]="check.satisfied">
<mat-icon class="admin-reset-password-check-icon" [fontIcon]="check.satisfied ? 'check_circle' : 'radio_button_unchecked'"></mat-icon>
<div class="password-dialog-check" [class.password-dialog-check--ok]="check.satisfied">
<mat-icon class="password-dialog-check-icon" [fontIcon]="check.satisfied ? 'check_circle' : 'radio_button_unchecked'"></mat-icon>
<span>{{ check.label }}</span>
</div>
}
</div>
<mat-form-field appearance="outline" class="admin-reset-password-field">
<mat-form-field appearance="outline" class="password-dialog-field">
<mat-label>Confirm new password</mat-label>
<input
matInput
@ -44,10 +44,10 @@
</mat-form-field>
@if (submitError()) {
<div class="admin-reset-password-error" role="alert">{{ submitError() }}</div>
<div class="password-dialog-error" role="alert">{{ submitError() }}</div>
}
<div class="admin-reset-password-actions">
<div class="password-dialog-actions">
<button type="button" mat-stroked-button (click)="close($event)" [disabled]="saving()">Cancel</button>
<button type="submit" mat-flat-button [disabled]="!canSubmit()">
@if (saving()) {

View File

@ -1,12 +1,13 @@
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Field, form, minLength, required, validate } from '@angular/forms/signals';
import { Field, form } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { FormUtility } from '@utilities/form-utility';
import { hasValidPasswordComplexity, evaluatePasswordChecks, PASSWORD_MIN_LENGTH } from '@utilities/password-validation';
import { evaluatePasswordChecks } from '@utilities/password-validation';
import { applyConfirmPasswordValidators, applyNewPasswordValidators } from '@utilities/password-form-validators';
@Component({
selector: 'app-admin-reset-password-dialog',
@ -32,27 +33,8 @@ export class AdminResetPasswordDialogComponent extends FormUtility {
});
readonly passwordForm = form(this.model, (model) => {
required(model.newPassword, { message: 'New password is required' });
minLength(model.newPassword, PASSWORD_MIN_LENGTH, { message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` });
validate(model.newPassword, ({ value }) => {
const password = value();
if (!password || hasValidPasswordComplexity(password)) return null;
return {
kind: 'passwordComplexity',
message: 'Password must include uppercase, lowercase, number and special character, with no spaces.'
};
});
required(model.confirmNewPassword, { message: 'Confirm your new password' });
validate(model.confirmNewPassword, ({ value, valueOf }) => {
if (value() !== valueOf(model.newPassword)) {
return {
kind: 'passwordMismatch',
message: 'Passwords do not match'
};
}
return null;
});
applyNewPasswordValidators(model.newPassword);
applyConfirmPasswordValidators(model.confirmNewPassword, model.newPassword);
});
readonly canSubmit = computed(() => !this.passwordForm().invalid() && !this.saving());

View File

@ -1,9 +1,3 @@
.bias-compare-dialog__backdrop { background: rgb(0 0 0 / .5); inset: 0; position: fixed; z-index: 10020; }
.bias-compare-dialog { background: #fff; border: 1px solid #cbd5e1; border-radius: 1rem; box-shadow: 0 25px 50px rgb(15 23 42 / .25); display: flex; flex-direction: column; left: 50%; max-height: min(88vh, 800px); max-width: 760px; position: fixed; top: 50%; transform: translate(-50%, -50%); width: min(94vw, 760px); z-index: 10021; }
header { align-items: center; border-bottom: 1px solid #e2e8f0; display: flex; gap: 1rem; justify-content: space-between; padding: 1rem 1.25rem; }
header h3, header p { margin: 0; }
header p { color: #64748b; font-size: .85rem; }
.bias-compare-dialog__content { display: grid; gap: .9rem; overflow: auto; padding: 1.25rem; }
.bias-compare-dialog__progress { background: #eff6ff; border-left: 3px solid #2563eb; color: #1e3a8a; margin: 0; padding: .6rem .7rem; }
.bias-compare-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; }
.bias-compare-dialog__error-row { align-items: center; display: flex; gap: .6rem; justify-content: space-between; }

View File

@ -1,23 +1,22 @@
@if (state()) {
<div class="bias-compare-dialog__backdrop" (click)="close()"></div>
<section class="bias-compare-dialog" role="dialog" aria-modal="true" aria-label="Compare with baseline">
<header>
<div><h3>Compare with baseline</h3><p>Baseline vs. biased execution outcome</p></div>
<button type="button" mat-stroked-button (click)="close()">Close</button>
</header>
<div class="bias-compare-dialog__content">
@if (loading()) {
<p class="bias-compare-dialog__progress">Comparing executions…</p>
}
@if (inlineError(); as error) {
<div class="bias-compare-dialog__error-row">
<p class="bias-compare-dialog__error">{{ error }}</p>
<button type="button" mat-stroked-button (click)="retry()">Retry</button>
</div>
}
@if (report(); as completedReport) {
<app-bias-impact-report-viewer [report]="completedReport" (highlightOnCanvas)="highlightOnCanvas()" />
}
</div>
</section>
<app-modal-shell
title="Compare with baseline"
subtitle="Baseline vs. biased execution outcome"
ariaLabel="Compare with baseline"
maxWidth="760px"
(backdropClick)="close()"
(closeClick)="close()">
@if (loading()) {
<p class="bias-compare-dialog__progress">Comparing executions…</p>
}
@if (inlineError(); as error) {
<div class="bias-compare-dialog__error-row">
<p class="bias-compare-dialog__error">{{ error }}</p>
<button type="button" mat-stroked-button (click)="retry()">Retry</button>
</div>
}
@if (report(); as completedReport) {
<app-bias-impact-report-viewer [report]="completedReport" (highlightOnCanvas)="highlightOnCanvas()" />
}
</app-modal-shell>
}

View File

@ -1,15 +1,17 @@
import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { BiasImpactReportViewerComponent } from '@shared/bias-impact-report-viewer/bias-impact-report-viewer';
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
import { BiasCompareDialogService } from '@services/dialogs/bias-compare-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { extractBiasErrorMessage } from '@services/bias/bias-error.util';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactReport } from '@models/bias-impact';
@Component({
selector: 'app-bias-compare-dialog-host',
standalone: true,
imports: [MatButtonModule, BiasImpactReportViewerComponent],
imports: [MatButtonModule, BiasImpactReportViewerComponent, ModalShellComponent],
templateUrl: './bias-compare-dialog.html',
styleUrl: './bias-compare-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -63,22 +65,8 @@ export class BiasCompareDialogHostComponent {
},
error: (error) => {
this.loading.set(false);
this.inlineError.set(this.extractErrorMessage(error));
this.inlineError.set(extractBiasErrorMessage(error, 'Unable to compare the baseline and biased executions.'));
}
});
}
private extractErrorMessage(error: unknown): string {
const body = (error as { error?: unknown })?.error;
if (body && typeof body === 'object') {
const record = body as Record<string, unknown>;
const errors = Array.isArray(record['errors']) ? record['errors'] : [];
const first = errors[0];
if (first && typeof first === 'object' && typeof (first as Record<string, unknown>)['message'] === 'string') {
return (first as Record<string, unknown>)['message'] as string;
}
if (typeof record['detail'] === 'string' && record['detail']) return record['detail'];
}
return error instanceof Error ? error.message : 'Unable to compare the baseline and biased executions.';
}
}

View File

@ -1,7 +1,6 @@
.bias-experiment-dialog__backdrop { background: rgb(0 0 0 / .5); inset: 0; position: fixed; z-index: 10020; }
.bias-experiment-dialog { background: #fff; border: 1px solid #cbd5e1; border-radius: 1rem; box-shadow: 0 25px 50px rgb(15 23 42 / .25); display: flex; flex-direction: column; left: 50%; max-height: min(88vh, 800px); max-width: 680px; position: fixed; top: 50%; transform: translate(-50%, -50%); width: min(94vw, 680px); z-index: 10021; }
header, footer { align-items: center; display: flex; gap: 1rem; justify-content: space-between; padding: 1rem 1.25rem; } header { border-bottom: 1px solid #e2e8f0; } header h3, header p { margin: 0; } header p { color: #64748b; font-size: .85rem; }
.bias-experiment-dialog__content { display: grid; gap: .9rem; overflow: auto; padding: 1.25rem; } .bias-experiment-dialog__content > p { margin: 0; }
fieldset { display: grid; gap: .5rem; } fieldset label, .bias-experiment-dialog__content > label { color: #334155; font-size: .87rem; }
p { margin: 0; }
fieldset { display: grid; gap: .5rem; }
label { color: #334155; font-size: .87rem; }
.bias-experiment-dialog__progress { background: #eff6ff; border-left: 3px solid #2563eb; color: #1e3a8a; padding: .6rem .7rem; }
.bias-experiment-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; } footer { border-top: 1px solid #e2e8f0; justify-content: flex-end; }
.bias-experiment-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; }
footer { align-items: center; border-top: 1px solid #e2e8f0; display: flex; gap: 1rem; justify-content: flex-end; padding: 1rem 1.25rem; }

View File

@ -1,24 +1,27 @@
@if (state(); as currentState) {
<div class="bias-experiment-dialog__backdrop" (click)="close()"></div>
<section class="bias-experiment-dialog" role="dialog" aria-modal="true" aria-label="Measure bias impact">
<header><div><h3>Measure bias impact</h3><p>{{ currentState.nodeName }}</p></div><button type="button" mat-stroked-button (click)="close()">Close</button></header>
<app-modal-shell
title="Measure bias impact"
[subtitle]="currentState.nodeName"
ariaLabel="Measure bias impact"
(backdropClick)="close()"
(closeClick)="close()">
@if (report(); as completedReport) {
<div class="bias-experiment-dialog__content"><app-bias-impact-report-viewer [report]="completedReport" (highlightOnCanvas)="highlightOnCanvas()" /></div>
<app-bias-impact-report-viewer [report]="completedReport" (highlightOnCanvas)="highlightOnCanvas()" />
} @else {
<div class="bias-experiment-dialog__content">
<p>Run the selected probes against this completed execution step.</p>
<fieldset [disabled]="submitting() || !!currentJob()"><legend>Executable annotations</legend>
@for (annotation of currentState.annotations; track annotation.id) {
<label><input type="checkbox" [checked]="selectedAnnotationIds().includes($any(annotation.id))" (change)="toggleAnnotation($any(annotation.id), $any($event.target).checked)"> {{ annotation.category || 'Uncategorized' }} — {{ annotation.issue || annotation.rationale || annotation.id }}</label>
}
</fieldset>
<label>Repetitions <input type="number" min="1" max="10" [ngModel]="repetitions()" (ngModelChange)="updateRepetitions($event)" [disabled]="submitting() || !!currentJob()"></label>
<label><input type="checkbox" [ngModel]="includeRawOutputs()" (ngModelChange)="includeRawOutputs.set($event)" [disabled]="submitting() || !!currentJob()"> Include raw outputs in the report</label>
<app-side-effect-policy-selector [policy]="policy()" [externalSideEffects]="currentState.capabilities.externalSideEffects" [disabled]="submitting() || !!currentJob()" (policyChange)="policy.set($event)" />
@if (currentJob(); as job) { <p class="bias-experiment-dialog__progress">{{ job.status === 'QUEUED' ? 'Experiment queued…' : 'Experiment running…' }}</p> }
@if (inlineError(); as error) { <p class="bias-experiment-dialog__error">{{ error }}</p> }
</div>
<footer><button type="button" mat-stroked-button (click)="close()">Cancel</button><button type="button" mat-flat-button [disabled]="submitting() || !!currentJob()" (click)="submit()">{{ submitting() || currentJob() ? 'Measuring…' : 'Measure impact' }}</button></footer>
<p>Run the selected probes against this completed execution step.</p>
<fieldset [disabled]="submitting() || !!currentJob()"><legend>Executable annotations</legend>
@for (annotation of currentState.annotations; track annotation.id) {
<label><input type="checkbox" [checked]="selectedAnnotationIds().includes($any(annotation.id))" (change)="toggleAnnotation($any(annotation.id), $any($event.target).checked)"> {{ annotation.category || 'Uncategorized' }} — {{ annotation.issue || annotation.rationale || annotation.id }}</label>
}
</fieldset>
<label>Repetitions <input type="number" min="1" max="10" [ngModel]="repetitions()" (ngModelChange)="updateRepetitions($event)" [disabled]="submitting() || !!currentJob()"></label>
<label><input type="checkbox" [ngModel]="includeRawOutputs()" (ngModelChange)="includeRawOutputs.set($event)" [disabled]="submitting() || !!currentJob()"> Include raw outputs in the report</label>
<app-side-effect-policy-selector [policy]="policy()" [externalSideEffects]="currentState.capabilities.externalSideEffects" [disabled]="submitting() || !!currentJob()" (policyChange)="policy.set($event)" />
@if (currentJob(); as job) { <p class="bias-experiment-dialog__progress">{{ job.status === 'QUEUED' ? 'Experiment queued…' : 'Experiment running…' }}</p> }
@if (inlineError(); as error) { <p class="bias-experiment-dialog__error">{{ error }}</p> }
}
</section>
@if (!report()) {
<footer><button type="button" mat-stroked-button (click)="close()">Cancel</button><button type="button" mat-flat-button [disabled]="submitting() || !!currentJob()" (click)="submit()">{{ submitting() || currentJob() ? 'Measuring…' : 'Measure impact' }}</button></footer>
}
</app-modal-shell>
}

View File

@ -3,18 +3,20 @@ import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { BiasImpactReportViewerComponent } from '@shared/bias-impact-report-viewer/bias-impact-report-viewer';
import { SideEffectPolicySelectorComponent } from '@shared/side-effect-policy-selector/side-effect-policy-selector';
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { extractBiasErrorMessage } from '@services/bias/bias-error.util';
import { NotificationService } from '@services/notifications/notification';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactJob, BiasImpactReport, BiasSideEffectError, ExternalSideEffectPolicy } from '@models/bias-impact';
import { BiasImpactJob, BiasImpactReport, ExternalSideEffectPolicy } from '@models/bias-impact';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-bias-impact-experiment-dialog-host',
standalone: true,
imports: [FormsModule, MatButtonModule, BiasImpactReportViewerComponent, SideEffectPolicySelectorComponent],
imports: [FormsModule, MatButtonModule, BiasImpactReportViewerComponent, SideEffectPolicySelectorComponent, ModalShellComponent],
templateUrl: './bias-impact-experiment-dialog.html',
styleUrl: './bias-impact-experiment-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -116,25 +118,20 @@ export class BiasImpactExperimentDialogHostComponent {
error: (error) => {
this.submitting.set(false);
this.currentJob.set(null);
this.inlineError.set(error instanceof Error ? error.message : 'Unable to retrieve the experiment status.');
this.inlineError.set(extractBiasErrorMessage(error, 'Unable to retrieve the experiment status.'));
}
});
}
private handleInitialError(error: unknown) {
this.submitting.set(false);
const sideEffectError = error as Partial<BiasSideEffectError>;
if (sideEffectError.reason === 'SIDE_EFFECT_BLOCKED' || sideEffectError.reason === 'CONFIRMATION_REQUIRED') {
this.inlineError.set(sideEffectError.message ?? 'External side effects require a different policy.');
return;
}
const status = (error as { status?: number })?.status;
if (status === 404 || status === 400) {
this.notifications.show(error instanceof Error ? error.message : 'The execution is no longer eligible for this experiment.', 'error');
this.notifications.show(extractBiasErrorMessage(error, 'The execution is no longer eligible for this experiment.'), 'error');
this.close();
return;
}
this.inlineError.set(error instanceof Error ? error.message : 'Unable to start the bias impact experiment.');
this.inlineError.set(extractBiasErrorMessage(error, 'Unable to start the bias impact experiment.'));
}
private cancelPolling() {

View File

@ -1,5 +1,4 @@
.bias-rerun-dialog__backdrop { background: rgb(0 0 0 / .5); inset: 0; position: fixed; z-index: 10020; }
.bias-rerun-dialog { background: #fff; border: 1px solid #cbd5e1; border-radius: 1rem; box-shadow: 0 25px 50px rgb(15 23 42 / .25); display: flex; flex-direction: column; left: 50%; max-height: min(88vh, 800px); max-width: 680px; position: fixed; top: 50%; transform: translate(-50%, -50%); width: min(94vw, 680px); z-index: 10021; }
header, footer { align-items: center; display: flex; gap: 1rem; justify-content: space-between; padding: 1rem 1.25rem; } header { border-bottom: 1px solid #e2e8f0; } h3, p { margin: 0; } header p { color: #64748b; font-size: .85rem; }
.bias-rerun-dialog__content { display: grid; gap: .9rem; overflow: auto; padding: 1.25rem; } fieldset { display: grid; gap: .5rem; } label { color: #334155; font-size: .87rem; }
.bias-rerun-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; padding: .6rem .7rem; } footer { border-top: 1px solid #e2e8f0; justify-content: flex-end; }
fieldset { display: grid; gap: .5rem; }
label { color: #334155; font-size: .87rem; }
.bias-rerun-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; padding: .6rem .7rem; }
footer { align-items: center; border-top: 1px solid #e2e8f0; display: flex; gap: 1rem; justify-content: flex-end; padding: 1rem 1.25rem; }

View File

@ -1,18 +1,19 @@
@if (state(); as currentState) {
<div class="bias-rerun-dialog__backdrop" (click)="close()"></div>
<section class="bias-rerun-dialog" role="dialog" aria-modal="true" aria-label="Create biased rerun">
<header><div><h3>Create biased rerun</h3><p>Select the executable annotations to activate for the full flow.</p></div><button type="button" mat-stroked-button (click)="close()">Close</button></header>
<div class="bias-rerun-dialog__content">
@for (candidate of currentState.candidates; track candidate.nodeId) {
<fieldset [disabled]="creating()"><legend>{{ candidate.nodeName }}</legend>
@for (annotation of candidate.annotations; track annotation.id) {
<label><input type="checkbox" [checked]="selectedIds(candidate.nodeId).includes($any(annotation.id))" (change)="toggleAnnotation(candidate.nodeId, $any(annotation.id), $any($event.target).checked)"> {{ annotation.category || 'Uncategorized' }} — {{ annotation.issue || annotation.rationale || annotation.id }}</label>
}
</fieldset>
}
<app-side-effect-policy-selector [policy]="policy()" [externalSideEffects]="hasExternalSideEffects()" [disabled]="creating()" (policyChange)="policy.set($event)" />
@if (inlineError(); as error) { <p class="bias-rerun-dialog__error">{{ error }}</p> }
</div>
<app-modal-shell
title="Create biased rerun"
subtitle="Select the executable annotations to activate for the full flow."
ariaLabel="Create biased rerun"
(backdropClick)="close()"
(closeClick)="close()">
@for (candidate of currentState.candidates; track candidate.nodeId) {
<fieldset [disabled]="creating()"><legend>{{ candidate.nodeName }}</legend>
@for (annotation of candidate.annotations; track annotation.id) {
<label><input type="checkbox" [checked]="selectedIds(candidate.nodeId).includes($any(annotation.id))" (change)="toggleAnnotation(candidate.nodeId, $any(annotation.id), $any($event.target).checked)"> {{ annotation.category || 'Uncategorized' }} — {{ annotation.issue || annotation.rationale || annotation.id }}</label>
}
</fieldset>
}
<app-side-effect-policy-selector [policy]="policy()" [externalSideEffects]="hasExternalSideEffects()" [disabled]="creating()" (policyChange)="policy.set($event)" />
@if (inlineError(); as error) { <p class="bias-rerun-dialog__error">{{ error }}</p> }
<footer><button type="button" mat-stroked-button (click)="close()">Cancel</button><button type="button" mat-flat-button [disabled]="creating()" (click)="submit()">{{ creating() ? 'Creating…' : 'Create biased rerun' }}</button></footer>
</section>
</app-modal-shell>
}

View File

@ -1,16 +1,18 @@
import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { ExternalSideEffectPolicy, BiasSideEffectError } from '@models/bias-impact';
import { ExternalSideEffectPolicy } from '@models/bias-impact';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { BiasRerunDialogService } from '@services/dialogs/bias-rerun-dialog';
import { extractBiasErrorMessage } from '@services/bias/bias-error.util';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { SideEffectPolicySelectorComponent } from '@shared/side-effect-policy-selector/side-effect-policy-selector';
import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
@Component({
selector: 'app-bias-rerun-dialog-host',
standalone: true,
imports: [FormsModule, MatButtonModule, SideEffectPolicySelectorComponent],
imports: [FormsModule, MatButtonModule, SideEffectPolicySelectorComponent, ModalShellComponent],
templateUrl: './bias-rerun-dialog.html',
styleUrl: './bias-rerun-dialog.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -68,10 +70,7 @@ export class BiasRerunDialogHostComponent {
next: (execution) => { state.onCreated(execution); this.dialog.close(); },
error: (error) => {
this.creating.set(false);
const sideEffectError = error as Partial<BiasSideEffectError>;
this.inlineError.set(sideEffectError.reason === 'SIDE_EFFECT_BLOCKED' || sideEffectError.reason === 'CONFIRMATION_REQUIRED'
? sideEffectError.message ?? 'External side effect policy prevented the rerun.'
: error instanceof Error ? error.message : 'Unable to create the biased rerun.');
this.inlineError.set(extractBiasErrorMessage(error, 'Unable to create the biased rerun.'));
}
});
}

View File

@ -1,94 +1 @@
.change-password-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.5);
}
.change-password-modal {
width: min(520px, calc(100vw - 32px));
max-height: min(80vh, 720px);
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
padding: 24px;
}
.change-password-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.change-password-title {
margin: 0;
color: #0f172a;
font-size: 20px;
font-weight: 700;
}
.change-password-subtitle {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
}
.change-password-form {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 20px;
}
.change-password-field {
width: 100%;
}
.change-password-checklist {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: -6px;
padding: 0 2px;
}
.change-password-check {
display: flex;
align-items: center;
gap: 8px;
color: #b91c1c;
font-size: 12px;
}
.change-password-check--ok {
color: #15803d;
}
.change-password-check-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.change-password-error {
border: 1px solid #fecaca;
border-radius: 12px;
background: #fff1f2;
color: #b91c1c;
font-size: 13px;
padding: 10px 12px;
}
.change-password-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
@import '../password-dialog-chrome.css';

View File

@ -1,15 +1,15 @@
<div class="change-password-backdrop" (click)="close($event)">
<div class="change-password-modal" (click)="$event.stopPropagation()">
<div class="change-password-header">
<div class="password-dialog-backdrop" (click)="close($event)">
<div class="password-dialog-modal" (click)="$event.stopPropagation()">
<div class="password-dialog-header">
<div>
<h3 class="change-password-title">Change Password</h3>
<p class="change-password-subtitle">{{ username() }}</p>
<h3 class="password-dialog-title">Change Password</h3>
<p class="password-dialog-subtitle">{{ username() }}</p>
</div>
<button type="button" mat-stroked-button (click)="close($event)">Close</button>
</div>
<form (ngSubmit)="submit($event)" class="change-password-form">
<mat-form-field appearance="outline" class="change-password-field">
<form (ngSubmit)="submit($event)" class="password-dialog-form">
<mat-form-field appearance="outline" class="password-dialog-field">
<mat-label>Current password</mat-label>
<input
matInput
@ -23,7 +23,7 @@
}
</mat-form-field>
<mat-form-field appearance="outline" class="change-password-field">
<mat-form-field appearance="outline" class="password-dialog-field">
<mat-label>New password</mat-label>
<input
matInput
@ -34,16 +34,16 @@
</button>
</mat-form-field>
<div class="change-password-checklist">
<div class="password-dialog-checklist">
@for (check of newPasswordChecks(); track check.label) {
<div class="change-password-check" [class.change-password-check--ok]="check.satisfied">
<mat-icon class="change-password-check-icon" [fontIcon]="check.satisfied ? 'check_circle' : 'radio_button_unchecked'"></mat-icon>
<div class="password-dialog-check" [class.password-dialog-check--ok]="check.satisfied">
<mat-icon class="password-dialog-check-icon" [fontIcon]="check.satisfied ? 'check_circle' : 'radio_button_unchecked'"></mat-icon>
<span>{{ check.label }}</span>
</div>
}
</div>
<mat-form-field appearance="outline" class="change-password-field">
<mat-form-field appearance="outline" class="password-dialog-field">
<mat-label>Confirm new password</mat-label>
<input
matInput
@ -58,10 +58,10 @@
</mat-form-field>
@if (submitError()) {
<div class="change-password-error" role="alert">{{ submitError() }}</div>
<div class="password-dialog-error" role="alert">{{ submitError() }}</div>
}
<div class="change-password-actions">
<div class="password-dialog-actions">
<button type="button" mat-stroked-button (click)="close($event)" [disabled]="saving()">Cancel</button>
<button type="submit" mat-flat-button [disabled]="!canSubmit()">
@if (saving()) {

View File

@ -1,13 +1,14 @@
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Field, form, minLength, required, validate } from '@angular/forms/signals';
import { Field, form, required } from '@angular/forms/signals';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { ChangePasswordRequest } from '@models/user';
import { FormUtility } from '@utilities/form-utility';
import { hasValidPasswordComplexity, evaluatePasswordChecks, PASSWORD_MIN_LENGTH } from '@utilities/password-validation';
import { evaluatePasswordChecks } from '@utilities/password-validation';
import { applyConfirmPasswordValidators, applyNewPasswordValidators } from '@utilities/password-form-validators';
@Component({
selector: 'app-change-password-dialog',
@ -36,28 +37,8 @@ export class ChangePasswordDialogComponent extends FormUtility {
readonly passwordForm = form(this.model, (model) => {
required(model.oldPassword, { message: 'Current password is required' });
required(model.newPassword, { message: 'New password is required' });
minLength(model.newPassword, PASSWORD_MIN_LENGTH, { message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` });
validate(model.newPassword, ({ value }) => {
const password = value();
if (!password || hasValidPasswordComplexity(password)) return null;
return {
kind: 'passwordComplexity',
message: 'Password must include uppercase, lowercase, number and special character, with no spaces.'
};
});
required(model.confirmNewPassword, { message: 'Confirm your new password' });
validate(model.confirmNewPassword, ({ value, valueOf }) => {
if (value() !== valueOf(model.newPassword)) {
return {
kind: 'passwordMismatch',
message: 'Passwords do not match'
};
}
return null;
});
applyNewPasswordValidators(model.newPassword);
applyConfirmPasswordValidators(model.confirmNewPassword, model.newPassword);
});
readonly canSubmit = computed(() => !this.passwordForm().invalid() && !this.saving());

View File

@ -43,7 +43,6 @@ export class FlowItem {
);
async open() {
console.log('Opening flow:', this.flow());
if (this.editorState.isDirty() && this.openedFlowId() !== this.flow().id) {
const confirmed = await this.confirm.open(
'You have unsaved changes in the current flow. Open another flow anyway?'
@ -57,9 +56,6 @@ export class FlowItem {
clone() {
this.flowsService.cloneFlow(this.flow()).subscribe({
next: clonedFlow => {
console.log('Flow cloned:', clonedFlow);
},
error: err => console.error('Error cloning flow:', err)
});
}
@ -76,7 +72,6 @@ export class FlowItem {
this.flowsService.deleteFlow(this.flow().id).subscribe({
next: () => {
console.log('Flow deleted:', this.flow().id);
if (this.openedFlowId() === this.flow().id) {
this.editorState.closeDocument();
}

View File

@ -0,0 +1,6 @@
.modal-shell__backdrop { background: rgb(0 0 0 / .5); inset: 0; position: fixed; z-index: 10020; }
.modal-shell { background: #fff; border: 1px solid #cbd5e1; border-radius: 1rem; box-shadow: 0 25px 50px rgb(15 23 42 / .25); display: flex; flex-direction: column; left: 50%; max-height: min(88vh, 800px); max-width: var(--modal-shell-max-width, 680px); position: fixed; top: 50%; transform: translate(-50%, -50%); width: min(94vw, var(--modal-shell-max-width, 680px)); z-index: 10021; }
header { align-items: center; border-bottom: 1px solid #e2e8f0; display: flex; gap: 1rem; justify-content: space-between; padding: 1rem 1.25rem; }
header h3, header p { margin: 0; }
header p { color: #64748b; font-size: .85rem; }
.modal-shell__content { display: grid; gap: .9rem; overflow: auto; padding: 1.25rem; }

View File

@ -0,0 +1,14 @@
<div class="modal-shell__backdrop" (click)="backdropClick.emit()"></div>
<section class="modal-shell" [style.--modal-shell-max-width]="maxWidth" role="dialog" aria-modal="true" [attr.aria-label]="ariaLabel">
<header>
<div>
<h3>{{ title }}</h3>
@if (subtitle) { <p>{{ subtitle }}</p> }
</div>
<button type="button" mat-stroked-button (click)="closeClick.emit()">{{ closeLabel }}</button>
</header>
<div class="modal-shell__content">
<ng-content></ng-content>
</div>
<ng-content select="footer"></ng-content>
</section>

View File

@ -0,0 +1,55 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ModalShellComponent } from './modal-shell';
@Component({
standalone: true,
imports: [ModalShellComponent],
template: `
<app-modal-shell title="A title" subtitle="A subtitle" ariaLabel="A dialog" maxWidth="760px"
(backdropClick)="backdropClicks = backdropClicks + 1" (closeClick)="closeClicks = closeClicks + 1">
<p class="body-marker">Body content</p>
<footer><button type="button">Footer button</button></footer>
</app-modal-shell>
`
})
class HostComponent {
backdropClicks = 0;
closeClicks = 0;
}
describe('ModalShellComponent', () => {
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
it('renders title, subtitle and aria-label', () => {
const section = fixture.nativeElement.querySelector('.modal-shell');
expect(fixture.nativeElement.querySelector('h3').textContent).toBe('A title');
expect(fixture.nativeElement.querySelector('header p').textContent).toBe('A subtitle');
expect(section.getAttribute('aria-label')).toBe('A dialog');
});
it('projects body content into the default slot and footer into its own slot', () => {
expect(fixture.nativeElement.querySelector('.modal-shell__content .body-marker')).not.toBeNull();
expect(fixture.nativeElement.querySelector('.modal-shell > footer')).not.toBeNull();
});
it('emits backdropClick and closeClick on interaction', () => {
fixture.nativeElement.querySelector('.modal-shell__backdrop').click();
fixture.nativeElement.querySelector('header button').click();
fixture.detectChanges();
expect(fixture.componentInstance.backdropClicks).toBe(1);
expect(fixture.componentInstance.closeClicks).toBe(1);
});
it('exposes maxWidth as a CSS custom property consumed by the panel width/max-width', () => {
const section = fixture.nativeElement.querySelector('.modal-shell') as HTMLElement;
expect(section.style.getPropertyValue('--modal-shell-max-width')).toBe('760px');
});
});

View File

@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
/**
* Shared centered-modal chrome (backdrop, panel, header with title/subtitle/close,
* content wrapper) used by the small action dialogs whose layout only differs in
* body/footer content. `footer` is optional — project a top-level `<footer>` element
* to get one, matching each dialog's own styling for its buttons.
*/
@Component({
selector: 'app-modal-shell',
standalone: true,
imports: [MatButtonModule],
templateUrl: './modal-shell.html',
styleUrl: './modal-shell.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ModalShellComponent {
@Input() title = '';
@Input() subtitle: string | null = null;
@Input({ required: true }) ariaLabel!: string;
@Input() maxWidth = '680px';
@Input() closeLabel = 'Close';
@Output() backdropClick = new EventEmitter<void>();
@Output() closeClick = new EventEmitter<void>();
}

View File

@ -11,6 +11,7 @@ import { ReteModule } from 'rete-angular-plugin/21';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { EditorStateHolder } from '@stores/flow-editor';
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
import { NodeFocusModalController } from '../node-focus-modal-controller';
import { firstValueFrom } from 'rxjs';
import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, resolveNodeIcon, resolveSchemaPath, splitTemplatedTextParts, valueToDisplayString } from '../node-utility';
@ -81,11 +82,10 @@ type ContainerFlowFieldView = SchemaFlowDataFieldDefinition & {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ContainerNodeComponent implements OnDestroy {
private static readonly BODY_LOCK_CLASS = 'node-focus-modal-open';
private static readonly BODY_LOCK_COUNT_ATTR = 'data-node-focus-lock-count';
private editorState = inject(EditorStateHolder);
private cdr = inject(ChangeDetectorRef);
private hostElement = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly focusModal = new NodeFocusModalController(this.hostElement, 'container-node-focus-placeholder');
private subflowPreview = inject(SubflowPreviewDialogService);
private fieldRetriever = inject(FieldRetriever);
private containersService = inject(ContainersService);
@ -106,10 +106,6 @@ export class ContainerNodeComponent implements OnDestroy {
schemaReady = false;
focusOpen = false;
private schemaLoading = false;
private focusPlaceholder: Comment | null = null;
private focusOriginalParent: Node | null = null;
private focusOriginalNextSibling: Node | null = null;
private pageScrollLocked = false;
nameEditorOpen = false;
draftName = '';
@ -156,8 +152,7 @@ export class ContainerNodeComponent implements OnDestroy {
}
ngOnDestroy() {
this.releasePageScrollLock();
this.restoreHostFromModalLayer();
this.focusModal.close();
}
ngAfterViewInit() {
@ -184,13 +179,8 @@ export class ContainerNodeComponent implements OnDestroy {
if (this.focusOpen === value) return;
this.focusOpen = value;
this.syncPersistedFocusState();
if (value) {
this.attachHostToModalLayer();
this.applyPageScrollLock();
} else {
this.releasePageScrollLock();
this.restoreHostFromModalLayer();
}
if (value) this.focusModal.open();
else this.focusModal.close();
this.cdr.markForCheck();
}
@ -206,62 +196,6 @@ export class ContainerNodeComponent implements OnDestroy {
nodeData['__focusOpen'] = this.focusOpen;
}
private applyPageScrollLock() {
if (this.pageScrollLocked) return;
const body = document.body;
const currentCount = Number(body.getAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
const nextCount = Number.isFinite(currentCount) ? currentCount + 1 : 1;
body.setAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
body.classList.add(ContainerNodeComponent.BODY_LOCK_CLASS);
this.pageScrollLocked = true;
}
private releasePageScrollLock() {
if (!this.pageScrollLocked) return;
const body = document.body;
const currentCount = Number(body.getAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
const nextCount = Number.isFinite(currentCount) ? Math.max(0, currentCount - 1) : 0;
if (nextCount === 0) {
body.removeAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR);
body.classList.remove(ContainerNodeComponent.BODY_LOCK_CLASS);
} else {
body.setAttribute(ContainerNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
}
this.pageScrollLocked = false;
}
private attachHostToModalLayer() {
const host = this.hostElement.nativeElement;
const parent = host.parentNode;
if (!parent || host.parentNode === document.body) return;
this.focusOriginalParent = parent;
this.focusOriginalNextSibling = host.nextSibling;
this.focusPlaceholder = document.createComment('container-node-focus-placeholder');
parent.insertBefore(this.focusPlaceholder, host);
document.body.appendChild(host);
}
private restoreHostFromModalLayer() {
const host = this.hostElement.nativeElement;
if (!this.focusOriginalParent) return;
if (this.focusPlaceholder?.parentNode === this.focusOriginalParent) {
this.focusOriginalParent.insertBefore(host, this.focusPlaceholder);
this.focusOriginalParent.removeChild(this.focusPlaceholder);
} else if (this.focusOriginalNextSibling?.parentNode === this.focusOriginalParent) {
this.focusOriginalParent.insertBefore(host, this.focusOriginalNextSibling);
} else {
this.focusOriginalParent.appendChild(host);
}
this.focusPlaceholder = null;
this.focusOriginalParent = null;
this.focusOriginalNextSibling = null;
}
get name() {
return String(this.configuration?.['name'] ?? this.data?.data?.name ?? 'Container');
}

View File

@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BiasAnnotation, BiasAnnotationsDescriptor, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, isProbeExecutable, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotationsComponent } from '../../bias-annotations/bias-annotations';
import { NodeFocusModalController } from '../node-focus-modal-controller';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import {
@ -131,15 +132,13 @@ type RenderedSocketPort = {
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GenericNodeComponent implements OnDestroy {
private static readonly BODY_LOCK_CLASS = 'node-focus-modal-open';
private static readonly BODY_LOCK_COUNT_ATTR = 'data-node-focus-lock-count';
private settingsDialog = inject(NodeSettingsDialogService);
private editorState = inject(EditorStateHolder);
private fieldRetriever = inject(FieldRetriever);
private blocksService = inject(BlocksService);
private cdr = inject(ChangeDetectorRef);
private hostElement = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly focusModal = new NodeFocusModalController(this.hostElement, 'generic-node-focus-placeholder');
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -204,10 +203,6 @@ export class GenericNodeComponent implements OnDestroy {
private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] };
private conditionalRequiredByPath = new Map<string, boolean>();
private refreshingConditionalRequirements = false;
private focusPlaceholder: Comment | null = null;
private focusOriginalParent: Node | null = null;
private focusOriginalNextSibling: Node | null = null;
private pageScrollLocked = false;
constructor() {
effect(() => {
@ -276,8 +271,7 @@ export class GenericNodeComponent implements OnDestroy {
}
ngOnDestroy() {
this.releasePageScrollLock();
this.restoreHostFromModalLayer();
this.focusModal.close();
}
ngAfterViewInit() {
@ -308,13 +302,8 @@ export class GenericNodeComponent implements OnDestroy {
if (this.focusOpen === value) return;
this.focusOpen = value;
this.syncPersistedFocusState();
if (value) {
this.attachHostToModalLayer();
this.applyPageScrollLock();
} else {
this.releasePageScrollLock();
this.restoreHostFromModalLayer();
}
if (value) this.focusModal.open();
else this.focusModal.close();
this.cdr.markForCheck();
}
@ -330,62 +319,6 @@ export class GenericNodeComponent implements OnDestroy {
nodeData['__focusOpen'] = this.focusOpen;
}
private applyPageScrollLock() {
if (this.pageScrollLocked) return;
const body = document.body;
const currentCount = Number(body.getAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
const nextCount = Number.isFinite(currentCount) ? currentCount + 1 : 1;
body.setAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
body.classList.add(GenericNodeComponent.BODY_LOCK_CLASS);
this.pageScrollLocked = true;
}
private releasePageScrollLock() {
if (!this.pageScrollLocked) return;
const body = document.body;
const currentCount = Number(body.getAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR) ?? '0');
const nextCount = Number.isFinite(currentCount) ? Math.max(0, currentCount - 1) : 0;
if (nextCount === 0) {
body.removeAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR);
body.classList.remove(GenericNodeComponent.BODY_LOCK_CLASS);
} else {
body.setAttribute(GenericNodeComponent.BODY_LOCK_COUNT_ATTR, String(nextCount));
}
this.pageScrollLocked = false;
}
private attachHostToModalLayer() {
const host = this.hostElement.nativeElement;
const parent = host.parentNode;
if (!parent || host.parentNode === document.body) return;
this.focusOriginalParent = parent;
this.focusOriginalNextSibling = host.nextSibling;
this.focusPlaceholder = document.createComment('generic-node-focus-placeholder');
parent.insertBefore(this.focusPlaceholder, host);
document.body.appendChild(host);
}
private restoreHostFromModalLayer() {
const host = this.hostElement.nativeElement;
if (!this.focusOriginalParent) return;
if (this.focusPlaceholder?.parentNode === this.focusOriginalParent) {
this.focusOriginalParent.insertBefore(host, this.focusPlaceholder);
this.focusOriginalParent.removeChild(this.focusPlaceholder);
} else if (this.focusOriginalNextSibling?.parentNode === this.focusOriginalParent) {
this.focusOriginalParent.insertBefore(host, this.focusOriginalNextSibling);
} else {
this.focusOriginalParent.appendChild(host);
}
this.focusPlaceholder = null;
this.focusOriginalParent = null;
this.focusOriginalNextSibling = null;
}
async openNameEditor(event?: Event) {
event?.preventDefault();
event?.stopPropagation();

View File

@ -0,0 +1,74 @@
import { ElementRef } from '@angular/core';
import { NodeFocusModalController } from './node-focus-modal-controller';
describe('NodeFocusModalController', () => {
afterEach(() => {
document.body.removeAttribute('data-node-focus-lock-count');
document.body.classList.remove('node-focus-modal-open');
document.body.innerHTML = '';
});
function attachedHost(): { parent: HTMLElement; host: HTMLElement; sibling: HTMLElement } {
const parent = document.createElement('div');
const host = document.createElement('div');
const sibling = document.createElement('span');
parent.appendChild(host);
parent.appendChild(sibling);
document.body.appendChild(parent);
return { parent, host, sibling };
}
it('reparents the host to <body> on open and restores its original position on close', () => {
const { parent, host, sibling } = attachedHost();
const controller = new NodeFocusModalController(new ElementRef(host), 'test-placeholder');
controller.open();
expect(host.parentNode).toBe(document.body);
controller.close();
expect(host.parentNode).toBe(parent);
expect(host.nextSibling).toBe(sibling);
});
it('applies the body scroll-lock class and count attribute while open', () => {
const { host } = attachedHost();
const controller = new NodeFocusModalController(new ElementRef(host), 'test-placeholder');
controller.open();
expect(document.body.classList.contains('node-focus-modal-open')).toBe(true);
expect(document.body.getAttribute('data-node-focus-lock-count')).toBe('1');
controller.close();
expect(document.body.classList.contains('node-focus-modal-open')).toBe(false);
expect(document.body.hasAttribute('data-node-focus-lock-count')).toBe(false);
});
it('shares one body-level lock counter across multiple simultaneously-open controllers', () => {
const first = attachedHost();
const second = attachedHost();
const controllerA = new NodeFocusModalController(new ElementRef(first.host), 'a');
const controllerB = new NodeFocusModalController(new ElementRef(second.host), 'b');
controllerA.open();
controllerB.open();
expect(document.body.getAttribute('data-node-focus-lock-count')).toBe('2');
controllerA.close();
expect(document.body.classList.contains('node-focus-modal-open')).toBe(true);
expect(document.body.getAttribute('data-node-focus-lock-count')).toBe('1');
controllerB.close();
expect(document.body.classList.contains('node-focus-modal-open')).toBe(false);
});
it('is idempotent: opening twice or closing without opening does not throw or double-lock', () => {
const { host } = attachedHost();
const controller = new NodeFocusModalController(new ElementRef(host), 'test-placeholder');
expect(() => controller.close()).not.toThrow();
controller.open();
controller.open();
expect(document.body.getAttribute('data-node-focus-lock-count')).toBe('1');
});
});

View File

@ -0,0 +1,89 @@
import { ElementRef } from '@angular/core';
const BODY_LOCK_CLASS = 'node-focus-modal-open';
const BODY_LOCK_COUNT_ATTR = 'data-node-focus-lock-count';
/**
* Handles the "expanded/focus" modal chrome shared by generic-node and
* container-node: reparents the node's host element to `<body>` so it renders
* above the canvas, and locks page scroll while open via a shared body-level
* counter (so multiple simultaneously-open nodes stack correctly instead of
* unlocking the page as soon as any single one closes).
*/
export class NodeFocusModalController {
private pageScrollLocked = false;
private placeholder: Comment | null = null;
private originalParent: Node | null = null;
private originalNextSibling: Node | null = null;
constructor(
private readonly hostElement: ElementRef<HTMLElement>,
private readonly placeholderLabel: string
) {}
open(): void {
this.attachHostToModalLayer();
this.applyPageScrollLock();
}
close(): void {
this.releasePageScrollLock();
this.restoreHostFromModalLayer();
}
private applyPageScrollLock() {
if (this.pageScrollLocked) return;
const body = document.body;
const currentCount = Number(body.getAttribute(BODY_LOCK_COUNT_ATTR) ?? '0');
const nextCount = Number.isFinite(currentCount) ? currentCount + 1 : 1;
body.setAttribute(BODY_LOCK_COUNT_ATTR, String(nextCount));
body.classList.add(BODY_LOCK_CLASS);
this.pageScrollLocked = true;
}
private releasePageScrollLock() {
if (!this.pageScrollLocked) return;
const body = document.body;
const currentCount = Number(body.getAttribute(BODY_LOCK_COUNT_ATTR) ?? '0');
const nextCount = Number.isFinite(currentCount) ? Math.max(0, currentCount - 1) : 0;
if (nextCount === 0) {
body.removeAttribute(BODY_LOCK_COUNT_ATTR);
body.classList.remove(BODY_LOCK_CLASS);
} else {
body.setAttribute(BODY_LOCK_COUNT_ATTR, String(nextCount));
}
this.pageScrollLocked = false;
}
private attachHostToModalLayer() {
const host = this.hostElement.nativeElement;
const parent = host.parentNode;
if (!parent || host.parentNode === document.body) return;
this.originalParent = parent;
this.originalNextSibling = host.nextSibling;
this.placeholder = document.createComment(this.placeholderLabel);
parent.insertBefore(this.placeholder, host);
document.body.appendChild(host);
}
private restoreHostFromModalLayer() {
const host = this.hostElement.nativeElement;
if (!this.originalParent) return;
if (this.placeholder?.parentNode === this.originalParent) {
this.originalParent.insertBefore(host, this.placeholder);
this.originalParent.removeChild(this.placeholder);
} else if (this.originalNextSibling?.parentNode === this.originalParent) {
this.originalParent.insertBefore(host, this.originalNextSibling);
} else {
this.originalParent.appendChild(host);
}
this.placeholder = null;
this.originalParent = null;
this.originalNextSibling = null;
}
}

View File

@ -129,4 +129,26 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
expect(component.measureBiasImpactTooltip()).toBe('Measure bias impact');
});
});
describe('isEmptyDisplayValue', () => {
const isEmptyDisplayValue = (value: unknown): boolean => (component as any).isEmptyDisplayValue(value);
it('treats an array of blank strings as empty, matching the editor-side isMissingValue semantics', () => {
expect(isEmptyDisplayValue(['', '', ''])).toBe(true);
});
it('treats an array containing a real value as non-empty', () => {
expect(isEmptyDisplayValue(['', 'value', ''])).toBe(false);
});
it('treats an object whose values are all missing as empty', () => {
expect(isEmptyDisplayValue({ a: '', b: null })).toBe(true);
});
it('treats null/undefined/blank strings as empty', () => {
expect(isEmptyDisplayValue(null)).toBe(true);
expect(isEmptyDisplayValue(undefined)).toBe(true);
expect(isEmptyDisplayValue(' ')).toBe(true);
});
});
});

View File

@ -1057,10 +1057,14 @@ export class TaskStepNodeComponent {
return isTextarea || label.trim().length >= 18;
}
private isEmptyDisplayValue(value: unknown) {
private isEmptyDisplayValue(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;
if (Array.isArray(value)) return value.length === 0;
if (Array.isArray(value)) return value.length === 0 || value.every((item) => this.isEmptyDisplayValue(item));
if (typeof value === 'object') {
const entries = Object.values(value as Record<string, unknown>);
return entries.length === 0 || entries.every((item) => this.isEmptyDisplayValue(item));
}
return false;
}

View File

@ -0,0 +1,95 @@
/* Shared chrome for the change-password and admin-reset-password dialogs. */
.password-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.5);
}
.password-dialog-modal {
width: min(520px, calc(100vw - 32px));
max-height: min(80vh, 720px);
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
padding: 24px;
}
.password-dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.password-dialog-title {
margin: 0;
color: #0f172a;
font-size: 20px;
font-weight: 700;
}
.password-dialog-subtitle {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
}
.password-dialog-form {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 20px;
}
.password-dialog-field {
width: 100%;
}
.password-dialog-checklist {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: -6px;
padding: 0 2px;
}
.password-dialog-check {
display: flex;
align-items: center;
gap: 8px;
color: #b91c1c;
font-size: 12px;
}
.password-dialog-check--ok {
color: #15803d;
}
.password-dialog-check-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.password-dialog-error {
border: 1px solid #fecaca;
border-radius: 12px;
background: #fff1f2;
color: #b91c1c;
font-size: 13px;
padding: 10px 12px;
}
.password-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, output, signal, SimpleChanges, untracked, viewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, effect, ElementRef, HostListener, Injector, input, OnChanges, OnDestroy, signal, SimpleChanges, untracked, viewChild } from '@angular/core';
import { BlockType, FlowData, FlowNode } from '@models/flow';
import { Drag } from 'rete-area-plugin';
import { BlocksService } from '@services/blocks/blocks';
@ -62,9 +62,6 @@ export class ReteEditor implements OnChanges, OnDestroy {
'connectionremoved'
]);
flowChanged = output<any>();
ngAfterViewInit(): void {
this.viewReady = true;
void this.reloadEditor();
@ -150,7 +147,6 @@ export class ReteEditor implements OnChanges, OnDestroy {
await addBlockToEditor(this.rete.editor, this.rete.area, newBlock, position);
const updatedData = exportGraph(this.rete.editor);
this.flowState.updateData(updatedData);
this.flowChanged.emit(updatedData);
}
onShellPointerDown(event: PointerEvent) {
@ -351,7 +347,6 @@ export class ReteEditor implements OnChanges, OnDestroy {
await this.rete.editor.removeConnection(currentConnection.id);
const updatedData = exportGraph(this.rete.editor);
this.flowState.updateData(updatedData, { structural: true });
this.flowChanged.emit(updatedData);
this.graphSelection.clearConnectionSelection();
}
@ -538,7 +533,6 @@ export class ReteEditor implements OnChanges, OnDestroy {
setEditorGlobalInputs(rete.editor, this.flowData().globalInputs ?? []);
const updatedData = exportGraph(rete.editor);
this.flowState.updateData(updatedData, { structural: context?.type !== 'nodetranslated' });
this.flowChanged.emit(updatedData);
}
private isEditorGraphInSync(): boolean {

View File

@ -127,7 +127,6 @@ export class TitleToolbar {
this.editingTitle.set(false);
return;
}
console.log('Updating flow title to:', trimmed);
this.editorState.updateFlowTitle(trimmed );
this.draftTitle.set(trimmed);
this.editingTitle.set(false);
@ -238,7 +237,6 @@ export class TitleToolbar {
take(1)
).subscribe({
next: (savedFlow) => {
console.log('Flow saved');
if ((savedFlow.validationErrors?.length ?? 0) > 0 && savedFlow.status === 'DRAFT') {
this.showSnackbar('Flow saved as draft with validation errors', 'error');
} else {

View File

@ -181,6 +181,14 @@ describe('EditorStateHolder', () => {
expect(service.currentFlow()?.data.blocks[0].biasAnnotations?.[0].id).toBe('bias-server-1');
});
it('save() surfaces a handled error instead of throwing when no flow is loaded', async () => {
expect(service.currentFlow()).toBeNull();
await expect(new Promise<void>((resolve, reject) => {
service.save().subscribe({ next: () => resolve(), error: reject });
})).rejects.toThrow('No flow is currently loaded.');
});
describe('isCurrentFlowReadOnly', () => {
it('should return true for finalized flow', async () => {
await service.openDocument(makeFlow({ finalized: true }));

View File

@ -147,7 +147,10 @@ export class EditorStateHolder {
if (this.isCurrentFlowReadOnly()) {
return throwError(() => new Error('Read-only flows cannot be saved.'));
}
const flow = this.currentFlow()!;
const flow = this.currentFlow();
if (!flow) {
return throwError(() => new Error('No flow is currently loaded.'));
}
const save$ = flow.id.startsWith(EditorStateHolder.ASSISTANT_DRAFT_PREFIX)
? this.flowsService.createFlow({
name: flow.name,

View File

@ -53,8 +53,4 @@ export class ListStateViewHolder<T> {
return this.state;
}
create() {
this.state = new ListView<T>();
}
}

View File

@ -0,0 +1,55 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { form } from '@angular/forms/signals';
import { applyConfirmPasswordValidators, applyNewPasswordValidators } from './password-form-validators';
describe('password-form-validators', () => {
function buildForm() {
return TestBed.runInInjectionContext(() => {
const model = signal({ newPassword: '', confirmNewPassword: '' });
return form(model, (f) => {
applyNewPasswordValidators(f.newPassword);
applyConfirmPasswordValidators(f.confirmNewPassword, f.newPassword);
});
});
}
it('requires a new password', () => {
const passwordForm = buildForm();
expect(passwordForm.newPassword().errors().some((e) => e.kind === 'required')).toBe(true);
});
it('flags a new password that is too short', () => {
const passwordForm = buildForm();
passwordForm.newPassword().value.set('Ab1!');
expect(passwordForm.newPassword().errors().some((e) => e.kind === 'minLength')).toBe(true);
});
it('flags a new password missing complexity (upper/lower/digit/special)', () => {
const passwordForm = buildForm();
passwordForm.newPassword().value.set('alllowercase');
expect(passwordForm.newPassword().errors().some((e) => e.kind === 'passwordComplexity')).toBe(true);
});
it('accepts a new password satisfying length and complexity', () => {
const passwordForm = buildForm();
passwordForm.newPassword().value.set('Str0ng!Pass');
expect(passwordForm.newPassword().errors().length).toBe(0);
});
it('requires the confirmation field and flags a mismatch', () => {
const passwordForm = buildForm();
passwordForm.newPassword().value.set('Str0ng!Pass');
expect(passwordForm.confirmNewPassword().errors().some((e) => e.kind === 'required')).toBe(true);
passwordForm.confirmNewPassword().value.set('Different1!');
expect(passwordForm.confirmNewPassword().errors().some((e) => e.kind === 'passwordMismatch')).toBe(true);
});
it('clears the mismatch error once both passwords match', () => {
const passwordForm = buildForm();
passwordForm.newPassword().value.set('Str0ng!Pass');
passwordForm.confirmNewPassword().value.set('Str0ng!Pass');
expect(passwordForm.confirmNewPassword().errors().length).toBe(0);
});
});

View File

@ -0,0 +1,37 @@
import { minLength, PathKind, required, SchemaPath, SchemaPathRules, validate } from '@angular/forms/signals';
import { hasValidPasswordComplexity, PASSWORD_MIN_LENGTH } from './password-validation';
type PasswordFieldPath = SchemaPath<string, SchemaPathRules.Supported, PathKind.Root>;
/**
* Shared "new password" schema rules (required, min length, complexity) for the
* signal-forms `form()` callback. Used by both the self-service change-password
* dialog and the admin reset-password dialog, which otherwise duplicated this
* validator chain verbatim.
*/
export function applyNewPasswordValidators(field: PasswordFieldPath): void {
required(field, { message: 'New password is required' });
minLength(field, PASSWORD_MIN_LENGTH, { message: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` });
validate(field, ({ value }) => {
const password = value();
if (!password || hasValidPasswordComplexity(password)) return null;
return {
kind: 'passwordComplexity',
message: 'Password must include uppercase, lowercase, number and special character, with no spaces.'
};
});
}
/** Shared "confirm new password" schema rules (required, must match `newPasswordField`). */
export function applyConfirmPasswordValidators(confirmField: PasswordFieldPath, newPasswordField: PasswordFieldPath): void {
required(confirmField, { message: 'Confirm your new password' });
validate(confirmField, ({ value, valueOf }) => {
if (value() !== valueOf(newPasswordField)) {
return {
kind: 'passwordMismatch',
message: 'Passwords do not match'
};
}
return null;
});
}

View File

@ -313,10 +313,6 @@ export async function addBlockToEditor(
typeName: currentLiveNode.data['typeName']
})
);
if (options?.source === 'import') {
console.log(`Container create response after ${targetPath} import:`, replacementFromServer);
}
const selectedIds = options?.selectedIds ?? new Set<string>();
const selectedNodeIds = editor.getNodes()
.filter((candidate) => selectedIds.has(String(candidate.data?.id ?? "")))
@ -467,12 +463,8 @@ export async function addBlockToEditor(
if (!sourceNode || !targetNode) continue;
const sourceOutput = connection.source === node.id
? connection.sourceOutput
: connection.sourceOutput;
const targetInput = connection.target === node.id
? connection.targetInput
: connection.targetInput;
const sourceOutput = connection.sourceOutput;
const targetInput = connection.targetInput;
if (connection.source === node.id && !replacementOutputNames.has(sourceOutput)) continue;
if (connection.target === node.id && !replacementInputNames.has(targetInput)) continue;

View File

@ -0,0 +1,27 @@
import { signal } from '@angular/core';
import { vi } from 'vitest';
import { scheduleSignalClear } from './temporary-signal';
describe('scheduleSignalClear', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('clears the signal back to null after the default delay', () => {
const message = signal<string | null>('Saved.');
scheduleSignalClear(message);
expect(message()).toBe('Saved.');
vi.advanceTimersByTime(3000);
expect(message()).toBeNull();
});
it('respects a custom delay', () => {
const message = signal<string | null>('Saved.');
scheduleSignalClear(message, 5000);
vi.advanceTimersByTime(3000);
expect(message()).toBe('Saved.');
vi.advanceTimersByTime(2000);
expect(message()).toBeNull();
});
});

View File

@ -0,0 +1,9 @@
import { WritableSignal } from '@angular/core';
/**
* Clears `target` back to `null` after `delayMs`. Used for transient
* success/error banners that should auto-dismiss.
*/
export function scheduleSignalClear<T>(target: WritableSignal<T | null>, delayMs = 3000): void {
setTimeout(() => target.set(null), delayMs);
}