From 3095dbe7e5cd7e841644504829315ef089229a9d Mon Sep 17 00:00:00 2001 From: MoritzWeber Date: Mon, 2 Dec 2024 16:54:54 +0100 Subject: [PATCH] feat: Group sessions by projects Show the project for read-only and provisioned sessions in the Active Sessions overview. --- ...f986_add_project_scope_to_session_table.py | 25 ++ backend/capellacollab/sessions/models.py | 15 + backend/capellacollab/sessions/routes.py | 1 + frontend/src/app/openapi/model/session.ts | 2 + .../model-diagram-code-block.component.html | 80 ++-- .../app/sessions/service/session.service.ts | 42 +- .../session-overview.stories.ts | 8 +- .../active-sessions.component.css | 20 - .../active-sessions.component.html | 210 +++------- .../active-sessions.component.ts | 126 +++--- .../active-sessions.stories.ts | 393 +++--------------- .../session-card/session-card.component.html | 155 +++++++ .../session-card/session-card.component.ts | 144 +++++++ .../session-card/session-card.stories.ts | 279 +++++++++++++ .../create-persistent-session.component.ts | 16 +- ...reate-readonly-session-dialog.component.ts | 17 +- .../create-session-history.component.ts | 15 +- .../alert-settings.component.css | 28 -- .../alert-settings.component.html | 39 +- .../alert-settings.component.ts | 1 - .../alert-settings/alert-settings.stories.ts | 15 + frontend/src/storybook/session.ts | 69 +-- frontend/src/storybook/tool.ts | 8 +- frontend/src/styles.css | 13 + 24 files changed, 971 insertions(+), 750 deletions(-) create mode 100644 backend/capellacollab/alembic/versions/4cf566b4f986_add_project_scope_to_session_table.py delete mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.css create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.component.html create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.component.ts create mode 100644 frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.stories.ts delete mode 100644 frontend/src/app/settings/core/alert-settings/alert-settings.component.css diff --git a/backend/capellacollab/alembic/versions/4cf566b4f986_add_project_scope_to_session_table.py b/backend/capellacollab/alembic/versions/4cf566b4f986_add_project_scope_to_session_table.py new file mode 100644 index 0000000000..e6fa7c64b6 --- /dev/null +++ b/backend/capellacollab/alembic/versions/4cf566b4f986_add_project_scope_to_session_table.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Add project scope to session table + +Revision ID: 4cf566b4f986 +Revises: 2f8449c217fa +Create Date: 2024-12-02 14:40:15.815359 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "4cf566b4f986" +down_revision = "2f8449c217fa" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "sessions", sa.Column("project_id", sa.Integer(), nullable=True) + ) + op.create_foreign_key(None, "sessions", "projects", ["project_id"], ["id"]) diff --git a/backend/capellacollab/sessions/models.py b/backend/capellacollab/sessions/models.py index 86813392fe..a468298d33 100644 --- a/backend/capellacollab/sessions/models.py +++ b/backend/capellacollab/sessions/models.py @@ -16,6 +16,7 @@ from capellacollab.core import database from capellacollab.core import models as core_models from capellacollab.core import pydantic as core_pydantic +from capellacollab.projects import models as projects_models from capellacollab.sessions import models2 as sessions_models2 from capellacollab.sessions import operators from capellacollab.tools import models as tools_models @@ -24,6 +25,7 @@ from . import injection if t.TYPE_CHECKING: + from capellacollab.projects.models import DatabaseProject from capellacollab.projects.toolmodels.provisioning.models import ( DatabaseModelProvisioning, ) @@ -131,6 +133,10 @@ class Session(core_pydantic.BaseModel): shared_with: list[SessionSharing] = pydantic.Field(default=[]) + project: projects_models.SimpleProject | None = pydantic.Field( + default=None + ) + _validate_created_at = pydantic.field_serializer("created_at")( core_pydantic.datetime_serializer ) @@ -217,6 +223,15 @@ class DatabaseSession(database.Base): ) ) + project_id: orm.Mapped[int | None] = orm.mapped_column( + sa.ForeignKey("projects.id"), + init=False, + ) + project: orm.Mapped[DatabaseProject | None] = orm.relationship( + foreign_keys=[project_id], + default=None, + ) + environment: orm.Mapped[dict[str, str]] = orm.mapped_column( nullable=False, default_factory=dict ) diff --git a/backend/capellacollab/sessions/routes.py b/backend/capellacollab/sessions/routes.py index 6ed27b24de..5873b7cf10 100644 --- a/backend/capellacollab/sessions/routes.py +++ b/backend/capellacollab/sessions/routes.py @@ -230,6 +230,7 @@ async def request_session( }, created_at=session["created_at"], connection_method_id=connection_method.id, + project=project_scope, ), ) diff --git a/frontend/src/app/openapi/model/session.ts b/frontend/src/app/openapi/model/session.ts index cbf0c2769f..d30de9c509 100644 --- a/frontend/src/app/openapi/model/session.ts +++ b/frontend/src/app/openapi/model/session.ts @@ -9,6 +9,7 @@ + To generate a new version, run `make openapi` in the root directory of this repository. */ +import { SimpleProject } from './simple-project'; import { SessionState } from './session-state'; import { BaseUser } from './base-user'; import { SessionType } from './session-type'; @@ -33,6 +34,7 @@ export interface Session { connection_method_id: string; connection_method: ToolSessionConnectionMethod | null; shared_with: Array; + project: SimpleProject | null; } export namespace Session { } diff --git a/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-code-block/model-diagram-code-block.component.html b/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-code-block/model-diagram-code-block.component.html index 888ca9182d..7a0d01cc1d 100644 --- a/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-code-block/model-diagram-code-block.component.html +++ b/frontend/src/app/projects/models/diagrams/model-diagram-dialog/model-diagram-code-block/model-diagram-code-block.component.html @@ -9,45 +9,47 @@ Learn how to use the diagram cache with capellambse! -
- You can also access the diagrams via our Python library - capellambse. Follow the installation instructions on - Githubopen_in_new +
+ You can also access the diagrams via our Python library + capellambse. Follow the installation instructions on + Githubopen_in_new + + and then use the code snippet. To authenticate, you have to insert a + personal access token. The token has the same scope as your user. Be + careful with it. You can revoke the token in the settings. +
+
+
+
-
-
- - + content_copy + + +
diff --git a/frontend/src/app/sessions/service/session.service.ts b/frontend/src/app/sessions/service/session.service.ts index 5fb140a186..b0d0e51469 100644 --- a/frontend/src/app/sessions/service/session.service.ts +++ b/frontend/src/app/sessions/service/session.service.ts @@ -6,12 +6,12 @@ import { Injectable } from '@angular/core'; import { Observable, tap } from 'rxjs'; import { Session, - SessionProvisioningRequest, SessionsService, SessionConnectionInformation, FileTree, SessionPreparationState, SessionState, + PostSessionRequest, } from 'src/app/openapi'; import { SessionHistoryService } from 'src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/session-history.service'; @@ -32,33 +32,19 @@ export class SessionService { private sessionsService: SessionsService, ) {} - createSession( - toolId: number, - versionId: number, - connectionMethodId: string, - session_type: 'persistent' | 'readonly', - models: SessionProvisioningRequest[], - ): Observable { - return this.sessionsService - .requestSession({ - tool_id: toolId, - version_id: versionId, - connection_method_id: connectionMethodId, - session_type: session_type, - provisioning: models, - }) - .pipe( - tap((session) => { - if (isPersistentSession(session)) { - this.sessionHistoryService.addSessionRequestToHistory({ - toolId, - versionId, - connectionMethodId, - lastRequested: new Date(), - }); - } - }), - ); + createSession(request: PostSessionRequest): Observable { + return this.sessionsService.requestSession(request).pipe( + tap((session) => { + if (isPersistentSession(session)) { + this.sessionHistoryService.addSessionRequestToHistory({ + toolId: request.tool_id, + versionId: request.version_id, + connectionMethodId: session.connection_method_id, + lastRequested: new Date(), + }); + } + }), + ); } setConnectionInformation(connectionInfo: SessionConnectionInformation): void { diff --git a/frontend/src/app/sessions/session-overview/session-overview.stories.ts b/frontend/src/app/sessions/session-overview/session-overview.stories.ts index 2a6c56b93c..ed8b86bf7f 100644 --- a/frontend/src/app/sessions/session-overview/session-overview.stories.ts +++ b/frontend/src/app/sessions/session-overview/session-overview.stories.ts @@ -13,7 +13,6 @@ import { SessionState, } from 'src/app/openapi'; import { - createPersistentSessionWithState, mockPersistentSession, mockReadonlySession, } from 'src/storybook/session'; @@ -57,10 +56,9 @@ const sessions = [ mockPersistentSession, { ...mockReadonlySession, id: 'vjmczglcgeltbfcronujtelwx' }, { - ...createPersistentSessionWithState( - SessionPreparationState.Failed, - SessionState.Pending, - ), + ...mockPersistentSession, + preparation_state: SessionPreparationState.Failed, + state: SessionState.Pending, owner: { ...mockUser, name: 'anotherUser', diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.css b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.css deleted file mode 100644 index abd9a56cbd..0000000000 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.css +++ /dev/null @@ -1,20 +0,0 @@ -/* -* SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors -* SPDX-License-Identifier: Apache-2.0 -*/ - -.error { - @apply bg-error; -} - -.warning { - @apply bg-warning; -} - -.success { - @apply bg-success; -} - -.primary { - @apply bg-primary; -} diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html index e1f2184e77..c07b213865 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.html @@ -44,162 +44,90 @@

No active sessions

} @else if ((sessions | async)?.length !== 0) { - @for (session of sessions | async; track session.id) { - @let sessionState = - sessionService.beautifyState(session.preparation_state, session.state); + @for (item of sessionsGroupedByName | async | keyvalue; track item.key) { + @let project = item.value[0].project; -
-
- -
-

- {{ session.version.tool.name }} ({{ session.version.name }}) - - via - {{ session.connection_method?.name }} - -

-
- @if (isPersistentSession(session)) { - Persistent workspace session created - - } @else if (isReadonlySession(session)) { - Read-Only session created - - } -
-
- -
- @if ((feedbackService.feedbackConfig$ | async)?.on_session_card) { - - } -
-
- -
-

- {{ sessionState.icon }} - {{ sessionState.text }} -

- @let remainingMinutes = minutesUntilSessionTermination(session); - @if (remainingMinutes !== null && remainingMinutes < 30) { -
+ -
- - - @if (!isSessionShared(session)) { - - -
+ + @if (project.type === "training") { + + + +
+ {{ readySessions(item.value).length }}/{{ + item.value.length + }} + Ready +
+ + +
+
+
+ @for (session of item.value; track session.id) { + + } +
+
+ } @else { + @for (session of item.value; track session.id) { + + } }
-
+ } @else { + @for (session of item.value; track session.id) { + + } + } } }
diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts index f88530dc25..6807476c40 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component.ts @@ -2,66 +2,74 @@ * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors * SPDX-License-Identifier: Apache-2.0 */ -import { NgClass, AsyncPipe } from '@angular/common'; +import { AsyncPipe, KeyValuePipe } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatAnchor, MatButton, MatIconButton } from '@angular/material/button'; +import { MatAnchor, MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialog } from '@angular/material/dialog'; +import { MatExpansionModule } from '@angular/material/expansion'; import { MatIcon } from '@angular/material/icon'; -import { MatTooltip } from '@angular/material/tooltip'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; import { RouterLink } from '@angular/router'; -import { addMinutes, differenceInMinutes } from 'date-fns'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { BehaviorSubject, map } from 'rxjs'; import { Session } from 'src/app/openapi'; -import { OwnUserWrapperService } from 'src/app/services/user/user.service'; -import { ConnectionDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component'; -import { SessionSharingDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component'; -import { RelativeTimeComponent } from '../../../general/relative-time/relative-time.component'; -import { DeleteSessionDialogComponent } from '../../delete-session-dialog/delete-session-dialog.component'; -import { FeedbackWrapperService } from '../../feedback/feedback.service'; -import { - SessionService, - isPersistentSession, - isReadonlySession, -} from '../../service/session.service'; +import { DeleteSessionDialogComponent } from 'src/app/sessions/delete-session-dialog/delete-session-dialog.component'; +import { FeedbackWrapperService } from 'src/app/sessions/feedback/feedback.service'; +import { SessionCardComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.component'; +import { SessionService } from '../../service/session.service'; import { UserSessionService } from '../../service/user-session.service'; -import { FileBrowserDialogComponent } from './file-browser-dialog/file-browser-dialog.component'; @Component({ selector: 'app-active-sessions', templateUrl: './active-sessions.component.html', - styleUrls: ['./active-sessions.component.css'], imports: [ NgxSkeletonLoaderModule, MatAnchor, RouterLink, MatIcon, - NgClass, - MatButton, AsyncPipe, - MatIconButton, - MatTooltip, MatCheckboxModule, FormsModule, - RelativeTimeComponent, + KeyValuePipe, + SessionCardComponent, + MatButtonModule, + MatExpansionModule, + MatProgressBarModule, ], }) export class ActiveSessionsComponent implements OnInit { - isReadonlySession = isReadonlySession; - isPersistentSession = isPersistentSession; - constructor( public sessionService: SessionService, public userSessionService: UserSessionService, - public feedbackService: FeedbackWrapperService, - private userWrapperService: OwnUserWrapperService, private dialog: MatDialog, + private feedbackService: FeedbackWrapperService, ) {} sessions = new BehaviorSubject(undefined); + readySessions(sessions: SessionWithSelection[]) { + return sessions.filter( + (session) => + this.sessionService.beautifyState( + session.preparation_state, + session.state, + ).success, + ); + } + + // groupBy is not supported by our target browsers + sessionsGroupedByName = this.sessions.pipe( + map((sessions) => + sessions?.reduce((rv: GroupedSessions, x) => { + const projectID = x.project?.id ?? -1; + (rv[projectID] = rv[projectID] || []).push(x); + return rv; + }, {}), + ), + ); + ngOnInit(): void { this.userSessionService.sessions$.subscribe((sessions) => { const unselectedSessionIDs = new Set( @@ -77,17 +85,7 @@ export class ActiveSessionsComponent implements OnInit { }); } - get selectedSessionIDs$() { - return this.sessions.pipe( - map((sessions) => - sessions - ?.filter((session) => session.selected) - .map((session) => session.id), - ), - ); - } - - openDeletionDialog(sessions: Session[]): void { + openTerminationDialog(sessions: Session[]): void { const dialogRef = this.dialog.open(DeleteSessionDialogComponent, { data: sessions, }); @@ -106,48 +104,20 @@ export class ActiveSessionsComponent implements OnInit { }); } - openConnectDialog(session: Session): void { - this.dialog.open(ConnectionDialogComponent, { - data: session, - }); - } - - openShareDialog(session: Session): void { - this.dialog.open(SessionSharingDialogComponent, { - data: session, - }); - } - - uploadFileDialog(session: Session): void { - this.dialog.open(FileBrowserDialogComponent, { data: session }); - } - - isSessionShared(session: Session): boolean { - return session.owner.id != this.userWrapperService.user?.id; + get selectedSessionIDs$() { + return this.sessions.pipe( + map((sessions) => + sessions + ?.filter((session) => session.selected) + .map((session) => session.id), + ), + ); } - minutesUntilSessionTermination(session: Session): number | null { - if (session.idle_state.available) { - if (session.idle_state.idle_for_minutes === -1) { - // session was never connected to, use creation time - return ( - session.idle_state.terminate_after_minutes - - differenceInMinutes(session.created_at, Date.now()) - ); - } else { - return ( - session.idle_state.terminate_after_minutes - - session.idle_state.idle_for_minutes! - ); - } - } else { - return null; - } + sessionIDsForSessions(sessions: SessionWithSelection[]) { + return sessions.map((session) => session.id); } - - protected readonly Date = Date; - protected readonly addMinutes = addMinutes; - protected readonly differenceInMinutes = differenceInMinutes; } -type SessionWithSelection = Session & { selected: boolean }; +export type SessionWithSelection = Session & { selected: boolean }; +type GroupedSessions = Record; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts index d53b54bb3b..aa582c9559 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.stories.ts @@ -8,23 +8,16 @@ import { componentWrapperDecorator, moduleMetadata, } from '@storybook/angular'; +import { userEvent, within } from '@storybook/test'; import MockDate from 'mockdate'; import { Observable, of } from 'rxjs'; +import { Session, SessionState } from 'src/app/openapi'; +import { mockProject } from 'src/storybook/project'; import { - Session, - SessionPreparationState, - SessionState, -} from 'src/app/openapi'; -import { - mockFeedbackConfig, - mockFeedbackWrapperServiceProvider, -} from 'src/storybook/feedback'; -import { - createPersistentSessionWithState, mockPersistentSession, mockReadonlySession, + mockTrainingSession, } from 'src/storybook/session'; -import { mockHttpConnectionMethod } from 'src/storybook/tool'; import { mockOwnUserWrapperServiceProvider, mockUser, @@ -35,15 +28,18 @@ import { ActiveSessionsComponent } from './active-sessions.component'; class MockUserSessionService implements Partial { public readonly sessions$: Observable = of(undefined); - constructor(session?: Session, empty?: boolean) { - if (session !== undefined) { - this.sessions$ = of([session]); - } else if (empty === true) { - this.sessions$ = of([]); - } + constructor(sessions?: Session[]) { + this.sessions$ = of(sessions); } } +const mockUserSessionServiceProvider = (sessions?: Session[]) => { + return { + provide: UserSessionService, + useValue: new MockUserSessionService(sessions), + }; +}; + const meta: Meta = { title: 'Session Components/Active Sessions', component: ActiveSessionsComponent, @@ -67,12 +63,7 @@ export const Loading: Story = { args: {}, decorators: [ moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => new MockUserSessionService(), - }, - ], + providers: [mockUserSessionServiceProvider()], }), ], }; @@ -81,358 +72,82 @@ export const NoActiveStories: Story = { args: {}, decorators: [ moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => new MockUserSessionService(undefined, true), - }, - ], - }), - ], -}; - -export const SessionNotFoundState: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState( - SessionPreparationState.NotFound, - SessionState.NotFound, - ), - ), - }, - ], - }), - ], -}; - -export const SessionPreparationPendingState: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState( - SessionPreparationState.Pending, - SessionState.Pending, - ), - ), - }, - ], - }), - ], -}; - -export const SessionPreparationRunningState: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState( - SessionPreparationState.Running, - SessionState.Pending, - ), - ), - }, - ], + providers: [mockUserSessionServiceProvider([])], }), ], }; -export const SessionRunningState: Story = { +export const FewActiveSessions: Story = { args: {}, decorators: [ moduleMetadata({ providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState( - SessionPreparationState.Completed, - SessionState.Running, - ), - ), - }, + mockUserSessionServiceProvider([ + mockPersistentSession, + mockReadonlySession, + ]), ], }), ], }; -export const SessionTerminatingSoon: Story = { +export const GroupedByProjectSessions: Story = { args: {}, decorators: [ moduleMetadata({ providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService({ - ...createPersistentSessionWithState( - SessionPreparationState.Completed, - SessionState.Running, - ), - idle_state: { - available: true, - terminate_after_minutes: 90, - idle_for_minutes: 80, - unavailable_reason: null, - }, - }), - }, + mockUserSessionServiceProvider([ + mockReadonlySession, + { ...mockPersistentSession, project: mockProject }, + { + ...mockPersistentSession, + project: mockProject, + }, + ]), ], }), ], }; -export const SessionTerminatedState: Story = { +export const GroupedByTrainingSessions: Story = { args: {}, decorators: [ moduleMetadata({ providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState( - SessionPreparationState.Completed, - SessionState.Terminated, - ), - ), - }, + mockUserSessionServiceProvider([ + mockReadonlySession, + mockTrainingSession, + { + ...mockPersistentSession, + project: mockProject, + state: SessionState.Pending, + }, + ]), ], }), ], }; -export const SessionPendingState: Story = { +export const GroupedByTrainingSessionsExpanded: Story = { args: {}, decorators: [ moduleMetadata({ providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState( - SessionPreparationState.Completed, - SessionState.Pending, - ), - ), - }, - ], - }), - ], -}; - -export const SessionFailedState: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState( - SessionPreparationState.Completed, - SessionState.Failed, - ), - ), - }, - ], - }), - ], -}; - -export const SessionUnknownState: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService( - createPersistentSessionWithState( - SessionPreparationState.Completed, - SessionState.Unknown, - ), - ), - }, - ], - }), - ], -}; - -export const SessionWithFeedbackEnabled: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => new MockUserSessionService(mockPersistentSession), - }, - mockFeedbackWrapperServiceProvider(mockFeedbackConfig), - ], - }), - ], -}; - -export const ReadonlySessionSuccessState: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => new MockUserSessionService(mockReadonlySession), - }, - ], - }), - ], -}; - -export const SessionSharingEnabled: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService({ - ...mockPersistentSession, - connection_method: { - ...mockHttpConnectionMethod, - sharing: { enabled: true }, - }, - }), - }, - ], - }), - ], -}; - -export const SessionSharedWithUser: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService({ - ...mockPersistentSession, - connection_method: { - ...mockHttpConnectionMethod, - sharing: { enabled: true }, - }, - shared_with: [ - { - user: { - id: 1, - name: 'user_1', - role: 'administrator', - email: null, - idp_identifier: 'user_1', - beta_tester: false, - }, - created_at: '2024-04-29T15:00:00Z', - }, - { - user: { - id: 2, - name: 'user_2', - role: 'user', - email: null, - idp_identifier: 'user_2', - beta_tester: false, - }, - created_at: '2024-04-29T15:00:00Z', - }, - ], - }), - }, - ], - }), - ], -}; -export const SessionSharedWithUserTerminatingSoon: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => - new MockUserSessionService({ - ...mockPersistentSession, - connection_method: { - ...mockHttpConnectionMethod, - sharing: { enabled: true }, - }, - idle_state: { - available: true, - terminate_after_minutes: 90, - idle_for_minutes: 80, - unavailable_reason: null, - }, - shared_with: [ - { - user: { - id: 1, - name: 'user_1', - role: 'administrator', - email: null, - idp_identifier: 'user_1', - beta_tester: false, - }, - created_at: '2024-04-29T15:00:00Z', - }, - { - user: { - id: 2, - name: 'user_2', - role: 'user', - email: null, - idp_identifier: 'user_2', - beta_tester: false, - }, - created_at: '2024-04-29T15:00:00Z', - }, - ], - }), - }, - ], - }), - ], -}; - -export const SharedSession: Story = { - args: {}, - decorators: [ - moduleMetadata({ - providers: [ - { - provide: UserSessionService, - useFactory: () => new MockUserSessionService(mockPersistentSession), - }, - mockOwnUserWrapperServiceProvider({ ...mockUser, id: 2 }), + mockUserSessionServiceProvider([ + mockReadonlySession, + mockTrainingSession, + { + ...mockPersistentSession, + project: mockProject, + state: SessionState.Pending, + }, + ]), ], }), ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const training = canvas.getByTestId('training-expansion-1'); + await userEvent.click(training); + }, }; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.component.html b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.component.html new file mode 100644 index 0000000000..0705b23d48 --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.component.html @@ -0,0 +1,155 @@ + + +@let sessionState = + sessionService.beautifyState(session.preparation_state, session.state); + +
+
+ @if (!hideActions) { + + } +
+

+ {{ session.version.tool.name }} ({{ session.version.name }}) + + via + {{ session.connection_method?.name }} + +

+
+ @if (isPersistentSession(session)) { + Persistent workspace session created + + } @else if (isReadonlySession(session)) { + Read-Only session created + + } +
+
+ +
+ @if ( + (feedbackService.feedbackConfig$ | async)?.on_session_card && + !hideActions + ) { + + } +
+
+ +
+

+ {{ sessionState.icon }} + {{ sessionState.text }} +

+ @let remainingMinutes = minutesUntilSessionTermination(session); + @if (remainingMinutes !== null && remainingMinutes < 30) { +
+ warning +
+ This session will automatically terminate + + if you do not interact with it. +
+
+ } + + @if (session.shared_with.length > 0 && !isSessionShared(session)) { +
+ share +
+ You're sharing this session with + @for ( + sharedSession of session.shared_with; + track sharedSession.user.id + ) { + + {{ sharedSession.user.name }} + + @if (!$last) { + + + } + } +
+
+ } + + @if (isSessionShared(session)) { +
+ share +
+ This session is shared with you and was created by + {{ session.owner.name }} + +
+
+ } +
+ @if (!hideActions) { +
+ + + @if (!isSessionShared(session)) { + + + + + + } +
+ } +
diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.component.ts new file mode 100644 index 0000000000..63928978f1 --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.component.ts @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { NgClass, AsyncPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { addMinutes, differenceInMinutes } from 'date-fns'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { RelativeTimeComponent } from 'src/app/general/relative-time/relative-time.component'; +import { Session } from 'src/app/openapi'; +import { OwnUserWrapperService } from 'src/app/services/user/user.service'; +import { DeleteSessionDialogComponent } from 'src/app/sessions/delete-session-dialog/delete-session-dialog.component'; +import { FeedbackWrapperService } from 'src/app/sessions/feedback/feedback.service'; +import { + isPersistentSession, + isReadonlySession, + SessionService, +} from 'src/app/sessions/service/session.service'; +import { UserSessionService } from 'src/app/sessions/service/user-session.service'; +import { SessionWithSelection } from 'src/app/sessions/user-sessions-wrapper/active-sessions/active-sessions.component'; +import { ConnectionDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/connection-dialog/connection-dialog.component'; +import { FileBrowserDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/file-browser-dialog/file-browser-dialog.component'; +import { SessionSharingDialogComponent } from 'src/app/sessions/user-sessions-wrapper/active-sessions/session-sharing-dialog/session-sharing-dialog.component'; + +@Component({ + selector: 'app-session-card', + standalone: true, + imports: [ + NgxSkeletonLoaderModule, + RouterLink, + MatIcon, + NgClass, + MatButton, + AsyncPipe, + MatIconButton, + MatTooltip, + MatCheckboxModule, + FormsModule, + RelativeTimeComponent, + ], + templateUrl: './session-card.component.html', + styles: ` + .error { + @apply bg-error; + } + + .warning { + @apply bg-warning; + } + + .success { + @apply bg-success; + } + + .primary { + @apply bg-primary; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SessionCardComponent { + @Input({ required: true }) session!: SessionWithSelection; + @Input() hideActions = false; + + isReadonlySession = isReadonlySession; + isPersistentSession = isPersistentSession; + + constructor( + public sessionService: SessionService, + public feedbackService: FeedbackWrapperService, + private userSessionService: UserSessionService, + private userWrapperService: OwnUserWrapperService, + private dialog: MatDialog, + ) {} + + uploadFileDialog(session: Session): void { + this.dialog.open(FileBrowserDialogComponent, { data: session }); + } + + openDeletionDialog(sessions: Session[]): void { + const dialogRef = this.dialog.open(DeleteSessionDialogComponent, { + data: sessions, + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + if (this.feedbackService.shouldShowPostSessionPrompt()) { + this.feedbackService.showDialog( + sessions, + 'After session termination', + ); + } + + this.userSessionService.loadSessions(); + } + }); + } + + openShareDialog(session: Session): void { + this.dialog.open(SessionSharingDialogComponent, { + data: session, + }); + } + + isSessionShared(session: Session): boolean { + return session.owner.id != this.userWrapperService.user?.id; + } + + openConnectDialog(session: Session): void { + this.dialog.open(ConnectionDialogComponent, { + data: session, + }); + } + + minutesUntilSessionTermination(session: Session): number | null { + if (session.idle_state.available) { + if (session.idle_state.idle_for_minutes === -1) { + // session was never connected to, use creation time + return ( + session.idle_state.terminate_after_minutes - + differenceInMinutes(session.created_at, Date.now()) + ); + } else { + return ( + session.idle_state.terminate_after_minutes - + session.idle_state.idle_for_minutes! + ); + } + } else { + return null; + } + } + + protected readonly Date = Date; + protected readonly addMinutes = addMinutes; + protected readonly differenceInMinutes = differenceInMinutes; +} diff --git a/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.stories.ts b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.stories.ts new file mode 100644 index 0000000000..8c02f19d3d --- /dev/null +++ b/frontend/src/app/sessions/user-sessions-wrapper/active-sessions/session-card/session-card.stories.ts @@ -0,0 +1,279 @@ +/* + * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + Meta, + StoryObj, + componentWrapperDecorator, + moduleMetadata, +} from '@storybook/angular'; +import MockDate from 'mockdate'; +import { SessionPreparationState, SessionState } from 'src/app/openapi'; +import { + mockFeedbackConfig, + mockFeedbackWrapperServiceProvider, +} from 'src/storybook/feedback'; +import { + mockPersistentSession, + mockReadonlySession, +} from 'src/storybook/session'; +import { mockHttpConnectionMethod } from 'src/storybook/tool'; +import { + mockOwnUserWrapperServiceProvider, + mockUser, +} from 'src/storybook/user'; +import { SessionCardComponent } from './session-card.component'; + +const meta: Meta = { + title: 'Session Components/Session Card', + component: SessionCardComponent, + decorators: [ + moduleMetadata({ + providers: [mockOwnUserWrapperServiceProvider(mockUser)], + }), + componentWrapperDecorator( + (story) => `
${story}
`, + ), + ], + beforeEach: () => { + MockDate.set(new Date('2024-05-01')); + }, +}; + +export default meta; +type Story = StoryObj; + +export const SessionNotFoundState: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + preparation_state: SessionPreparationState.NotFound, + state: SessionState.NotFound, + }, + }, +}; + +export const SessionPreparationPendingState: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + preparation_state: SessionPreparationState.Pending, + state: SessionState.Pending, + }, + }, +}; + +export const SessionPreparationRunningState: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + preparation_state: SessionPreparationState.Running, + state: SessionState.Pending, + }, + }, +}; + +export const SessionRunningState: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + preparation_state: SessionPreparationState.Completed, + state: SessionState.Running, + }, + }, +}; + +export const SessionTerminatingSoon: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + preparation_state: SessionPreparationState.Completed, + state: SessionState.Running, + idle_state: { + available: true, + terminate_after_minutes: 90, + idle_for_minutes: 80, + unavailable_reason: null, + }, + }, + }, +}; + +export const SessionTerminatedState: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + preparation_state: SessionPreparationState.Completed, + state: SessionState.Terminated, + }, + }, +}; + +export const SessionPendingState: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + preparation_state: SessionPreparationState.Completed, + state: SessionState.Pending, + }, + }, +}; + +export const SessionFailedState: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + preparation_state: SessionPreparationState.Completed, + state: SessionState.Failed, + }, + }, +}; + +export const SessionUnknownState: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + preparation_state: SessionPreparationState.Completed, + state: SessionState.Unknown, + }, + }, +}; + +export const SessionWithFeedbackEnabled: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + }, + }, + decorators: [ + moduleMetadata({ + providers: [mockFeedbackWrapperServiceProvider(mockFeedbackConfig)], + }), + ], +}; + +export const ReadonlySessionSuccessState: Story = { + args: { + session: { + selected: false, + ...mockReadonlySession, + }, + }, +}; + +export const SessionSharingEnabled: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + connection_method: { + ...mockHttpConnectionMethod, + sharing: { enabled: true }, + }, + }, + }, +}; + +export const SessionSharedWithUser: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + connection_method: { + ...mockHttpConnectionMethod, + sharing: { enabled: true }, + }, + shared_with: [ + { + user: { + id: 1, + name: 'user_1', + role: 'administrator', + email: null, + idp_identifier: 'user_1', + beta_tester: false, + }, + created_at: '2024-04-29T15:00:00Z', + }, + { + user: { + id: 2, + name: 'user_2', + role: 'user', + email: null, + idp_identifier: 'user_2', + beta_tester: false, + }, + created_at: '2024-04-29T15:00:00Z', + }, + ], + }, + }, +}; +export const SessionSharedWithUserTerminatingSoon: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + connection_method: { + ...mockHttpConnectionMethod, + sharing: { enabled: true }, + }, + idle_state: { + available: true, + terminate_after_minutes: 90, + idle_for_minutes: 80, + unavailable_reason: null, + }, + shared_with: [ + { + user: { + id: 1, + name: 'user_1', + role: 'administrator', + email: null, + idp_identifier: 'user_1', + beta_tester: false, + }, + created_at: '2024-04-29T15:00:00Z', + }, + { + user: { + id: 2, + name: 'user_2', + role: 'user', + email: null, + idp_identifier: 'user_2', + beta_tester: false, + }, + created_at: '2024-04-29T15:00:00Z', + }, + ], + }, + }, +}; + +export const SharedSession: Story = { + args: { + session: { + selected: false, + ...mockPersistentSession, + }, + }, + decorators: [ + moduleMetadata({ + providers: [mockOwnUserWrapperServiceProvider({ ...mockUser, id: 2 })], + }), + ], +}; diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts index 30715c6d0f..812f6bbf8e 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-persistent-session/create-persistent-session.component.ts @@ -19,7 +19,7 @@ import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; import { MatSelect } from '@angular/material/select'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { map, Observable } from 'rxjs'; -import { Session, Tool, ToolVersion } from 'src/app/openapi'; +import { Session, SessionType, Tool, ToolVersion } from 'src/app/openapi'; import { SessionService } from 'src/app/sessions/service/session.service'; import { UserSessionService } from 'src/app/sessions/service/user-session.service'; import { @@ -92,13 +92,13 @@ export class CreatePersistentSessionComponent implements OnInit { this.requestInProgress = true; this.sessionService - .createSession( - this.toolSelectionForm.controls.toolId.value!, - this.toolSelectionForm.controls.versionId.value!, - this.toolSelectionForm.controls.connectionMethodId.value!, - 'persistent', - [], - ) + .createSession({ + tool_id: this.toolSelectionForm.controls.toolId.value!, + version_id: this.toolSelectionForm.controls.versionId.value!, + connection_method_id: + this.toolSelectionForm.controls.connectionMethodId.value!, + session_type: SessionType.Persistent, + }) .subscribe({ next: () => { this.userSessionService.loadSessions(); diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-readonly-session/create-readonly-session-dialog/create-readonly-session-dialog.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-readonly-session/create-readonly-session-dialog/create-readonly-session-dialog.component.ts index 9f09085fc2..92ed51d145 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-readonly-session/create-readonly-session-dialog/create-readonly-session-dialog.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-readonly-session/create-readonly-session-dialog/create-readonly-session-dialog.component.ts @@ -18,7 +18,7 @@ import { MatRadioButton, MatRadioGroup } from '@angular/material/radio'; import { Router } from '@angular/router'; import { UntilDestroy } from '@ngneat/until-destroy'; import { ToastService } from 'src/app/helpers/toast/toast.service'; -import { Tool, ToolModel, ToolVersion } from 'src/app/openapi'; +import { SessionType, Tool, ToolModel, ToolVersion } from 'src/app/openapi'; import { getPrimaryGitModel } from 'src/app/projects/models/service/model.service'; import { SessionService } from 'src/app/sessions/service/session.service'; import { @@ -112,12 +112,13 @@ export class CreateReadonlySessionDialogComponent implements OnInit { } this.sessionService - .createSession( - this.data.tool.id, - this.data.toolVersion.id, - this.form.controls.connectionMethodId.value!, - 'readonly', - included.map((m) => { + .createSession({ + tool_id: this.data.tool.id, + version_id: this.data.toolVersion.id, + connection_method_id: this.form.controls.connectionMethodId.value!, + session_type: SessionType.Readonly, + project_slug: this.data.projectSlug, + provisioning: included.map((m) => { return { project_slug: this.data.projectSlug, model_slug: m.model.slug, @@ -126,7 +127,7 @@ export class CreateReadonlySessionDialogComponent implements OnInit { deep_clone: m.deepClone, }; }), - ) + }) .subscribe({ next: (session) => { this.dialog.close(); diff --git a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.ts b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.ts index 74a3925edb..8f8b965191 100644 --- a/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.ts +++ b/frontend/src/app/sessions/user-sessions-wrapper/create-sessions/create-session-history/create-session-history.component.ts @@ -7,7 +7,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Subscription, filter, take } from 'rxjs'; -import { Tool, ToolVersion } from 'src/app/openapi'; +import { SessionType, Tool, ToolVersion } from 'src/app/openapi'; import { SessionService } from 'src/app/sessions/service/session.service'; import { SessionHistoryService, @@ -117,13 +117,12 @@ export class CreateSessionHistoryComponent implements OnInit, OnDestroy { requestSession(session: ResolvedSessionRequestHistory) { session.loading = true; this.sessionService - .createSession( - session.tool.id, - session.version.id, - session.connectionMethod.id!, - 'persistent', - [], - ) + .createSession({ + tool_id: session.tool.id, + version_id: session.version.id, + connection_method_id: session.connectionMethod.id!, + session_type: SessionType.Persistent, + }) .subscribe({ next: () => { session.loading = false; diff --git a/frontend/src/app/settings/core/alert-settings/alert-settings.component.css b/frontend/src/app/settings/core/alert-settings/alert-settings.component.css deleted file mode 100644 index ebecbca3bd..0000000000 --- a/frontend/src/app/settings/core/alert-settings/alert-settings.component.css +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -mat-card { - width: fit-content; - margin: 10px; - height: fit-content; -} - -mat-form-field { - margin-right: 10px; -} - -h1 { - margin: 10px; -} - -button { - margin-top: 5px; - display: block; -} - -.form-field { - width: 400px; - max-width: 85vw; -} diff --git a/frontend/src/app/settings/core/alert-settings/alert-settings.component.html b/frontend/src/app/settings/core/alert-settings/alert-settings.component.html index 5f90dbbeab..d5733041a2 100644 --- a/frontend/src/app/settings/core/alert-settings/alert-settings.component.html +++ b/frontend/src/app/settings/core/alert-settings/alert-settings.component.html @@ -7,12 +7,14 @@

Create new Alert

-
- - Title - - - +
+
+ + Title + + +
+ Level @for (noticeLevel of noticeLevels; track noticeLevel) { @@ -36,7 +38,7 @@

Create new Alert

-
+

Handle alerts

@if ((noticeWrapperService.notices$ | async) === undefined) { @for (_ of [0, 1, 2]; track $index) { @@ -60,20 +62,25 @@

Handle alerts

track notice.id ) { - + {{ notice.title }} {{ notice.level }} -

{{ notice.message }}

- +
+

{{ notice.message }}

+ +
} @empty { There are no existing alerts. diff --git a/frontend/src/app/settings/core/alert-settings/alert-settings.component.ts b/frontend/src/app/settings/core/alert-settings/alert-settings.component.ts index 7d1de6d009..7c2ec4ec81 100644 --- a/frontend/src/app/settings/core/alert-settings/alert-settings.component.ts +++ b/frontend/src/app/settings/core/alert-settings/alert-settings.component.ts @@ -31,7 +31,6 @@ import { @Component({ selector: 'app-alert-settings', templateUrl: './alert-settings.component.html', - styleUrls: ['./alert-settings.component.css'], imports: [ FormsModule, ReactiveFormsModule, diff --git a/frontend/src/app/settings/core/alert-settings/alert-settings.stories.ts b/frontend/src/app/settings/core/alert-settings/alert-settings.stories.ts index e2add0319c..c4032fcb65 100644 --- a/frontend/src/app/settings/core/alert-settings/alert-settings.stories.ts +++ b/frontend/src/app/settings/core/alert-settings/alert-settings.stories.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { userEvent, within } from '@storybook/test'; import { mockNotice, mockNoticeWrapperServiceProvider, @@ -43,3 +44,17 @@ export const SomeAlerts: Story = { }), ], }; + +export const AlertExpanded: Story = { + args: {}, + decorators: [ + moduleMetadata({ + providers: [mockNoticeWrapperServiceProvider([mockNotice])], + }), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const alert = canvas.getByTestId('alert-1'); + await userEvent.click(alert); + }, +}; diff --git a/frontend/src/storybook/session.ts b/frontend/src/storybook/session.ts index 78f9d3f7af..b5abb25c1c 100644 --- a/frontend/src/storybook/session.ts +++ b/frontend/src/storybook/session.ts @@ -3,44 +3,53 @@ * SPDX-License-Identifier: Apache-2.0 */ import { + ProjectType, Session, SessionPreparationState, SessionState, + SessionType, } from 'src/app/openapi'; -import { mockHttpConnectionMethod, mockToolVersionWithTool } from './tool'; +import { mockProject } from 'src/storybook/project'; +import { + mockHttpConnectionMethod, + mockToolVersionWithTool, + mockTrainingControllerVersionWithTool, +} from './tool'; import { mockUser } from './user'; -export const mockPersistentSession = createPersistentSessionWithState( - SessionPreparationState.Completed, - SessionState.Running, -); +export const mockPersistentSession = { + id: 'vfurvsrldxfwwsqdiqvnufonh', + created_at: '2024-04-29T15:00:00Z', + idle_state: { + available: true, + terminate_after_minutes: 90, + idle_for_minutes: 30, + unavailable_reason: null, + }, + type: SessionType.Persistent, + version: mockToolVersionWithTool, + preparation_state: SessionPreparationState.Completed, + state: SessionState.Running, + owner: mockUser, + connection_method: { ...mockHttpConnectionMethod, name: 'Xpra' }, + warnings: [], + connection_method_id: 'default', + shared_with: [], + project: null, +}; export const mockReadonlySession: Readonly = { ...mockPersistentSession, - type: 'readonly', + type: SessionType.Readonly, }; -export function createPersistentSessionWithState( - preparationState: SessionPreparationState, - state: SessionState, -): Session { - return { - id: 'vfurvsrldxfwwsqdiqvnufonh', - created_at: '2024-04-29T15:00:00Z', - idle_state: { - available: true, - terminate_after_minutes: 90, - idle_for_minutes: 30, - unavailable_reason: null, - }, - type: 'persistent', - version: mockToolVersionWithTool, - preparation_state: preparationState, - state: state, - owner: mockUser, - connection_method: mockHttpConnectionMethod, - warnings: [], - connection_method_id: 'default', - shared_with: [], - }; -} +export const mockTrainingSession: Readonly = { + ...mockPersistentSession, + project: { + ...mockProject, + type: ProjectType.Training, + name: 'PVMT Training', + }, + version: mockTrainingControllerVersionWithTool, + connection_method: mockHttpConnectionMethod, +}; diff --git a/frontend/src/storybook/tool.ts b/frontend/src/storybook/tool.ts index a1eb598614..45205768f1 100644 --- a/frontend/src/storybook/tool.ts +++ b/frontend/src/storybook/tool.ts @@ -65,7 +65,7 @@ export const mockToolNature: Readonly = { const defaultToolConfig: ToolSessionConfigurationOutput = { connection: { - methods: [{ ...mockHttpConnectionMethod, type: 'http', environment: {} }], + methods: [mockHttpConnectionMethod], }, provisioning: { directory: '/tmp', @@ -124,3 +124,9 @@ export const mockToolVersionWithTool: Readonly = { ...mockCapellaToolVersion, tool: mockCapellaTool, }; + +export const mockTrainingControllerVersionWithTool: Readonly = + { + ...mockOtherToolVersion, + tool: mockTrainingControllerTool, + }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d876744460..55eb8f05f8 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -277,6 +277,19 @@ Angular Material Card Styles background-color: black !important; } +.mat-expansion-indicator::after { + border-color: black; + margin-top: -6px; +} + +.mat-expansion-panel-body { + padding: 0 !important; +} + +.mat-expansion-panel-header { + padding: 0 12px !important; +} + /* https://github.com/angular/components/issues/26176 */ .mat-mdc-button-touch-target { @apply !hidden;