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:
parent
94ce814a6f
commit
af246dab65
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.'
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ={
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 })
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -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.'));
|
||||
}
|
||||
|
|
@ -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)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>();
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -53,8 +53,4 @@ export class ListStateViewHolder<T> {
|
|||
return this.state;
|
||||
}
|
||||
|
||||
create() {
|
||||
this.state = new ListView<T>();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue