From cc47d3c71be2b096e6c946936ec622e1385bc295 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 09:15:02 +0200 Subject: [PATCH 01/26] fix(shared-model): export gql model --- libs/shared/model/src/index.ts | 1 + libs/shared/model/src/lib/gql.model.ts | 387 ++++++++++++++++++++++++- 2 files changed, 381 insertions(+), 7 deletions(-) diff --git a/libs/shared/model/src/index.ts b/libs/shared/model/src/index.ts index d7df1c49..6a2986ae 100644 --- a/libs/shared/model/src/index.ts +++ b/libs/shared/model/src/index.ts @@ -1,2 +1,3 @@ export { default as AuthUser } from './lib/auth-user.model'; export { Role } from './lib/role.enum'; +export * from './lib/gql.model'; diff --git a/libs/shared/model/src/lib/gql.model.ts b/libs/shared/model/src/lib/gql.model.ts index 711df25e..5b72ef7b 100644 --- a/libs/shared/model/src/lib/gql.model.ts +++ b/libs/shared/model/src/lib/gql.model.ts @@ -25,11 +25,19 @@ export type Scalars = { Boolean: { input: boolean; output: boolean }; Int: { input: number; output: number }; Float: { input: number; output: number }; + /** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */ + DateTime: { input: any; output: any }; }; -export type AppData = { - __typename?: 'AppData'; - message: Scalars['String']['output']; +export type AlertGroup = { + __typename?: 'AlertGroup'; + assignment?: Maybe; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + name: Scalars['String']['output']; + orgId: Scalars['String']['output']; + units: Array; + updatedAt?: Maybe; }; export type BBox = { @@ -43,6 +51,32 @@ export type BBoxInput = { topLeft: CoordinateInput; }; +export type BaseCreateMessageInput = { + channel: Scalars['String']['input']; + recipient: UnitInput; + sender: UnitInput; +}; + +export type CommunicationMessage = { + __typename?: 'CommunicationMessage'; + channel: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + id: Scalars['ID']['output']; + orgId: Scalars['String']['output']; + payload: CommunicationMessagePayload; + producer: UserProducer; + recipient: UnitUnion; + searchableText: Scalars['String']['output']; + sender: UnitUnion; + time: Scalars['DateTime']['output']; + updatedAt?: Maybe; +}; + +export type CommunicationMessagePayload = { + __typename?: 'CommunicationMessagePayload'; + message: Scalars['String']['output']; +}; + export type Coordinate = { __typename?: 'Coordinate'; lat: Scalars['Float']['output']; @@ -54,10 +88,72 @@ export type CoordinateInput = { lon: Scalars['Float']['input']; }; +export type DeploymentAlertGroup = { + __typename?: 'DeploymentAlertGroup'; + alertGroup: AlertGroup; + assignedUnits: Array; +}; + +export type DeploymentAssignment = DeploymentAlertGroup | DeploymentUnit; + +export type DeploymentUnit = { + __typename?: 'DeploymentUnit'; + unit: Unit; +}; + +export type EntityRescueStationAssignment = { + __typename?: 'EntityRescueStationAssignment'; + callSign: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + defaultUnits: Array; + id: Scalars['ID']['output']; + location: RescueStationLocation; + name: Scalars['String']['output']; + note: Scalars['String']['output']; + orgId: Scalars['String']['output']; + signedIn: Scalars['Boolean']['output']; + strength: RescueStationStrength; + updatedAt?: Maybe; +}; + +export type FurtherAttribute = { + __typename?: 'FurtherAttribute'; + name: Scalars['String']['output']; + value: Scalars['String']['output']; +}; + export type Mutation = { __typename?: 'Mutation'; + changeEmail: Scalars['Boolean']['output']; + changeRole: Scalars['Boolean']['output']; + createCommunicationMessage: CommunicationMessage; createOrganization: Organization; + createUser: UserEntity; + deactivateUser: Scalars['Boolean']['output']; + reactivateUser: Scalars['Boolean']['output']; + signInRescueStation: RescueStationDeployment; + signOffRescueStation: RescueStationDeployment; updateOrganizationGeoSettings: Organization; + updateSignedInRescueStation: RescueStationDeployment; + updateUnitNote: Unit; + updateUnitStatus: Unit; +}; + +export type MutationChangeEmailArgs = { + newEmail: Scalars['String']['input']; + userId: Scalars['String']['input']; +}; + +export type MutationChangeRoleArgs = { + newRole: Role; + userId: Scalars['String']['input']; +}; + +export type MutationCreateCommunicationMessageArgs = { + channel: Scalars['String']['input']; + message: Scalars['String']['input']; + recipient: UnitInput; + sender: UnitInput; }; export type MutationCreateOrganizationArgs = { @@ -65,18 +161,60 @@ export type MutationCreateOrganizationArgs = { name: Scalars['String']['input']; }; +export type MutationCreateUserArgs = { + email: Scalars['String']['input']; + firstName: Scalars['String']['input']; + lastName: Scalars['String']['input']; + role: Role; + username: Scalars['String']['input']; +}; + +export type MutationDeactivateUserArgs = { + userId: Scalars['String']['input']; +}; + +export type MutationReactivateUserArgs = { + userId: Scalars['String']['input']; +}; + +export type MutationSignInRescueStationArgs = { + protocolMessageData: BaseCreateMessageInput; + rescueStationData: UpdateRescueStationInput; +}; + +export type MutationSignOffRescueStationArgs = { + protocolMessageData: BaseCreateMessageInput; + rescueStationId: Scalars['String']['input']; +}; + export type MutationUpdateOrganizationGeoSettingsArgs = { geoSettings: OrganizationGeoSettingsInput; id: Scalars['String']['input']; }; +export type MutationUpdateSignedInRescueStationArgs = { + protocolMessageData?: InputMaybe; + rescueStationData: UpdateRescueStationInput; +}; + +export type MutationUpdateUnitNoteArgs = { + note: Scalars['String']['input']; + unitId: Scalars['String']['input']; +}; + +export type MutationUpdateUnitStatusArgs = { + status: Scalars['Int']['input']; + unitId: Scalars['String']['input']; +}; + export type Organization = { __typename?: 'Organization'; - createdAt: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; geoSettings: OrganizationGeoSettings; - id: Scalars['String']['output']; + id: Scalars['ID']['output']; name: Scalars['String']['output']; - updatedAt: Scalars['String']['output']; + orgId: Scalars['String']['output']; + updatedAt?: Maybe; }; export type OrganizationGeoSettings = { @@ -90,12 +228,247 @@ export type OrganizationGeoSettingsInput = { centroid: CoordinateInput; }; +export type PageInfo = { + __typename?: 'PageInfo'; + endCursor?: Maybe; + hasNextPage: Scalars['Boolean']['output']; + hasPreviousPage: Scalars['Boolean']['output']; + startCursor?: Maybe; + totalEdges?: Maybe; +}; + +export type ProtocolEntryConnection = { + __typename?: 'ProtocolEntryConnection'; + edges: Array; + pageInfo: PageInfo; +}; + +export type ProtocolEntryEdge = { + __typename?: 'ProtocolEntryEdge'; + /** An opaque cursor that can be used to retrieve further pages of edges before or after this one. */ + cursor: Scalars['String']['output']; + /** The node object (belonging to type ProtocolEntryUnion) attached to the edge. */ + node: ProtocolEntryUnion; +}; + +export type ProtocolEntryUnion = CommunicationMessage; + export type Query = { __typename?: 'Query'; - data: AppData; + alertGroup: AlertGroup; + alertGroups: Array; organization: Organization; + /** Returns protocol entries sorted by time desc. */ + protocolEntries: ProtocolEntryConnection; + rescueStationDeployments: Array; + unassignedEntities: Array; + unit: Unit; + units: Array; + userLoginHistory: Array; + users: Array; +}; + +export type QueryAlertGroupArgs = { + id: Scalars['String']['input']; }; export type QueryOrganizationArgs = { id: Scalars['String']['input']; }; + +export type QueryProtocolEntriesArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}; + +export type QueryRescueStationDeploymentsArgs = { + signedIn?: InputMaybe; +}; + +export type QueryUnitArgs = { + id: Scalars['String']['input']; +}; + +export type QueryUserLoginHistoryArgs = { + historyLength?: Scalars['Float']['input']; + userId: Scalars['String']['input']; +}; + +export type RegisteredUnit = { + __typename?: 'RegisteredUnit'; + unit: Unit; +}; + +export type RescueStationAddress = { + __typename?: 'RescueStationAddress'; + city: Scalars['String']['output']; + postalCode: Scalars['String']['output']; + street: Scalars['String']['output']; +}; + +export type RescueStationDeployment = { + __typename?: 'RescueStationDeployment'; + assignments: Array; + callSign: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + defaultUnits: Array; + id: Scalars['ID']['output']; + location: RescueStationLocation; + name: Scalars['String']['output']; + note: Scalars['String']['output']; + orgId: Scalars['String']['output']; + signedIn: Scalars['Boolean']['output']; + strength: RescueStationStrength; + updatedAt?: Maybe; +}; + +export type RescueStationLocation = { + __typename?: 'RescueStationLocation'; + address: RescueStationAddress; + coordinate: Coordinate; +}; + +export type RescueStationMessageAssignedAlertGroup = { + __typename?: 'RescueStationMessageAssignedAlertGroup'; + id: Scalars['String']['output']; + name: Scalars['String']['output']; + units: Array; +}; + +export type RescueStationMessageAssignedUnit = { + __typename?: 'RescueStationMessageAssignedUnit'; + callSign: Scalars['String']['output']; + id: Scalars['String']['output']; + name: Scalars['String']['output']; +}; + +export type RescueStationMessagePayload = { + __typename?: 'RescueStationMessagePayload'; + alertGroups: Array; + rescueStationCallSign: Scalars['String']['output']; + rescueStationId: Scalars['String']['output']; + rescueStationName: Scalars['String']['output']; + strength: RescueStationMessageStrength; + units: Array; +}; + +export type RescueStationMessageStrength = { + __typename?: 'RescueStationMessageStrength'; + helpers: Scalars['Float']['output']; + leaders: Scalars['Float']['output']; + subLeaders: Scalars['Float']['output']; +}; + +export type RescueStationSignOffMessagePayload = { + __typename?: 'RescueStationSignOffMessagePayload'; + rescueStationCallSign: Scalars['String']['output']; + rescueStationId: Scalars['String']['output']; + rescueStationName: Scalars['String']['output']; +}; + +export type RescueStationStrength = { + __typename?: 'RescueStationStrength'; + helpers: Scalars['Float']['output']; + leaders: Scalars['Float']['output']; + subLeaders: Scalars['Float']['output']; +}; + +export enum Role { + Admin = 'ADMIN', + OrganizationAdmin = 'ORGANIZATION_ADMIN', + User = 'USER', +} + +export type Subscription = { + __typename?: 'Subscription'; + currentUserDeactivated: UserDeactivated; + rescueStationSignedIn: RescueStationDeployment; + rescueStationSignedOff: RescueStationDeployment; + signedInRescueStationUpdated: RescueStationDeployment; + unitStatusUpdated: Unit; +}; + +export type Unit = { + __typename?: 'Unit'; + alertGroup?: Maybe; + assignment?: Maybe; + callSign: Scalars['String']['output']; + callSignAbbreviation: Scalars['String']['output']; + createdAt: Scalars['DateTime']['output']; + department: Scalars['String']['output']; + furtherAttributes: Array; + id: Scalars['ID']['output']; + name: Scalars['String']['output']; + note: Scalars['String']['output']; + orgId: Scalars['String']['output']; + rcsId: Scalars['String']['output']; + status?: Maybe; + updatedAt?: Maybe; +}; + +export type UnitInput = { + id?: InputMaybe; + name?: InputMaybe; + type: Scalars['String']['input']; +}; + +export type UnitStatus = { + __typename?: 'UnitStatus'; + receivedAt: Scalars['String']['output']; + source: Scalars['String']['output']; + status: Scalars['Float']['output']; +}; + +export type UnitUnion = RegisteredUnit | UnknownUnit; + +export type UnknownUnit = { + __typename?: 'UnknownUnit'; + name: Scalars['String']['output']; +}; + +export type UpdateRescueStationAssignedAlertGroup = { + alertGroupId: Scalars['String']['input']; + unitIds: Array; +}; + +export type UpdateRescueStationInput = { + /** The Alert Groups to assign. If a Unit is currently assigned to another Rescue Station it will be released first. If the alert group is assigned to another deployment, it will be released and units that are not assigned within the new assignment will be kept as normally assigned units in the deployment. Alert Groups and Units currently assigned to an operation will result in an error! */ + assignedAlertGroups: Array; + /** The Units to assign. If a Unit is currently assigned to another Rescue Station it will be released first. Units currently assigned to an operation will result in an error! */ + assignedUnitIds: Array; + note?: Scalars['String']['input']; + rescueStationId: Scalars['String']['input']; + strength: UpdateRescueStationStrength; +}; + +export type UpdateRescueStationStrength = { + helpers: Scalars['Float']['input']; + leaders: Scalars['Float']['input']; + subLeaders: Scalars['Float']['input']; +}; + +export type UserDeactivated = { + __typename?: 'UserDeactivated'; + userId?: Maybe; +}; + +export type UserEntity = { + __typename?: 'UserEntity'; + deactivated: Scalars['Boolean']['output']; + email: Scalars['String']['output']; + firstName: Scalars['String']['output']; + id: Scalars['ID']['output']; + lastName: Scalars['String']['output']; + organizationId: Scalars['String']['output']; + role: Scalars['String']['output']; + userName: Scalars['String']['output']; +}; + +export type UserProducer = { + __typename?: 'UserProducer'; + firstName: Scalars['String']['output']; + lastName: Scalars['String']['output']; + userId: Scalars['String']['output']; +}; From 2b2b18a37a7682e6b05ce8ebce1aa71b7ba23765 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 09:16:55 +0200 Subject: [PATCH 02/26] chore(spa-graphql): make gql variables in operation optional --- libs/spa/core/graphql/src/lib/graphql.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/spa/core/graphql/src/lib/graphql.service.ts b/libs/spa/core/graphql/src/lib/graphql.service.ts index 6ef29ec1..6b396676 100644 --- a/libs/spa/core/graphql/src/lib/graphql.service.ts +++ b/libs/spa/core/graphql/src/lib/graphql.service.ts @@ -11,7 +11,7 @@ export class GraphqlService { queryOnce$( query: DocumentNode, - variables?: Record, + variables: Record = {}, ): Observable { return this.apollo .query({ @@ -23,10 +23,10 @@ export class GraphqlService { query( query: DocumentNode, - variables?: Record, + variables: Record = {}, ): { $: Observable; - refresh: (variables: Record) => Promise; + refresh: (variables?: Record) => Promise; } { const queryRef = this.apollo.watchQuery({ query, @@ -35,7 +35,7 @@ export class GraphqlService { return { $: queryRef.valueChanges.pipe(map(({ data }) => data)), - refresh: (variables: Record): Promise => + refresh: (variables?: Record): Promise => queryRef.refetch(variables).then(({ data }) => data), }; } From 54f8c318e91d64446d70f1579880b09ec268c1c2 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 09:19:35 +0200 Subject: [PATCH 03/26] fix(api-protocol): import missing profile --- libs/api/protocol/src/lib/protocol.module.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libs/api/protocol/src/lib/protocol.module.ts b/libs/api/protocol/src/lib/protocol.module.ts index 2e4604e7..f8a064cf 100644 --- a/libs/api/protocol/src/lib/protocol.module.ts +++ b/libs/api/protocol/src/lib/protocol.module.ts @@ -35,7 +35,10 @@ import { import { ProtocolEntryMapper } from './mapper-profile/protocol-entry.mapper'; import { RescueStationMessagePayloadDocumentProfile } from './mapper-profile/rescue-station/rescue-station-message-payload-document.mapper-profile'; import { RescueStationMessagePayloadProfile } from './mapper-profile/rescue-station/rescue-station-message-payload-entity.mapper-profile'; -import { RescueStationSignOffMessageDocumentProfile } from './mapper-profile/rescue-station/rescue-station-sign-off-message-document.mapper-profile'; +import { + RescueStationSignOffMessageDocumentProfile, + RescueStationSignOffMessagePayloadDocumentProfile, +} from './mapper-profile/rescue-station/rescue-station-sign-off-message-document.mapper-profile'; import { RescueStationSignOffMessageEntityProfile, RescueStationSignOffMessagePayloadEntityProfile, @@ -55,6 +58,7 @@ const MAPPER_PROFILES = [ ProtocolEntryMapper, RescueStationMessagePayloadDocumentProfile, RescueStationMessagePayloadProfile, + RescueStationSignOffMessagePayloadDocumentProfile, RescueStationSignOffMessageDocumentProfile, RescueStationSignOffMessageEntityProfile, RescueStationSignOffMessagePayloadEntityProfile, From bce1ac83e4387f6660ab11ddc867dc07ee1fdac7 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 09:20:23 +0200 Subject: [PATCH 04/26] feat(api-protocol): make protocol entries for rescue station updates optional --- ...-in-rescue-station-process.command.spec.ts | 71 ++++++++++++++++++- ...igned-in-rescue-station-process.command.ts | 36 +++++----- .../controller/rescue-station.resolver.ts | 11 ++- 3 files changed, 97 insertions(+), 21 deletions(-) diff --git a/libs/api/rescue-station-manager/src/lib/core/command/launch-update-signed-in-rescue-station-process.command.spec.ts b/libs/api/rescue-station-manager/src/lib/core/command/launch-update-signed-in-rescue-station-process.command.spec.ts index 3f1aef17..2575ed51 100644 --- a/libs/api/rescue-station-manager/src/lib/core/command/launch-update-signed-in-rescue-station-process.command.spec.ts +++ b/libs/api/rescue-station-manager/src/lib/core/command/launch-update-signed-in-rescue-station-process.command.spec.ts @@ -1,8 +1,9 @@ -import { createMock } from '@golevelup/ts-jest'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; import { CommandBus, CqrsModule, EventBus } from '@nestjs/cqrs'; import { Test, TestingModule } from '@nestjs/testing'; import { UpdateSignedInRescueStationCommand } from '@kordis/api/deployment'; +import { CreateRescueStationSignOnMessageCommand } from '@kordis/api/protocol'; import { AuthUser } from '@kordis/shared/model'; import { SignedInRescueStationUpdatedEvent } from '../event/signed-in-rescue-station-updated.event'; @@ -47,6 +48,7 @@ describe('LaunchUpdateSignedInRescueStationProcessHandler', () => { let handler: LaunchUpdateSignedInRescueStationProcessHandler; let commandBus: CommandBus; let eventBus: EventBus; + let mockMessageCommandRescueStationDetailsFactory: DeepMocked; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -68,9 +70,50 @@ describe('LaunchUpdateSignedInRescueStationProcessHandler', () => { ); commandBus = module.get(CommandBus); eventBus = module.get(EventBus); + mockMessageCommandRescueStationDetailsFactory = module.get< + DeepMocked + >(MessageCommandRescueStationDetailsFactory); }); - it('should fire UpdateSignedInRescueStationCommand', async () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fire UpdateSignedInRescueStationCommand and CreateRescueStationSignOnMessageCommand', async () => { + const rsDetails = { + id: 'mockId', + name: 'Mock Station Name', + callSign: 'MockCallSign', + strength: { + leaders: 2, + subLeaders: 1, + helpers: 3, + }, + units: [ + { + id: 'unitId2', + name: 'Unit Name 2', + callSign: 'UnitCallSign2', + }, + ], + alertGroups: [ + { + id: 'alertGroupId1', + name: 'Alert Group Name 1', + units: [ + { + id: 'unitId1', + name: 'Unit Name 1', + callSign: 'UnitCallSign1', + }, + ], + }, + ], + }; + mockMessageCommandRescueStationDetailsFactory.createFromCommandRescueStationData.mockResolvedValue( + rsDetails, + ); + await handler.execute(COMMAND); expect(commandBus.execute).toHaveBeenCalledWith( @@ -92,6 +135,30 @@ describe('LaunchUpdateSignedInRescueStationProcessHandler', () => { ], ), ); + + expect(commandBus.execute).toHaveBeenCalledWith( + new CreateRescueStationSignOnMessageCommand( + expect.any(Date), + { name: 'senderName' }, + { unit: { id: 'unitId' } }, + rsDetails, + 'channel', + { organizationId: 'orgId' } as AuthUser, + ), + ); + }); + + it('should not fire CreateRescueStationSignOnMessageCommand if communicationMessageData is null', async () => { + await handler.execute( + new LaunchUpdateSignedInRescueStationProcessCommand( + {} as any, + {} as any, + null, + ), + ); + expect(commandBus.execute).not.toHaveBeenCalledWith( + expect.any(CreateRescueStationSignOnMessageCommand), + ); }); it('should publish SignedInRescueStationUpdatedEvent after station update', async () => { diff --git a/libs/api/rescue-station-manager/src/lib/core/command/launch-update-signed-in-rescue-station-process.command.ts b/libs/api/rescue-station-manager/src/lib/core/command/launch-update-signed-in-rescue-station-process.command.ts index 0f36f57f..d1630089 100644 --- a/libs/api/rescue-station-manager/src/lib/core/command/launch-update-signed-in-rescue-station-process.command.ts +++ b/libs/api/rescue-station-manager/src/lib/core/command/launch-update-signed-in-rescue-station-process.command.ts @@ -7,7 +7,7 @@ import { import { UpdateSignedInRescueStationCommand } from '@kordis/api/deployment'; import { - CreateRescueStationSignOnMessageCommand, + CreateRescueStationUpdateMessageCommand, MessageUnit, } from '@kordis/api/protocol'; import { AuthUser } from '@kordis/shared/model'; @@ -24,7 +24,7 @@ export class LaunchUpdateSignedInRescueStationProcessCommand { sender: MessageUnit; recipient: MessageUnit; channel: string; - }, + } | null, ) {} } @@ -60,21 +60,23 @@ export class LaunchUpdateSignedInRescueStationProcessHandler ), ); - const rsDetails = - await this.messageCommandRescueStationDetailsFactory.createFromCommandRescueStationData( - reqUser.organizationId, - rescueStationData, - ); + if (communicationMessageData) { + const rsDetails = + await this.messageCommandRescueStationDetailsFactory.createFromCommandRescueStationData( + reqUser.organizationId, + rescueStationData, + ); - await this.commandBus.execute( - new CreateRescueStationSignOnMessageCommand( - new Date(), - communicationMessageData.sender, - communicationMessageData.recipient, - rsDetails, - communicationMessageData.channel, - reqUser, - ), - ); + await this.commandBus.execute( + new CreateRescueStationUpdateMessageCommand( + new Date(), + communicationMessageData.sender, + communicationMessageData.recipient, + rsDetails, + communicationMessageData.channel, + reqUser, + ), + ); + } } } diff --git a/libs/api/rescue-station-manager/src/lib/infra/controller/rescue-station.resolver.ts b/libs/api/rescue-station-manager/src/lib/infra/controller/rescue-station.resolver.ts index fba450c0..67da9258 100644 --- a/libs/api/rescue-station-manager/src/lib/infra/controller/rescue-station.resolver.ts +++ b/libs/api/rescue-station-manager/src/lib/infra/controller/rescue-station.resolver.ts @@ -27,14 +27,21 @@ export class RescueStationResolver { async updateSignedInRescueStation( @RequestUser() reqUser: AuthUser, @Args('rescueStationData') rescueStationData: UpdateRescueStationInput, - @Args('protocolMessageData') protocolMessageData: BaseCreateMessageArgs, + @Args('protocolMessageData', { + nullable: true, + type: () => BaseCreateMessageArgs, + }) + protocolMessageData: BaseCreateMessageArgs | null, ): Promise { try { + const protocolPayload = protocolMessageData + ? await protocolMessageData.asTransformedPayload() + : null; await this.commandBus.execute( new LaunchUpdateSignedInRescueStationProcessCommand( reqUser, rescueStationData, - await protocolMessageData.asTransformedPayload(), + protocolPayload, ), ); return this.queryBus.execute( From 7699c9445f73f492b0c0d42cdc24bb3eb919c1b6 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 09:23:42 +0200 Subject: [PATCH 05/26] chore(spa): allow property access from index signatures --- apps/spa/tsconfig.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/spa/tsconfig.json b/apps/spa/tsconfig.json index 174c1724..b6ee9bbe 100644 --- a/apps/spa/tsconfig.json +++ b/apps/spa/tsconfig.json @@ -1,11 +1,10 @@ { "compilerOptions": { - "target": "es2022", + "target": "ES2022", "useDefineForClassFields": false, "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "allowSyntheticDefaultImports": true From ade52d38d404a7249aa0f39d8a3fb14c7c734b38 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 09:26:03 +0200 Subject: [PATCH 06/26] fix(api-unit): updating of missing status not possible --- .../lib/core/command/update-unit-status.command.ts | 2 +- .../src/lib/infra/repository/unit.repository.spec.ts | 5 ++++- .../unit/src/lib/infra/repository/unit.repository.ts | 11 +++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/libs/api/unit/src/lib/core/command/update-unit-status.command.ts b/libs/api/unit/src/lib/core/command/update-unit-status.command.ts index 6df71a9a..8ff23419 100644 --- a/libs/api/unit/src/lib/core/command/update-unit-status.command.ts +++ b/libs/api/unit/src/lib/core/command/update-unit-status.command.ts @@ -54,7 +54,7 @@ export class UpdateUnitStatusHandler ); if (!updateSucceeded) { - this.logger.warn(`Unit ${unitId} status update failed: outdated status`); + this.logger.error(`Unit ${unitId} status update failed: outdated status`); throw new UnitStatusOutdatedException(); } diff --git a/libs/api/unit/src/lib/infra/repository/unit.repository.spec.ts b/libs/api/unit/src/lib/infra/repository/unit.repository.spec.ts index 3041291f..81b3bca7 100644 --- a/libs/api/unit/src/lib/infra/repository/unit.repository.spec.ts +++ b/libs/api/unit/src/lib/infra/repository/unit.repository.spec.ts @@ -200,7 +200,10 @@ describe('UnitRepositoryImpl', () => { { _id: 'id', orgId: 'orgId', - 'status.receivedAt': { $lt: status.receivedAt }, + $or: [ + { 'status.receivedAt': { $lt: status.receivedAt } }, + { status: null }, + ], }, { $set: { status } }, ); diff --git a/libs/api/unit/src/lib/infra/repository/unit.repository.ts b/libs/api/unit/src/lib/infra/repository/unit.repository.ts index 3b00c171..c910a23c 100644 --- a/libs/api/unit/src/lib/infra/repository/unit.repository.ts +++ b/libs/api/unit/src/lib/infra/repository/unit.repository.ts @@ -64,9 +64,16 @@ export class UnitRepositoryImpl implements UnitRepository { id: string, status: UnitStatus, ): Promise { - // we only want to update if the new status is newer than the current one const res = await this.unitModel.updateOne( - { _id: id, orgId, 'status.receivedAt': { $lt: status.receivedAt } }, + { + _id: id, + orgId, + $or: [ + // we only want to update if the new status is newer than the current one + { 'status.receivedAt': { $lt: status.receivedAt } }, + { status: null }, + ], + }, { $set: { status } }, ); From 877c77c49d14e9d38f8bf1294c5e5200c31b5c62 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 10:42:32 +0200 Subject: [PATCH 07/26] feat(spa-deployment): add deployments frontend --- libs/spa/feature/.gitkeep | 0 libs/spa/feature/deployment/.eslintrc.json | 39 +++ libs/spa/feature/deployment/README.md | 9 + libs/spa/feature/deployment/jest.config.ts | 22 ++ libs/spa/feature/deployment/project.json | 20 ++ libs/spa/feature/deployment/src/index.ts | 1 + .../deployment-alert-group.component.spec.ts | 28 ++ .../deployment-alert-group.component.ts | 49 ++++ .../deployment-unit-details.component.html | 54 ++++ .../deployment-unit-details.component.spec.ts | 38 +++ .../deployment-unit-details.component.ts | 133 +++++++++ .../deployment-unit.component.spec.ts | 41 +++ .../deployment/deployment-unit.component.ts | 89 ++++++ .../deployment/deployments.component.css | 33 +++ .../deployment/deployments.component.html | 42 +++ .../deployment/deployments.component.ts | 176 ++++++++++++ .../deployment/deplyoment-card.component.ts | 144 ++++++++++ .../deployment/status-badge.component.spec.ts | 31 +++ .../deployment/status-badge.component.ts | 77 +++++ .../alert-group-assignment.model.ts | 6 + .../alert-group-autocomplete.component.ts | 113 ++++++++ .../alert-group-selection.component.ts | 65 +++++ .../alert-group-selections.component.ts | 153 ++++++++++ .../component/protocol-data.component.ts | 66 +++++ .../component/strength.component.spec.ts | 33 +++ .../component/strength.component.ts | 106 +++++++ .../unit-selection-option.component.spec.ts | 27 ++ .../unit/unit-selection-option.component.ts | 61 ++++ .../unit/units-select.component.spec.ts | 44 +++ .../component/unit/units-select.component.ts | 159 +++++++++++ .../rescue-station-edit-modal.component.css | 32 +++ .../rescue-station-edit-modal.component.html | 79 ++++++ .../rescue-station-edit-modal.component.ts | 263 ++++++++++++++++++ .../service/alert-group-selection.service.ts | 49 ++++ .../possible-entity-selection.service.spec.ts | 80 ++++++ .../possible-entity-selection.service.ts | 59 ++++ .../rescue-station-edit.service.spec.ts | 105 +++++++ .../service/rescue-station-edit.service.ts | 152 ++++++++++ .../service/unit-selection.service.ts | 42 +++ .../alert-group-min-units.validator.spec.ts | 32 +++ .../alert-group-min-units.validator.ts | 18 ++ .../validator/min-strength.validator.spec.ts | 27 ++ .../validator/min-strength.validator.ts | 20 ++ .../services/alert-group-search.service.ts | 58 ++++ .../services/entity-search.service.spec.ts | 117 ++++++++ .../src/lib/services/entity-search.service.ts | 54 ++++ .../src/lib/services/unit-search.service.ts | 56 ++++ .../deployment/src/lib/status-explanations.ts | 9 + libs/spa/feature/deployment/src/test-setup.ts | 9 + libs/spa/feature/deployment/tsconfig.json | 28 ++ libs/spa/feature/deployment/tsconfig.lib.json | 17 ++ .../spa/feature/deployment/tsconfig.spec.json | 17 ++ .../src/lib/dashboard.component.html | 28 +- .../src/lib/dashboard.component.spec.ts | 4 - .../dashboard/src/lib/dashboard.component.ts | 2 + package-lock.json | 6 + package.json | 1 + tsconfig.base.json | 1 + 58 files changed, 3206 insertions(+), 18 deletions(-) delete mode 100644 libs/spa/feature/.gitkeep create mode 100644 libs/spa/feature/deployment/.eslintrc.json create mode 100644 libs/spa/feature/deployment/README.md create mode 100644 libs/spa/feature/deployment/jest.config.ts create mode 100644 libs/spa/feature/deployment/project.json create mode 100644 libs/spa/feature/deployment/src/index.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-assignment.model.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.css create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/alert-group-selection.service.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/unit-selection.service.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.ts create mode 100644 libs/spa/feature/deployment/src/lib/services/alert-group-search.service.ts create mode 100644 libs/spa/feature/deployment/src/lib/services/entity-search.service.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/services/entity-search.service.ts create mode 100644 libs/spa/feature/deployment/src/lib/services/unit-search.service.ts create mode 100644 libs/spa/feature/deployment/src/lib/status-explanations.ts create mode 100644 libs/spa/feature/deployment/src/test-setup.ts create mode 100644 libs/spa/feature/deployment/tsconfig.json create mode 100644 libs/spa/feature/deployment/tsconfig.lib.json create mode 100644 libs/spa/feature/deployment/tsconfig.spec.json diff --git a/libs/spa/feature/.gitkeep b/libs/spa/feature/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/libs/spa/feature/deployment/.eslintrc.json b/libs/spa/feature/deployment/.eslintrc.json new file mode 100644 index 00000000..7ec2dbe3 --- /dev/null +++ b/libs/spa/feature/deployment/.eslintrc.json @@ -0,0 +1,39 @@ +{ + "extends": [ + "../../../../.eslintrc.json", + "../../../../.eslintrc.angular.json" + ], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": [ + "plugin:@nx/angular", + "plugin:@angular-eslint/template/process-inline-templates" + ], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "krd", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "krd", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + } + ] +} diff --git a/libs/spa/feature/deployment/README.md b/libs/spa/feature/deployment/README.md new file mode 100644 index 00000000..85724244 --- /dev/null +++ b/libs/spa/feature/deployment/README.md @@ -0,0 +1,9 @@ +# Deployment + +All components and services related to the deployment feature are located in +this library. Currently, a deployment is a Rescue Station or an Operation, so a +collection of units and alert groups. + +## Running unit tests + +Run `nx test deployment` to execute the unit tests. diff --git a/libs/spa/feature/deployment/jest.config.ts b/libs/spa/feature/deployment/jest.config.ts new file mode 100644 index 00000000..b1b69c65 --- /dev/null +++ b/libs/spa/feature/deployment/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'spa-deployment', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/spa/feature/deployment', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/spa/feature/deployment/project.json b/libs/spa/feature/deployment/project.json new file mode 100644 index 00000000..e1474740 --- /dev/null +++ b/libs/spa/feature/deployment/project.json @@ -0,0 +1,20 @@ +{ + "name": "spa-deployment", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/spa/feature/deployment/src", + "prefix": "krd", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/spa/feature/deployment/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/spa/feature/deployment/src/index.ts b/libs/spa/feature/deployment/src/index.ts new file mode 100644 index 00000000..0e1071a8 --- /dev/null +++ b/libs/spa/feature/deployment/src/index.ts @@ -0,0 +1 @@ +export * from './lib/components/deployment/deployments.component'; diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts new file mode 100644 index 00000000..17e825c2 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts @@ -0,0 +1,28 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DeploymentAlertGroupComponent } from './deployment-alert-group.component'; + +describe('DeploymentAlertGroupComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeploymentAlertGroupComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DeploymentAlertGroupComponent); + }); + + it('should show no assignment not on no unit assignments', () => { + fixture.componentRef.setInput('alertGroup', { name: 'test' }); + fixture.componentRef.setInput('unitAssignments', []); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('[data-testid="no-unit-assignments"]') + .textContent, + ).toBe('Keine Zuordnungen'); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.ts new file mode 100644 index 00000000..7fe9143d --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.ts @@ -0,0 +1,49 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { NzCardComponent } from 'ng-zorro-antd/card'; + +import { AlertGroup, DeploymentUnit } from '@kordis/shared/model'; + +import { DeploymentUnitComponent } from './deployment-unit.component'; + +@Component({ + selector: 'krd-deployment-alert-group', + standalone: true, + imports: [CommonModule, NzCardComponent, DeploymentUnitComponent], + template: ` + +
+ {{ alertGroup().name }} + @for ( + unitAssignment of unitAssignments(); + track unitAssignment.unit.id + ) { + + } @empty { + Keine Zuordnungen + } +
+
+ `, + styles: ` + .alert-group { + display: flex; + flex-direction: column; + gap: 5px; + } + + .name { + font-weight: 500; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeploymentAlertGroupComponent { + alertGroup = input.required(); + unitAssignments = input.required(); +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html new file mode 100644 index 00000000..81a08f3e --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html @@ -0,0 +1,54 @@ +@if (isLoading()) { + +} +
+ + + + + + + + + +
diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts new file mode 100644 index 00000000..eb45cb02 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts @@ -0,0 +1,38 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { DeploymentUnitDetailsComponent } from './deployment-unit-details.component'; + +describe('DeploymentUnitDetailsComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + { + provide: GraphqlService, + useValue: createMock(), + }, + ], + imports: [DeploymentUnitDetailsComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DeploymentUnitDetailsComponent); + fixture.componentRef.setInput('unit', { + id: '1', + callSign: 'Alpha', + name: 'Unit Alpha', + status: { status: 1 }, + note: 'Initial note', + }); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.ts new file mode 100644 index 00000000..610a62de --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.ts @@ -0,0 +1,133 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { NzInputDirective } from 'ng-zorro-antd/input'; +import { NzRadioModule } from 'ng-zorro-antd/radio'; +import { NzSpinComponent } from 'ng-zorro-antd/spin'; +import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; +import { Subject, debounceTime, delay, switchMap, tap } from 'rxjs'; + +import { Unit } from '@kordis/shared/model'; +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +import { STATUS_EXPLANATIONS } from '../../status-explanations'; +import { StatusBadgeComponent } from './status-badge.component'; + +@Component({ + selector: 'krd-deployment-unit-details', + standalone: true, + imports: [ + FormsModule, + NzInputDirective, + NzRadioModule, + NzSpinComponent, + NzTooltipDirective, + StatusBadgeComponent, + ], + templateUrl: './deployment-unit-details.component.html', + styles: ` + nz-spin { + position: absolute; + top: 0; + right: 0; + padding: 5px 16px 4px; + } + + textarea { + resize: none; + } + + .status { + display: flex; + flex-direction: column; + gap: 5px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeploymentUnitDetailsComponent { + readonly unit = input.required(); + readonly unitStatusUpdated = output(); + note = ''; + status?: number; + readonly isLoading = signal(false); + + protected readonly STATUS_EXPLANATIONS = STATUS_EXPLANATIONS; + private readonly noteUpdatedSubject$ = new Subject(); + private readonly gqlService = inject(GraphqlService); + private readonly statusUpdatedSubject$ = new Subject(); + + constructor() { + effect(() => { + this.status = this.unit().status?.status; + }); + effect(() => { + this.note = this.unit().note; + }); + this.watchNoteUpdates(); + } + + updateNote(): void { + this.noteUpdatedSubject$.next(); + } + + updateStatus(): void { + this.isLoading.set(true); + this.gqlService + .mutate$( + gql` + mutation UpdateUnitStatus($unitId: String!, $status: Int!) { + updateUnitStatus(unitId: $unitId, status: $status) { + id + status { + status + source + receivedAt + } + } + } + `, + { + unitId: this.unit().id, + status: this.status, + }, + ) + .subscribe(() => this.unitStatusUpdated.emit()) + .add(() => this.isLoading.set(false)); + } + + private watchNoteUpdates(): void { + this.noteUpdatedSubject$ + .pipe( + debounceTime(300), + tap(() => this.isLoading.set(true)), + switchMap(() => + this.gqlService.mutate$( + gql` + mutation UpdateUnitNote($unitId: String!, $note: String!) { + updateUnitNote(unitId: $unitId, note: $note) { + id + note + } + } + `, + { + unitId: this.unit().id, + note: this.note, + }, + ), + ), + delay(300), // eye candy for loading spinner + takeUntilDestroyed(), + ) + .subscribe(() => this.isLoading.set(false)); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts new file mode 100644 index 00000000..10e2a86b --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts @@ -0,0 +1,41 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { DeploymentUnitComponent } from './deployment-unit.component'; + +describe('DeploymentUnitComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeploymentUnitComponent, NoopAnimationsModule], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DeploymentUnitComponent); + }); + + it('should be defined', () => { + fixture.componentRef.setInput('unit', { + id: '1', + callSign: 'Alpha', + name: 'Unit Alpha', + status: { status: 1 }, + }); + fixture.detectChanges(); + expect(fixture.componentInstance).toBeDefined(); + }); + + it('should show a note if the unit has a note', () => { + fixture.componentRef.setInput('unit', { + id: '1', + callSign: 'Alpha', + name: 'Unit Alpha', + note: 'This is a note', + status: { status: 1 }, + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('i')).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts new file mode 100644 index 00000000..a55d5439 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts @@ -0,0 +1,89 @@ +import { Component, input, signal } from '@angular/core'; +import { InfoCircleOutline } from '@ant-design/icons-angular/icons'; +import { NzCardComponent } from 'ng-zorro-antd/card'; +import { NzIconDirective, NzIconService } from 'ng-zorro-antd/icon'; +import { NzPopoverModule } from 'ng-zorro-antd/popover'; +import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; + +import { Unit } from '@kordis/shared/model'; + +import { DeploymentUnitDetailsComponent } from './deployment-unit-details.component'; +import { StatusBadgeComponent } from './status-badge.component'; + +@Component({ + selector: 'krd-deployment-unit', + standalone: true, + imports: [ + DeploymentUnitDetailsComponent, + NzCardComponent, + NzIconDirective, + NzPopoverModule, + NzTooltipDirective, + StatusBadgeComponent, + ], + template: ` + + + + +
+
+
+ {{ unit().callSign }} + @if (unit().note) { + + } +
+
+ +
+
+
+ {{ unit().name }} +
+
+
+ `, + styles: ` + .card-body { + display: flex; + flex-direction: column; + } + + .header-row { + display: flex; + justify-content: space-between; + align-items: center; + } + + .name { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9rem; + color: grey; + } + `, +}) +export class DeploymentUnitComponent { + unit = input.required(); + protected showPopover = signal(false); + + constructor(iconService: NzIconService) { + iconService.addIcon(InfoCircleOutline); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css new file mode 100644 index 00000000..459bd9dd --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css @@ -0,0 +1,33 @@ +.container { + display: flex; + height: 100%; + max-height: 500px; + align-items: center; + + nz-divider { + height: 90%; + } + + .deployment-section { + height: 100%; + } + + .logged-out-stations-container { + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 5px; + } + + .rescue-stations { + display: flex; + flex-direction: row; + gap: 5px; + + #sub-header { + display: flex; + flex-direction: row; + justify-content: space-between; + } + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html new file mode 100644 index 00000000..6e1d9732 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html @@ -0,0 +1,42 @@ +
+
+ +
+ +
+ @for (station of signedInRescueStations$ | async; track station.id) { + +
+ {{ station.strength.leaders }}/{{ + station.strength.subLeaders + }}/{{ station.strength.helpers }}//{{ + station.strength.leaders + + station.strength.subLeaders + + station.strength.helpers + }} + @if (station.note) { + + } +
+
+ } +
+ +
+ @for (station of signedOffRescueStations$ | async; track station.id) { + {{ station.name }} + + } +
+
diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts new file mode 100644 index 00000000..b40cbd96 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts @@ -0,0 +1,176 @@ +import { AsyncPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { InfoCircleOutline } from '@ant-design/icons-angular/icons'; +import { NzCardComponent } from 'ng-zorro-antd/card'; +import { NzDividerComponent } from 'ng-zorro-antd/divider'; +import { NzIconDirective, NzIconService } from 'ng-zorro-antd/icon'; +import { NzModalService } from 'ng-zorro-antd/modal'; +import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; +import { Observable, map, merge, shareReplay } from 'rxjs'; + +import { + DeploymentAssignment, + Query, + RescueStationDeployment, +} from '@kordis/shared/model'; +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +import { RescueStationEditModalComponent } from '../rescue-station-edit-modal/rescue-station-edit-modal.component'; +import { DeploymentCardComponent } from './deplyoment-card.component'; + +const DEPLOYMENTS_QUERY = gql` + fragment UnitData on Unit { + id + callSign + name + note + status { + status + receivedAt + } + } + fragment RescueStationData on RescueStationDeployment { + id + name + note + signedIn + defaultUnits { + ...UnitData + } + strength { + helpers + subLeaders + leaders + } + assignments { + ... on DeploymentUnit { + unit { + ...UnitData + } + } + ... on DeploymentAlertGroup { + assignedUnits { + unit { + ...UnitData + } + } + alertGroup { + id + name + } + } + } + } + query { + signedInStations: rescueStationDeployments(signedIn: true) { + ...RescueStationData + } + signedOffStations: rescueStationDeployments(signedIn: false) { + ...RescueStationData + } + unassignedEntities { + ... on DeploymentUnit { + unit { + ...UnitData + } + } + ... on DeploymentAlertGroup { + alertGroup { + name + } + assignedUnits { + unit { + ...UnitData + } + } + } + } + } +`; + +@Component({ + selector: 'krd-deployments', + standalone: true, + imports: [ + AsyncPipe, + DeploymentCardComponent, + NzCardComponent, + NzDividerComponent, + NzIconDirective, + NzTooltipDirective, + ], + templateUrl: `./deployments.component.html`, + styleUrl: `./deployments.component.css`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeploymentsComponent { + readonly signedInRescueStations$: Observable; + readonly signedOffRescueStations$: Observable; + readonly unassigned$: Observable; + private readonly gqlService = inject(GraphqlService); + private readonly modalService = inject(NzModalService); + + constructor(iconService: NzIconService) { + iconService.addIcon(InfoCircleOutline); + + const deploymentsQuery = this.gqlService.query<{ + signedInStations: Query['rescueStationDeployments']; + signedOffStations: Query['rescueStationDeployments']; + unassignedEntities: Query['unassignedEntities']; + }>(DEPLOYMENTS_QUERY); + + const deployments$ = deploymentsQuery.$.pipe( + takeUntilDestroyed(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + // right now we greedily get all deployments, as a change in a deployment or a unit can result in changes in multiple deployment + // a better way would be to have more fine events for all actions taken that could replay the action in the frontend + merge( + this.gqlService.subscribe$(gql` + subscription { + signedInRescueStationUpdated { + id + } + } + `), + this.gqlService.subscribe$(gql` + subscription { + rescueStationSignedOff { + id + } + } + `), + this.gqlService.subscribe$(gql` + subscription { + rescueStationSignedIn { + id + } + } + `), + ) + .pipe(takeUntilDestroyed()) + .subscribe(() => deploymentsQuery.refresh()); + + this.signedInRescueStations$ = deployments$.pipe( + map(({ signedInStations }) => signedInStations), + ); + this.signedOffRescueStations$ = deployments$.pipe( + map(({ signedOffStations }) => signedOffStations), + ); + this.unassigned$ = deployments$.pipe( + map(({ unassignedEntities }) => unassignedEntities), + ); + } + + openRescueStationEditModal(station: RescueStationDeployment): void { + this.modalService.create({ + nzContent: RescueStationEditModalComponent, + nzData: station, + nzFooter: null, + nzClosable: false, + nzNoAnimation: true, + }); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts new file mode 100644 index 00000000..dc664b79 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts @@ -0,0 +1,144 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { NzCardComponent } from 'ng-zorro-antd/card'; + +import '@kordis/shared/model'; +import { DeploymentAssignment } from '@kordis/shared/model'; + +import { DeploymentAlertGroupComponent } from './deployment-alert-group.component'; +import { DeploymentUnitComponent } from './deployment-unit.component'; + +// status - rank mapping +const STATUS_SORT_ORDER: Readonly> = Object.freeze({ + 3: 1, + 4: 1, + 1: 2, + 2: 3, + 6: 4, + '-1': 5, +}); + +@Component({ + selector: 'krd-deployment-card', + standalone: true, + imports: [ + DeploymentAlertGroupComponent, + DeploymentUnitComponent, + NzCardComponent, + ], + template: ` + +

{{ name() }}

+ +
+ +
+ +
+ @for ( + assignment of sortedAssignments(); + track trackDeploymentAssignment + ) { + @if (assignment.__typename === 'DeploymentUnit') { + + } @else if (assignment.__typename === 'DeploymentAlertGroup') { + + } + } @empty { + Keine Zuordnungen + } +
+
+ `, + styles: ` + nz-card { + h3 { + margin-bottom: 0; + } + + height: 100%; + width: 250px; + overflow: auto; + + .ant-card-body { + padding: 18px; + } + + .sub-header { + margin-bottom: 10px; + } + + .assignments { + display: flex; + flex-direction: column; + gap: 3px; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeploymentCardComponent { + readonly name = input.required(); + readonly assignments = input.required(); + readonly clickable = input(true); + readonly clicked = output(); + + readonly sortedAssignments = computed(() => { + // sort alert group units by status + const assignmentsWithSortedAlertGroups = structuredClone( + this.assignments(), + ).map((assignment) => { + if (assignment.__typename === 'DeploymentAlertGroup') { + const assignedUnits = assignment.assignedUnits.sort( + (a, b) => + STATUS_SORT_ORDER[a.unit.status?.status ?? -1] - + STATUS_SORT_ORDER[b.unit.status?.status ?? -1], + ); + return { + ...assignment, + assignedUnits, + }; + } + return assignment; + }); + + // sort by status of a unit or the "highest ranked" status from the units of an alert group + return assignmentsWithSortedAlertGroups.sort((a, b) => { + const statusA = this.getEffectiveStatus(a); + const statusB = this.getEffectiveStatus(b); + const rankA = STATUS_SORT_ORDER[statusA]; + const rankB = STATUS_SORT_ORDER[statusB]; + return rankA - rankB; + }); + }); + + trackDeploymentAssignment( + index: number, + assignment: DeploymentAssignment, + ): string { + if (assignment.__typename === 'DeploymentUnit') { + return assignment.unit.id; + } else if (assignment.__typename === 'DeploymentAlertGroup') { + return assignment.alertGroup.id; + } + return index.toString(); + } + + private getEffectiveStatus(assignment: DeploymentAssignment): number { + if (assignment.__typename === 'DeploymentUnit') { + return assignment.unit.status?.status ?? -1; + } else if (assignment.__typename === 'DeploymentAlertGroup') { + // we can simply take the first unit because we sorted them above + return assignment.assignedUnits[0]?.unit.status?.status ?? -1; + } + return -1; + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts new file mode 100644 index 00000000..b2bed4bb --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StatusBadgeComponent } from './status-badge.component'; + +describe('StrengthBadgeComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StatusBadgeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(StatusBadgeComponent); + }); + + it('should show the status', () => { + fixture.componentRef.setInput('status', 1); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('span').textContent).toEqual( + '1', + ); + }); + + it('should show missing status', () => { + fixture.componentRef.setInput('status', null); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('span').textContent).toEqual( + '?', + ); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.ts new file mode 100644 index 00000000..606a2f4e --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.ts @@ -0,0 +1,77 @@ +import { NgClass } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; + +import { STATUS_EXPLANATIONS } from '../../status-explanations'; + +@Component({ + selector: 'krd-status-badge', + standalone: true, + imports: [NzTooltipDirective, NgClass], + template: ` + @if (hasStatus()) { + {{ status() }} + } @else { + ? + } + `, + styles: ` + span { + height: 23px; + width: 23px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + } + + .no-status { + background-color: var(--ant-primary-1); + color: #000000; + } + + .status-1 { + background-color: var(--ant-success-color); + color: #000000; + } + + .status-2 { + background-color: var(--ant-success-color-active); + color: #ffffff; + } + + .status-3 { + background-color: var(--ant-warning-color); + color: #000000; + } + + .status-4 { + background-color: var(--ant-error-color); + color: #ffffff; + } + + .status-6 { + background-color: var(--ant-error-color-outline); + color: #000000; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StatusBadgeComponent { + readonly status = input.required(); + readonly hasStatus = computed(() => this.status() != null); + protected readonly STATUS_EXPLANATIONS = STATUS_EXPLANATIONS; +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-assignment.model.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-assignment.model.ts new file mode 100644 index 00000000..78d36989 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-assignment.model.ts @@ -0,0 +1,6 @@ +import { AlertGroup, Unit } from '@kordis/shared/model'; + +export interface AlertGroupAssignment { + alertGroup: AlertGroup; + assignedUnits: Unit[]; +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts new file mode 100644 index 00000000..ef4c03b8 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts @@ -0,0 +1,113 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, Output, inject, input } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { + NzAutocompleteModule, + NzAutocompleteOptionComponent, +} from 'ng-zorro-antd/auto-complete'; +import { NzNoAnimationDirective } from 'ng-zorro-antd/core/no-animation'; +import { NzInputDirective } from 'ng-zorro-antd/input'; +import { NzSelectModule } from 'ng-zorro-antd/select'; +import { Subject, debounceTime, filter, merge, share, switchMap } from 'rxjs'; + +import { AlertGroup } from '@kordis/shared/model'; + +import { PossibleAlertGroupSelectionsService } from '../../service/alert-group-selection.service'; + +@Component({ + selector: 'krd-alert-group-autocomplete', + standalone: true, + imports: [ + NzInputDirective, + NzAutocompleteModule, + AsyncPipe, + ReactiveFormsModule, + NzSelectModule, + NzNoAnimationDirective, + ], + template: ` + + + @for (alertGroup of alertGroupResults$ | async; track alertGroup.id) { + +
+ {{ alertGroup.name }} + @if ( + alertGroup.assignment?.__typename === + 'EntityRescueStationAssignment' + ) { + Zuordnung: {{ alertGroup.assignment!.name }} + } +
+
+ } +
+ `, + styles: ` + .result-item { + display: flex; + flex-direction: column; + + .name { + font-weight: 500; + } + } + `, +}) +export class AlertGroupAutocompleteComponent { + showErrorState = input(false); + + readonly searchInput = new FormControl(''); + readonly possibleAlertGroupSelectionService = inject( + PossibleAlertGroupSelectionsService, + ); + private readonly alertGroupSelectedSubject$ = new Subject(); + // eslint-disable-next-line rxjs/finnish + @Output() alertGroupSelected = this.alertGroupSelectedSubject$ + .asObservable() + .pipe(share()); + + private readonly searchInputFocusedSubject$ = new Subject(); + readonly alertGroupResults$ = merge( + merge( + this.searchInputFocusedSubject$, + this.searchInput.valueChanges.pipe(filter((value) => value === '')), + ).pipe( + switchMap( + () => + this.possibleAlertGroupSelectionService.allPossibleEntitiesToSelect$, + ), + ), + this.searchInput.valueChanges.pipe( + filter((value): value is string => typeof value === 'string'), + debounceTime(300), + switchMap((value) => + value + ? this.possibleAlertGroupSelectionService.searchAllPossibilities( + value, + ) + : [], + ), + ), + ); + + onUnitSelected({ nzValue: unit }: NzAutocompleteOptionComponent): void { + this.alertGroupSelectedSubject$.next(unit); + this.searchInput.reset(); + } + + onSearchInputFocus(): void { + this.searchInputFocusedSubject$.next(); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts new file mode 100644 index 00000000..62fabc6f --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts @@ -0,0 +1,65 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + viewChild, +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { NzCardComponent } from 'ng-zorro-antd/card'; +import { NzIconDirective } from 'ng-zorro-antd/icon'; + +import { AlertGroup, Unit } from '@kordis/shared/model'; + +import { UnitsSelectComponent } from '../unit/units-select.component'; + +@Component({ + selector: 'krd-alert-group-selection', + standalone: true, + imports: [ + NzCardComponent, + NzIconDirective, + ReactiveFormsModule, + UnitsSelectComponent, + ], + template: ` +
+ {{ formGroup().value.alertGroup!.name }} + +
+ + + `, + styles: ` + .alert-group-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + span { + font-weight: 500; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlertGroupSelectionComponent { + readonly removed = output(); + + formGroup = input.required< + FormGroup<{ + alertGroup: FormControl; + assignedUnits: FormControl; + }> + >(); + private unitSelectionEle = viewChild(UnitsSelectComponent); + + removeAlertGroup(): void { + this.removed.emit(); + } + + focusUnitSelection(): void { + this.unitSelectionEle()?.focus(); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts new file mode 100644 index 00000000..57be20b0 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts @@ -0,0 +1,153 @@ +import { CdkTrapFocus } from '@angular/cdk/a11y'; +import { AsyncPipe, NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + input, + viewChildren, +} from '@angular/core'; +import { + FormArray, + FormControl, + FormGroup, + FormsModule, + NonNullableFormBuilder, + ReactiveFormsModule, +} from '@angular/forms'; +import { NzCardComponent } from 'ng-zorro-antd/card'; +import { NzIconDirective } from 'ng-zorro-antd/icon'; +import { NzSelectModule } from 'ng-zorro-antd/select'; +import { NzTagComponent } from 'ng-zorro-antd/tag'; + +import { AlertGroup, Unit } from '@kordis/shared/model'; + +import { StatusBadgeComponent } from '../../../deployment/status-badge.component'; +import { PossibleAlertGroupSelectionsService } from '../../service/alert-group-selection.service'; +import { PossibleUnitSelectionsService } from '../../service/unit-selection.service'; +import { UnitSelectionOptionComponent } from '../unit/unit-selection-option.component'; +import { UnitsSelectComponent } from '../unit/units-select.component'; +import { AlertGroupAutocompleteComponent } from './alert-group-autocomplete.component'; +import { AlertGroupSelectionComponent } from './alert-group-selection.component'; + +@Component({ + selector: 'krd-alert-group-selections', + standalone: true, + imports: [ + AsyncPipe, + ReactiveFormsModule, + NzSelectModule, + StatusBadgeComponent, + FormsModule, + UnitSelectionOptionComponent, + CdkTrapFocus, + NzTagComponent, + AlertGroupAutocompleteComponent, + NzCardComponent, + UnitsSelectComponent, + NzIconDirective, + AlertGroupSelectionComponent, + NgIf, + ], + template: ` + + +
+ @for ( + alertGroupAssignment of formArray().controls; + track alertGroupAssignment.value.alertGroup!.id + ) { + + } +
+
+ `, + styles: ` + :host { + display: flex; + flex-direction: column; + } + + .selections:empty { + margin-top: 0; + } + + .selections { + max-height: 115px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 5px; + } + + nz-card { + .ant-card-body { + padding: 5px; + } + + margin-top: 5px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlertGroupSelectionsComponent { + formArray = input.required< + FormArray< + FormGroup<{ + alertGroup: FormControl; + assignedUnits: FormControl; + }> + > + >(); + readonly alertGroupSelectionElements = viewChildren( + AlertGroupSelectionComponent, + ); + private readonly possibleAlertGroupSelectionsService = inject( + PossibleAlertGroupSelectionsService, + ); + private readonly possibleUnitSelectionsService = inject( + PossibleUnitSelectionsService, + { + optional: true, + }, + ); + private readonly fb = inject(NonNullableFormBuilder); + private readonly cd = inject(ChangeDetectorRef); + + removeAlertGroup(index: number): void { + this.possibleAlertGroupSelectionsService.unmarkAsSelected( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.formArray().at(index).value.alertGroup!, + ); + this.formArray().removeAt(index); + } + + addAlertGroup(alertGroup: AlertGroup): void { + this.possibleAlertGroupSelectionsService.markAsSelected(alertGroup); + // newly assigned alert groups should have default units initially + const alertGroupAssignment = this.fb.group({ + alertGroup: this.fb.control(alertGroup), + assignedUnits: this.fb.control( + alertGroup.units.filter( + (unit) => + !(this.possibleUnitSelectionsService?.isSelected(unit) ?? false), + ), + ), + }); + + this.formArray().push(alertGroupAssignment); + + this.cd.detectChanges(); // detect changes, so alertGroupSelectionElements() will be updated + + this.alertGroupSelectionElements()[ + this.alertGroupSelectionElements().length - 1 + ].focusUnitSelection(); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts new file mode 100644 index 00000000..e4fd03f7 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts @@ -0,0 +1,66 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + OnInit, + inject, + input, + viewChild, +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { NzInputDirective } from 'ng-zorro-antd/input'; + +import { Unit } from '@kordis/shared/model'; + +// placeholder until we have the protocol frontend ready! +@Component({ + selector: 'krd-protocol-data', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, NzInputDirective], + template: ` +
+ + + + +
+ `, + styles: ` + div { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProtocolDataComponent implements OnInit { + readonly formGroup = input.required< + FormGroup<{ + sender: FormControl; + recipient: FormControl; + channel: FormControl; + }> + >(); + focusInitially = input(false, { + transform: (x: unknown) => x !== false, + }); + + private senderInputEle = + viewChild>('senderInput'); + private readonly cd = inject(ChangeDetectorRef); + + ngOnInit(): void { + if (this.focusInitially()) { + // https://github.com/NG-ZORRO/ng-zorro-antd/issues/7257, modal container always has focus :( + setTimeout(() => { + this.senderInputEle()?.nativeElement.focus(); + this.formGroup().controls.sender.markAsUntouched(); + this.cd.markForCheck(); + }, 0); + } + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts new file mode 100644 index 00000000..eed38a24 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup } from '@angular/forms'; + +import { StrengthComponent } from './strength.component'; + +describe('StrengthComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StrengthComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(StrengthComponent); + }); + + it('should show correct total', async () => { + fixture.componentRef.setInput( + 'formGroup', + new FormGroup({ + leaders: new FormControl(1), + subLeaders: new FormControl(2), + helpers: new FormControl(3), + }), + ); + + await fixture.autoDetectChanges(); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toMatch(new RegExp(`6 $`)); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.ts new file mode 100644 index 00000000..a18fe48c --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.ts @@ -0,0 +1,106 @@ +import { AsyncPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + effect, + input, +} from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { NzInputNumberComponent } from 'ng-zorro-antd/input-number'; +import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; +import { Subject, map, of, startWith, takeUntil } from 'rxjs'; + +@Component({ + selector: 'krd-strength', + standalone: true, + template: ` +
+ + / + + / + + // {{ total$ | async }} +
+ `, + imports: [ + AsyncPipe, + NzInputNumberComponent, + NzTooltipDirective, + ReactiveFormsModule, + ], + styles: ` + div { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StrengthComponent implements OnDestroy { + readonly formGroup = input.required< + FormGroup<{ + leaders: FormControl; + subLeaders: FormControl; + helpers: FormControl; + }> + >(); + total$ = of(0); + + private readonly destroyRefSubject$ = new Subject(); + + constructor() { + effect(() => { + this.destroyRefSubject$.next(); // when formGroup changes, destroy previous subscription + this.total$ = this.formGroup().valueChanges.pipe( + startWith(() => null), // trigger initial calculation + map(() => this.formGroup().getRawValue()), + map( + ({ leaders, subLeaders, helpers }) => leaders + subLeaders + helpers, + ), + takeUntil(this.destroyRefSubject$), + ); + }); + } + + ngOnDestroy(): void { + this.destroyRefSubject$.next(); + this.destroyRefSubject$.complete(); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts new file mode 100644 index 00000000..14af4dbd --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UnitSelectionOptionComponent } from './unit-selection-option.component'; + +describe('StrengthComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UnitSelectionOptionComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UnitSelectionOptionComponent); + }); + + it('should create', async () => { + fixture.componentRef.setInput('unit', { + id: 1, + callSign: 'unit1', + name: 'name', + }); + fixture.detectChanges(); + + expect(fixture.componentInstance).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts new file mode 100644 index 00000000..1fd3547d --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +import { Unit } from '@kordis/shared/model'; + +import { StatusBadgeComponent } from '../../../deployment/status-badge.component'; + +@Component({ + selector: 'krd-unit-selection-option', + standalone: true, + imports: [StatusBadgeComponent], + template: ` +
+
+
+ {{ unit().callSign }} + {{ unit().name }} +
+ +
+ + @if ( + unit().assignment?.__typename === 'EntityRescueStationAssignment' + ) { + Zuordnung: {{ unit().assignment!.name }} + } @else { + Keine Zuordnung + } + +
+ `, + styles: ` + .result-item { + display: flex; + flex-direction: column; + + .info { + display: flex; + justify-content: space-between; + + .call-sign { + font-weight: 500; + margin-right: 5px; + } + + .name { + color: grey; + font-size: 0.9em; + } + } + + .assignment-note { + font-size: 0.8em; + margin: -5px 0; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UnitSelectionOptionComponent { + readonly unit = input.required(); +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.spec.ts new file mode 100644 index 00000000..69738e2a --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.spec.ts @@ -0,0 +1,44 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createMock } from '@golevelup/ts-jest'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { UnitSearchService } from '../../../../services/unit-search.service'; +import { PossibleUnitSelectionsService } from '../../service/unit-selection.service'; +import { UnitsSelectComponent } from './units-select.component'; + +describe('StrengthComponent', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UnitsSelectComponent, NoopAnimationsModule], + providers: [ + PossibleUnitSelectionsService, + { + provide: GraphqlService, + useValue: createMock(), + }, + { + provide: UnitSearchService, + useValue: createMock(), + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UnitsSelectComponent); + }); + + it('should create', async () => { + fixture.componentRef.setInput( + 'control', + new FormControl([{ id: 1, callSign: 'unit1', name: 'name' }]), + ); + fixture.detectChanges(); + + expect(fixture.componentInstance).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.ts new file mode 100644 index 00000000..a73724de --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.ts @@ -0,0 +1,159 @@ +import { AsyncPipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + effect, + inject, + input, + viewChild, +} from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { NzSelectComponent, NzSelectModule } from 'ng-zorro-antd/select'; +import { + Observable, + Subject, + debounceTime, + filter, + merge, + scan, + share, + switchMap, + takeUntil, +} from 'rxjs'; + +import { Unit } from '@kordis/shared/model'; + +import { PossibleUnitSelectionsService } from '../../service/unit-selection.service'; +import { UnitSelectionOptionComponent } from './unit-selection-option.component'; + +@Component({ + selector: 'krd-units-select', + standalone: true, + imports: [ + AsyncPipe, + NzSelectModule, + ReactiveFormsModule, + UnitSelectionOptionComponent, + ], + template: ` + + @for (unit of unitResults$ | async; track unit.id) { + + + + } + + @for (unit of control().value; track unit.id) { + + } + + `, + styles: ` + nz-select { + width: 100%; + height: 100%; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UnitsSelectComponent implements OnDestroy { + readonly control = input.required>(); // must be a form control, as nz-select does not support form arrays + + private readonly searchValueChangedSubject$ = new Subject(); + private readonly onSelectionChangedSubject$ = new Subject(); + private readonly onFocusSubject$ = new Subject(); + private readonly searchValueChanged$ = + this.searchValueChangedSubject$.pipe(share()); + private readonly possibleUnitSelectionsService = inject( + PossibleUnitSelectionsService, + ); + + protected readonly unitResults$: Observable = merge( + // if search cleared, selection changed, or focused, show all possible units + merge( + this.searchValueChanged$.pipe(filter((value) => value === '')), + this.onSelectionChangedSubject$, + this.onFocusSubject$, + ).pipe( + switchMap( + () => this.possibleUnitSelectionsService.allPossibleEntitiesToSelect$, + ), + ), + // search + this.searchValueChanged$.pipe( + filter((value) => value !== ''), + debounceTime(300), + switchMap((value) => + value + ? this.possibleUnitSelectionsService.searchAllPossibilities(value) + : [], + ), + ), + ); + private selectEle = viewChild(NzSelectComponent); + private readonly destroySubject$ = new Subject(); + + constructor() { + effect(() => { + this.destroySubject$.next(); + // units selected should not be visible in the search while units that are unselected should become visible again + this.control() + .valueChanges.pipe( + scan((prev, curr) => { + // naively unmark all previously selected units + prev.forEach((unit) => + this.possibleUnitSelectionsService.unmarkAsSelected(unit), + ); + + // mark all currently selected + curr.forEach((unit) => + this.possibleUnitSelectionsService.markAsSelected(unit), + ); + + return curr; + }, [] as Unit[]), + takeUntil(this.destroySubject$), + ) + .subscribe(() => this.onSelectionChangedSubject$.next()); + }); + } + + ngOnDestroy(): void { + this.destroySubject$.next(); + this.destroySubject$.complete(); + } + + focus(): void { + this.selectEle()?.focus(); + this.selectEle()?.setOpenState(true); + } + + protected onSearch(value: string): void { + this.searchValueChangedSubject$.next(value); + } + + protected onFocus(): void { + this.onFocusSubject$.next(); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.css b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.css new file mode 100644 index 00000000..87203434 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.css @@ -0,0 +1,32 @@ +.form { + display: flex; + flex-direction: column; + gap: 10px; + + .form-item { + display: flex; + flex-direction: column; + } + + textarea { + height: 55px; + resize: none; + } +} + +.actions { + margin-top: 10px; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +nz-card { + .ant-card-body { + padding: 10px; + } +} + +nz-alert { + margin-top: 5px; +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html new file mode 100644 index 00000000..86aafad8 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html @@ -0,0 +1,79 @@ +

+ @if (rescueStation.signedIn) { + Nachmeldung {{ rescueStation.name }} + } @else { + Anmeldung {{ rescueStation.name }} + } +

+ + +

Funkspruch

+ + @if(formGroup.controls.protocolData.dirty && + formGroup.controls.protocolData.controls.sender.touched && + formGroup.controls.protocolData.controls.channel.touched && + formGroup.controls.protocolData.controls.recipient.touched && + formGroup.controls.protocolData.invalid) { + + } +
+ + +

RW-Daten

+
+
+ Stärke + +
+ @if(formGroup.controls.rescueStationData.controls.strength.touched + && formGroup.controls.rescueStationData.controls.strength.errors?.totalStrengthInvalid) { + + } +
+ Einheiten + +
+
+ Alarmgruppen + +
+ @if(formGroup.controls.rescueStationData.controls.alertGroups.touched && formGroup.controls.rescueStationData.controls.alertGroups.errors?.minUnitsInvalid) { + + } +
+ Anmerkungen + +
+
+
+ +
+ @if (rescueStation.signedIn) { + + + } @else { + + } +
+ diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts new file mode 100644 index 00000000..f15e4c1b --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts @@ -0,0 +1,263 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; +import { + FormControl, + FormGroup, + NonNullableFormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { NzAlertComponent } from 'ng-zorro-antd/alert'; +import { NzButtonComponent } from 'ng-zorro-antd/button'; +import { NzCardComponent } from 'ng-zorro-antd/card'; +import { NzFormModule } from 'ng-zorro-antd/form'; +import { NzInputDirective } from 'ng-zorro-antd/input'; +import { NZ_MODAL_DATA, NzModalRef } from 'ng-zorro-antd/modal'; +import { NzNotificationService } from 'ng-zorro-antd/notification'; + +import { + AlertGroup, + RescueStationDeployment, + Unit, +} from '@kordis/shared/model'; + +import { AlertGroupSelectionsComponent } from './component/alert-group/alert-group-selections.component'; +import { ProtocolDataComponent } from './component/protocol-data.component'; +import { StrengthComponent } from './component/strength.component'; +import { UnitsSelectComponent } from './component/unit/units-select.component'; +import { PossibleAlertGroupSelectionsService } from './service/alert-group-selection.service'; +import { + ProtocolMessageData, + RescueStationData, + RescueStationEditService, +} from './service/rescue-station-edit.service'; +import { PossibleUnitSelectionsService } from './service/unit-selection.service'; +import { alertGroupMinUnitsValidator } from './validator/alert-group-min-units.validator'; +import { minStrengthValidator } from './validator/min-strength.validator'; + +export type AlertGroupAssignmentFormGroup = FormGroup<{ + alertGroup: FormControl; + assignedUnits: FormControl; +}>; + +@Component({ + selector: 'krd-rescue-station-edit-modal', + standalone: true, + imports: [ + AlertGroupSelectionsComponent, + NzAlertComponent, + NzButtonComponent, + NzCardComponent, + NzFormModule, + NzInputDirective, + ProtocolDataComponent, + ReactiveFormsModule, + StrengthComponent, + UnitsSelectComponent, + ], + templateUrl: `./rescue-station-edit-modal.component.html`, + styleUrl: `./rescue-station-edit-modal.component.css`, + providers: [ + PossibleUnitSelectionsService, + PossibleAlertGroupSelectionsService, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RescueStationEditModalComponent { + readonly rescueStation: RescueStationDeployment = inject(NZ_MODAL_DATA); + readonly loadingState = signal<'UPDATE' | 'SIGN_OFF' | false>(false); + readonly #modal = inject(NzModalRef); + + private readonly fb = inject(NonNullableFormBuilder); + readonly formGroup = this.fb.group({ + rescueStationData: this.fb.group({ + strength: this.fb.group( + { + leaders: this.fb.control( + this.rescueStation.strength.leaders, + Validators.min(0), + ), + subLeaders: this.fb.control( + this.rescueStation.strength.subLeaders, + Validators.min(0), + ), + helpers: this.fb.control( + this.rescueStation.strength.helpers, + Validators.min(0), + ), + }, + { + validators: [minStrengthValidator], + }, + ), + note: this.fb.control(this.rescueStation.note), + units: this.fb.control(this.getInitialUnitsFromStation()), + alertGroups: this.fb.array( + this.getInitialAlertGroupsFromStation(), + alertGroupMinUnitsValidator, + ), + }), + protocolData: this.fb.group({ + sender: this.fb.control('', Validators.required), + recipient: this.fb.control('', Validators.required), + channel: this.fb.control('', Validators.required), + }), + }); + + private readonly rescueStationService = inject(RescueStationEditService); + private readonly notificationService = inject(NzNotificationService); + + updateSignedInStation(): void { + this.formGroup.markAllAsTouched(); + + // if the protocol data is dirty, we want to make sure it is valid as it is an optional form for updates + // first check if some fields are filled, if not, we mark the form as pristine to handle the case where a user can enter a value, validates it, deletes it => should not be required + if ( + Object.values(this.formGroup.controls.protocolData.controls).every( + (control) => control.value === null || control.value === '', + ) + ) { + this.formGroup.controls.protocolData.markAsPristine(); + } + if ( + (this.formGroup.controls.protocolData.dirty && + this.formGroup.controls.protocolData.invalid) || + this.formGroup.controls.rescueStationData.invalid + ) { + return; + } + + this.loadingState.set('UPDATE'); + this.rescueStationService + .update$( + this.getRescueStationPayload(), + this.formGroup.controls.protocolData.dirty + ? this.getProtocolPayload() + : undefined, + ) + .subscribe({ + next: () => { + this.notificationService.success( + 'RW Nachmeldung', + `${this.rescueStation.name} wurde erfolgreich nachgemeldet.`, + ); + this.#modal.destroy(); + }, + error: () => + this.notificationService.error( + 'Fehler', + 'Die Rettungswache konnte aufgrund eines Fehlers nicht nachgemeldet werden.', + ), + }) + .add(() => this.loadingState.set(false)); + } + + signInStation(): void { + this.formGroup.markAllAsTouched(); + this.formGroup.controls.protocolData.markAsDirty(); + if (this.formGroup.invalid) { + return; + } + + this.loadingState.set('UPDATE'); + this.rescueStationService + .signIn$(this.getRescueStationPayload(), this.getProtocolPayload()) + .subscribe({ + next: () => { + this.notificationService.success( + 'RW Anmeldung', + `${this.rescueStation.name} wurde erfolgreich angemeldet.`, + ); + this.#modal.destroy(); + }, + error: () => + this.notificationService.error( + 'Fehler', + 'Die Rettungswache konnte aufgrund eines Fehlers nicht angemeldet werden.', + ), + }) + .add(() => this.loadingState.set(false)); + } + + signOffStation(): void { + this.formGroup.controls.protocolData.markAllAsTouched(); + this.formGroup.controls.protocolData.markAsDirty(); + if (this.formGroup.controls.protocolData.invalid) { + return; + } + + this.loadingState.set('SIGN_OFF'); + this.rescueStationService + .signOff$(this.rescueStation.id, this.getProtocolPayload()) + .subscribe({ + next: () => { + this.notificationService.success( + 'RW Abmeldung', + `${this.rescueStation.name} wurde erfolgreich abgemeldet.`, + ); + this.#modal.destroy(); + }, + error: () => + this.notificationService.error( + 'Fehler', + 'Die Rettungswache konnte aufgrund eines Fehlers nicht abgemeldet werden.', + ), + }) + .add(() => this.loadingState.set(false)); + } + + private getInitialUnitsFromStation(): Unit[] { + let units: Unit[]; + // if the station is not signed in, we want to preselect the default units + if (!this.rescueStation.signedIn) { + units = this.rescueStation.defaultUnits; + } else { + units = this.rescueStation.assignments.reduce((acc, assignment) => { + if (assignment.__typename === 'DeploymentUnit') { + acc.push(assignment.unit); + } + return acc; + }, [] as Unit[]); + } + return units; + } + + private getInitialAlertGroupsFromStation(): AlertGroupAssignmentFormGroup[] { + return this.rescueStation.assignments.reduce((acc, assignment) => { + if (assignment.__typename === 'DeploymentAlertGroup') { + const fg = this.fb.group({ + alertGroup: this.fb.control(assignment.alertGroup), + assignedUnits: this.fb.control( + assignment.assignedUnits.map(({ unit }) => unit), + ), + }); + acc.push(fg); + } + return acc; + }, [] as AlertGroupAssignmentFormGroup[]); + } + + private getRescueStationPayload(): RescueStationData { + const rsData = this.formGroup.controls.rescueStationData.getRawValue(); + return { + rescueStationId: this.rescueStation.id, + strength: rsData.strength, + note: rsData.note, + assignedUnits: rsData.units, + assignedAlertGroups: rsData.alertGroups, + }; + } + + private getProtocolPayload(): ProtocolMessageData { + const protocolData = this.formGroup.controls.protocolData.getRawValue(); + return { + sender: protocolData.sender, + recipient: protocolData.recipient, + channel: protocolData.channel, + }; + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/alert-group-selection.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/alert-group-selection.service.ts new file mode 100644 index 00000000..38e473f9 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/alert-group-selection.service.ts @@ -0,0 +1,49 @@ +import { Injectable, inject } from '@angular/core'; +import { TypedDocumentNode } from 'apollo-angular'; + +import { AlertGroup, Query } from '@kordis/shared/model'; +import { gql } from '@kordis/spa/core/graphql'; + +import { AlertGroupSearchService } from '../../../services/alert-group-search.service'; +import { EntitySearchService } from '../../../services/entity-search.service'; +import { PossibleEntitySelectionsService } from './possible-entity-selection.service'; + +@Injectable() +export class PossibleAlertGroupSelectionsService extends PossibleEntitySelectionsService< + AlertGroup, + { alertGroups: Query['alertGroups'] } +> { + protected override query: TypedDocumentNode<{ + alertGroups: Query['alertGroups']; + }> = gql` + query { + alertGroups { + id + name + units { + id + name + callSign + status { + status + } + assignment { + __typename + id + name + } + } + assignment { + __typename + id + name + } + } + } + `; + protected queryName = 'alertGroups' as const; + + protected searchService: EntitySearchService = inject( + AlertGroupSearchService, + ); +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.spec.ts new file mode 100644 index 00000000..dd8fbb8f --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.spec.ts @@ -0,0 +1,80 @@ +import { TestBed } from '@angular/core/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { of } from 'rxjs'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { EntitySearchService } from '../../../services/entity-search.service'; +import { PossibleEntitySelectionsService } from './possible-entity-selection.service'; + +class TestEntitySelectionService extends PossibleEntitySelectionsService< + { id: string; name: string }, + { + testEntities: { id: string; name: string }[]; + } +> { + protected query = {} as any; // Mock GraphQL query + protected queryName = 'testEntities' as const; + protected searchService = + createMock>(); // Mock search service +} + +describe('PossibleEntitySelectionsService', () => { + let service: PossibleEntitySelectionsService< + { id: string; name: string }, + { + testEntities: { id: string; name: string }[]; + } + >; + let graphqlServiceMock: DeepMocked; + + beforeEach(() => { + graphqlServiceMock = createMock(); + + TestBed.configureTestingModule({ + providers: [ + TestEntitySelectionService, + { provide: GraphqlService, useValue: graphqlServiceMock }, + ], + }); + + service = TestBed.inject(TestEntitySelectionService); + }); + + it('should mark and unmark an entity as selected', () => { + const testEntity = { id: '1', name: 'Test Entity' }; + service.markAsSelected(testEntity); + expect(service.isSelected(testEntity)).toBe(true); + + service.unmarkAsSelected(testEntity); + expect(service.isSelected(testEntity)).toBe(false); + }); + + it('should filter out selected entities from all possible entities to select', () => + new Promise((done) => { + const testEntities = [ + { id: '1', name: 'Entity 1' }, + { id: '2', name: 'Entity 2' }, + ]; + graphqlServiceMock.queryOnce$.mockReturnValue(of({ testEntities })); + + service.markAsSelected(testEntities[0]); + + service.allPossibleEntitiesToSelect$.subscribe((entities) => { + expect(entities.length).toBe(1); + expect(entities[0].id).toBe('2'); + done(); + }); + })); + + it('should filter search', async () => { + service.markAsSelected({ id: '1', name: 'Entity 1' }); + (service as any).searchService.searchByTerm.mockResolvedValue([ + { id: '1', name: 'Entity 1' }, + { id: '2', name: 'Entity 2' }, + ]); + await expect(service.searchAllPossibilities('query')).resolves.toEqual([ + { id: '2', name: 'Entity 2' }, + ]); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.ts new file mode 100644 index 00000000..9a34fb60 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.ts @@ -0,0 +1,59 @@ +import { inject } from '@angular/core'; +import { TypedDocumentNode } from 'apollo-angular'; +import { Subject, map, shareReplay, startWith, switchMap } from 'rxjs'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { EntitySearchService } from '../../../services/entity-search.service'; + +/* + This service handles the selection of entities in a context where an entity can only be selected once. + */ +export abstract class PossibleEntitySelectionsService< + TEntity extends { id: string }, + TQuery extends Record, +> { + protected abstract query: TypedDocumentNode; + protected abstract queryName: keyof TQuery; + protected abstract searchService: EntitySearchService; + + private readonly entityIdsSelected = new Set(); + private readonly gqlService = inject(GraphqlService); + private readonly selectionChangedSubject$ = new Subject(); + readonly allPossibleEntitiesToSelect$ = this.selectionChangedSubject$.pipe( + startWith(null), + switchMap(() => + this.gqlService + .queryOnce$(this.query) + .pipe( + map((data) => + data[this.queryName].filter( + ({ id }) => !this.entityIdsSelected.has(id), + ), + ), + ), + ), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + // Mark entity as selected, it cannot be selected again, so it will not be visible in the search results + markAsSelected(entity: TEntity): void { + this.entityIdsSelected.add(entity.id); + this.selectionChangedSubject$.next(); + } + + // Remove entity from selection, it will be visible in the search results again + unmarkAsSelected(entity: TEntity): void { + this.entityIdsSelected.delete(entity.id); + this.selectionChangedSubject$.next(); + } + + isSelected(entity: TEntity): boolean { + return this.entityIdsSelected.has(entity.id); + } + + async searchAllPossibilities(query: string): Promise { + const entities = await this.searchService.searchByTerm(query); + return entities.filter(({ id }) => !this.entityIdsSelected.has(id)); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.spec.ts new file mode 100644 index 00000000..5c02955f --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.spec.ts @@ -0,0 +1,105 @@ +import { TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; + +import { AlertGroup, Unit } from '@kordis/shared/model'; +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { + ProtocolMessageData, + RescueStationData, + RescueStationEditService, +} from './rescue-station-edit.service'; + +const RESCUE_STATION_DATA: RescueStationData = Object.freeze({ + rescueStationId: '123', + strength: { leaders: 1, subLeaders: 2, helpers: 3 }, + note: 'Test Note', + assignedUnits: [{ id: 'unitId1' } as Unit], + assignedAlertGroups: [ + { + alertGroup: { id: 'alertGroupId' } as AlertGroup, + assignedUnits: [{ id: 'unitId2' } as Unit], + }, + ], +}); + +const PROTOCOL_MESSAGE_DATA: ProtocolMessageData = Object.freeze({ + channel: 'TestChannel', + sender: 'SenderUnit', + recipient: { id: 'unitId' } as Unit, +}); + +const EXPECTED_ARGS = Object.freeze({ + rescueStationData: { + rescueStationId: '123', + strength: { + leaders: 1, + subLeaders: 2, + helpers: 3, + }, + note: 'Test Note', + assignedUnitIds: ['unitId1'], + assignedAlertGroups: [ + { + alertGroupId: 'alertGroupId', + unitIds: ['unitId2'], + }, + ], + }, + protocolMessageData: { + sender: { + name: 'SenderUnit', + type: 'UNKNOWN_UNIT', + }, + recipient: { + id: 'unitId', + type: 'REGISTERED_UNIT', + }, + channel: 'TestChannel', + }, +}); + +describe('RescueStationEditService', () => { + let service: RescueStationEditService; + let graphqlServiceMock: jest.Mocked; + + beforeEach(() => { + graphqlServiceMock = createMock(); + + TestBed.configureTestingModule({ + providers: [ + RescueStationEditService, + { provide: GraphqlService, useValue: graphqlServiceMock }, + ], + }); + + service = TestBed.inject(RescueStationEditService); + }); + + it('should sign in with correct gql vars', () => { + service.signIn$(RESCUE_STATION_DATA, PROTOCOL_MESSAGE_DATA).subscribe(); + + expect(graphqlServiceMock.mutate$).toHaveBeenCalledWith( + expect.anything(), + EXPECTED_ARGS, + ); + }); + + it('should sign off with protocol args', () => { + service.update$(RESCUE_STATION_DATA, PROTOCOL_MESSAGE_DATA).subscribe(); + + expect(graphqlServiceMock.mutate$).toHaveBeenCalledWith( + expect.anything(), + EXPECTED_ARGS, + ); + }); + + it('should sign off without protocol args', () => { + service.update$(RESCUE_STATION_DATA, undefined).subscribe(); + + expect(graphqlServiceMock.mutate$).toHaveBeenCalledWith(expect.anything(), { + ...EXPECTED_ARGS, + protocolMessageData: null, + }); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.ts new file mode 100644 index 00000000..db9003b1 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.ts @@ -0,0 +1,152 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, map } from 'rxjs'; + +import { + Mutation, + MutationSignInRescueStationArgs, + Unit, + UnitInput, +} from '@kordis/shared/model'; +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +import { AlertGroupAssignment } from '../component/alert-group/alert-group-assignment.model'; + +export interface ProtocolMessageData { + sender: Unit | string; + recipient: Unit | string; + channel: string; +} + +export interface RescueStationData { + rescueStationId: string; + strength: { + leaders: number; + subLeaders: number; + helpers: number; + }; + note: string; + assignedUnits: Unit[]; + assignedAlertGroups: AlertGroupAssignment[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class RescueStationEditService { + private readonly gqlService = inject(GraphqlService); + + signIn$( + rescueStationData: RescueStationData, + protocolMessageData: ProtocolMessageData, + ): Observable { + return this.gqlService + .mutate$<{ signInRescueStation: Mutation['signInRescueStation'] }>( + gql` + mutation ( + $rescueStationData: UpdateRescueStationInput! + $protocolMessageData: BaseCreateMessageInput! + ) { + signInRescueStation( + rescueStationData: $rescueStationData + protocolMessageData: $protocolMessageData + ) { + id + } + } + `, + { + rescueStationData: this.rescueStationDataToArgs(rescueStationData), + protocolMessageData: this.protocolDataToArgs(protocolMessageData), + }, + ) + .pipe(map(() => undefined)); + } + + update$( + rescueStationData: RescueStationData, + protocolMessageData?: ProtocolMessageData, + ): Observable { + return this.gqlService + .mutate$( + gql` + mutation ( + $rescueStationData: UpdateRescueStationInput! + $protocolMessageData: BaseCreateMessageInput + ) { + updateSignedInRescueStation( + rescueStationData: $rescueStationData + protocolMessageData: $protocolMessageData + ) { + id + } + } + `, + { + rescueStationData: this.rescueStationDataToArgs(rescueStationData), + protocolMessageData: protocolMessageData + ? this.protocolDataToArgs(protocolMessageData) + : null, + }, + ) + .pipe(map(() => undefined)); + } + + signOff$( + rescueStationId: string, + protocolMessageData: ProtocolMessageData, + ): Observable { + return this.gqlService + .mutate$( + gql` + mutation ( + $rescueStationId: String! + $protocolMessageData: BaseCreateMessageInput! + ) { + signOffRescueStation( + protocolMessageData: $protocolMessageData + rescueStationId: $rescueStationId + ) { + id + signedIn + } + } + `, + { + rescueStationId, + protocolMessageData: this.protocolDataToArgs(protocolMessageData), + }, + ) + .pipe(map(() => undefined)); + } + + private rescueStationDataToArgs( + payload: RescueStationData, + ): MutationSignInRescueStationArgs['rescueStationData'] { + return { + rescueStationId: payload.rescueStationId, + strength: payload.strength, + note: payload.note, + assignedUnitIds: payload.assignedUnits.map((unit) => unit.id), + assignedAlertGroups: payload.assignedAlertGroups.map((assignment) => ({ + alertGroupId: assignment.alertGroup.id, + unitIds: assignment.assignedUnits.map((unit) => unit.id), + })), + }; + } + + private protocolDataToArgs( + payload: ProtocolMessageData, + ): MutationSignInRescueStationArgs['protocolMessageData'] { + return { + ...payload, + sender: this.unitToUnitInput(payload.sender), + recipient: this.unitToUnitInput(payload.recipient), + }; + } + + private unitToUnitInput(unit: Unit | string): UnitInput { + return typeof unit === 'string' + ? { name: unit, type: 'UNKNOWN_UNIT' } + : { id: unit.id, type: 'REGISTERED_UNIT' }; + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/unit-selection.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/unit-selection.service.ts new file mode 100644 index 00000000..8c077f3a --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/unit-selection.service.ts @@ -0,0 +1,42 @@ +import { Injectable, inject } from '@angular/core'; +import { TypedDocumentNode } from 'apollo-angular'; + +import { Query, Unit } from '@kordis/shared/model'; +import { gql } from '@kordis/spa/core/graphql'; + +import { EntitySearchService } from '../../../services/entity-search.service'; +import { UnitSearchService } from '../../../services/unit-search.service'; +import { PossibleEntitySelectionsService } from './possible-entity-selection.service'; + +/* + This service handles the selection of units in a context where a unit can only be selected once. + */ +@Injectable() +export class PossibleUnitSelectionsService extends PossibleEntitySelectionsService< + Unit, + { units: Query['units'] } +> { + protected query: TypedDocumentNode<{ units: Query['units'] }> = gql` + query { + units { + id + callSign + callSignAbbreviation + name + status { + status + receivedAt + source + } + assignment { + __typename + id + name + } + } + } + `; + protected queryName = 'units' as const; + protected searchService: EntitySearchService = + inject(UnitSearchService); +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.spec.ts new file mode 100644 index 00000000..d7bf7a59 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.spec.ts @@ -0,0 +1,32 @@ +import { FormArray, FormControl, FormGroup } from '@angular/forms'; + +import { alertGroupMinUnitsValidator } from './alert-group-min-units.validator'; + +describe('alertGroupMinUnitsValidator', () => { + it('should return null if the FormArray contains a group with assigned units', () => { + const formGroup = new FormGroup({ + assignedUnits: new FormControl(['unit1']), + }); + const formArray = new FormArray([formGroup]); + + const result = alertGroupMinUnitsValidator(formArray); + expect(result).toBeNull(); + }); + + it('should return an error object if the FormArray contains a group without assigned units', () => { + const formGroup = new FormGroup({ + assignedUnits: new FormControl([]), + }); + const formArray = new FormArray([formGroup]); + + const result = alertGroupMinUnitsValidator(formArray); + expect(result).toEqual({ minUnitsInvalid: true }); + }); + + it('should return null if the FormArray is empty', () => { + const formArray = new FormArray([]); + + const result = alertGroupMinUnitsValidator(formArray); + expect(result).toBeNull(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.ts new file mode 100644 index 00000000..98c5ca5b --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.ts @@ -0,0 +1,18 @@ +import { AbstractControl, FormArray, ValidationErrors } from '@angular/forms'; + +import { AlertGroupAssignmentFormGroup } from '../rescue-station-edit-modal.component'; + +export const alertGroupMinUnitsValidator = ( + control: AbstractControl, +): ValidationErrors | null => { + const array = control as FormArray; + + for (const group of array.controls) { + if (group.controls.assignedUnits.value.length === 0) { + group.controls.assignedUnits.setErrors({ minUnitsInvalid: true }); + return { minUnitsInvalid: true }; + } + } + + return null; +}; diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.spec.ts new file mode 100644 index 00000000..a1da056c --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.spec.ts @@ -0,0 +1,27 @@ +import { FormControl, FormGroup } from '@angular/forms'; + +import { minStrengthValidator } from './min-strength.validator'; + +describe('minStrengthValidator', () => { + it('should return null if the total strength is greater than 0', () => { + const formGroup = new FormGroup({ + leaders: new FormControl(1), + subLeaders: new FormControl(1), + helpers: new FormControl(1), + }); + + const result = minStrengthValidator(formGroup); + expect(result).toBeNull(); + }); + + it('should return an error object if the total strength is 0', () => { + const formGroup = new FormGroup({ + leaders: new FormControl(0), + subLeaders: new FormControl(0), + helpers: new FormControl(0), + }); + + const result = minStrengthValidator(formGroup); + expect(result).toEqual({ totalStrengthInvalid: true }); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.ts new file mode 100644 index 00000000..79e0c1bd --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.ts @@ -0,0 +1,20 @@ +import { + AbstractControl, + FormControl, + FormGroup, + ValidationErrors, +} from '@angular/forms'; + +export const minStrengthValidator = ( + control: AbstractControl, +): ValidationErrors | null => { + const group = control as FormGroup<{ + leaders: FormControl; + subLeaders: FormControl; + helpers: FormControl; + }>; + + const { leaders, subLeaders, helpers } = group.controls; + const totalStrength = leaders.value + subLeaders.value + helpers.value; + return totalStrength > 0 ? null : { totalStrengthInvalid: true }; +}; diff --git a/libs/spa/feature/deployment/src/lib/services/alert-group-search.service.ts b/libs/spa/feature/deployment/src/lib/services/alert-group-search.service.ts new file mode 100644 index 00000000..3ee7dec5 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/services/alert-group-search.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; + +import { AlertGroup, Query } from '@kordis/shared/model'; +import { gql } from '@kordis/spa/core/graphql'; + +import { AbstractEntitySearchService } from './entity-search.service'; + +@Injectable({ + providedIn: 'root', +}) +export class AlertGroupSearchService extends AbstractEntitySearchService< + AlertGroup, + { alertGroups: Query['alertGroups'] }, + { alertGroup: Query['alertGroup'] } +> { + protected constructor() { + super( + gql` + query GetAlertGroups { + alertGroups { + id + name + } + } + `, + gql` + query GetAlertGroup($id: String!) { + alertGroup(id: $id) { + id + name + units { + id + name + callSign + note + status { + status + } + assignment { + __typename + id + name + } + } + assignment { + __typename + id + name + } + } + } + `, + 'alertGroups', + 'alertGroup', + ['name'], + ); + } +} diff --git a/libs/spa/feature/deployment/src/lib/services/entity-search.service.spec.ts b/libs/spa/feature/deployment/src/lib/services/entity-search.service.spec.ts new file mode 100644 index 00000000..602f6677 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/services/entity-search.service.spec.ts @@ -0,0 +1,117 @@ +import { TestBed } from '@angular/core/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { TypedDocumentNode } from 'apollo-angular'; +import { of } from 'rxjs'; + +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +import { AbstractEntitySearchService } from './entity-search.service'; + +interface TestEntity { + id: string; + name: string; + someMoreData: string; +} + +type QueryAllResponse = { + testEntities: TestEntity[]; +}; + +type QueryOneResponse = { + testEntity: TestEntity; +}; + +const getAllQuery: TypedDocumentNode = gql` + query GetAllTestEntities { + testEntities { + id + name + } + } +`; + +const getOneQuery: TypedDocumentNode = gql` + query GetTestEntity($id: String!) { + testEntity(id: $id) { + id + name + someMoreData + } + } +`; + +class TestEntitySearchService extends AbstractEntitySearchService< + TestEntity, + QueryAllResponse, + QueryOneResponse +> { + constructor() { + super(getAllQuery, getOneQuery, 'testEntities', 'testEntity', ['name']); + } +} + +const searchEngineAddAllMock = jest.fn(); +const searchEngineSearchMock = jest.fn(); +jest.mock('minisearch', () => + jest.fn().mockImplementation(() => ({ + addAll: searchEngineAddAllMock, + search: searchEngineSearchMock, + })), +); + +describe('TestEntitySearchService', () => { + let service: TestEntitySearchService; + let graphqlServiceMock: DeepMocked; + + beforeEach(() => { + graphqlServiceMock = createMock(); + graphqlServiceMock.queryOnce$.mockReturnValueOnce( + of({ + testEntities: [ + { id: '1', name: 'Entity One' }, + { id: '2', name: 'Entity Two' }, + ], + }), + ); + + TestBed.configureTestingModule({ + providers: [ + { provide: GraphqlService, useValue: graphqlServiceMock }, + TestEntitySearchService, + ], + }); + + service = TestBed.inject(TestEntitySearchService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch all entities initially', () => { + expect(graphqlServiceMock.queryOnce$).toHaveBeenCalledWith(getAllQuery); + expect(searchEngineAddAllMock).toHaveBeenCalledWith([ + { id: '1', name: 'Entity One' }, + { id: '2', name: 'Entity Two' }, + ]); + }); + + it('should populate entities correctly on search by term', async () => { + searchEngineSearchMock.mockReturnValueOnce([ + { id: '1', name: 'Entity One' }, + ]); + graphqlServiceMock.queryOnce$.mockReturnValueOnce( + of({ + testEntity: { + id: '1', + name: 'Entity One', + someMoreData: 'Some more data', + }, + }), + ); + const entities = await service.searchByTerm('One'); + expect(entities).toEqual([ + { id: '1', name: 'Entity One', someMoreData: 'Some more data' }, + ]); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts b/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts new file mode 100644 index 00000000..2bc3fffc --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts @@ -0,0 +1,54 @@ +import { inject } from '@angular/core'; +import { TypedDocumentNode } from 'apollo-angular'; +import MiniSearch from 'minisearch'; +import { firstValueFrom, map } from 'rxjs'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +export interface EntitySearchService { + searchByTerm(query: string): Promise; +} + +export abstract class AbstractEntitySearchService< + TEntity extends { + id: string; + }, + TQueryAll extends Record, + TQueryOne extends Record, +> implements EntitySearchService +{ + private readonly gqlService = inject(GraphqlService); + private readonly searchEngine: MiniSearch; + + protected constructor( + getAllQuery: TypedDocumentNode, + private readonly populateEntityQuery: TypedDocumentNode, + queryAllName: keyof TQueryAll, + private readonly queryOneName: keyof TQueryOne, + searchFields: (keyof TEntity)[], + ) { + this.searchEngine = new MiniSearch({ + fields: searchFields as string[], + }); + // first index all entities with the necessary data to search + this.gqlService + .queryOnce$(getAllQuery) + .pipe(map((res) => res[queryAllName])) + .subscribe((entities) => this.searchEngine.addAll(entities)); + } + + searchByTerm(query: string): Promise { + const res = this.searchEngine.search(query); + + // populate the entities to get the whole entity + return Promise.all(res.map((sr) => this.getEntity(sr.id))); + } + + private getEntity(id: string): Promise { + return firstValueFrom( + this.gqlService + .queryOnce$(this.populateEntityQuery, { id }) + .pipe(map((res) => res[this.queryOneName])), + ); + } +} diff --git a/libs/spa/feature/deployment/src/lib/services/unit-search.service.ts b/libs/spa/feature/deployment/src/lib/services/unit-search.service.ts new file mode 100644 index 00000000..a6aa50b2 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/services/unit-search.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; + +import { Query, Unit } from '@kordis/shared/model'; +import { gql } from '@kordis/spa/core/graphql'; + +import { AbstractEntitySearchService } from './entity-search.service'; + +@Injectable({ + providedIn: 'root', +}) +export class UnitSearchService extends AbstractEntitySearchService< + Unit, + { units: Query['units'] }, + { unit: Query['unit'] } +> { + constructor() { + super( + gql` + query GetUnits { + units { + id + callSign + callSignAbbreviation + name + assignment { + __typename + } + } + } + `, + gql` + query GetUnit($id: String!) { + unit(id: $id) { + id + callSign + callSignAbbreviation + name + status { + status + receivedAt + source + } + assignment { + __typename + id + name + } + } + } + `, + 'units', + 'unit', + ['callSign', 'callSignAbbreviation', 'name'], + ); + } +} diff --git a/libs/spa/feature/deployment/src/lib/status-explanations.ts b/libs/spa/feature/deployment/src/lib/status-explanations.ts new file mode 100644 index 00000000..1a9d97a6 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/status-explanations.ts @@ -0,0 +1,9 @@ +export const STATUS_EXPLANATIONS: Readonly> = + Object.freeze({ + 1: 'Einsatzbereit auf Funk', + 2: 'Einsatzbereit auf Wache', + 3: 'Einsatz übernommen / Anfahrt zum Einsatzort', + 4: 'Ankunft am Einsatzort', + 5: 'Sprechwunsch', + 6: 'Nicht einsatzbereit', + }); diff --git a/libs/spa/feature/deployment/src/test-setup.ts b/libs/spa/feature/deployment/src/test-setup.ts new file mode 100644 index 00000000..58672d7b --- /dev/null +++ b/libs/spa/feature/deployment/src/test-setup.ts @@ -0,0 +1,9 @@ +import 'jest-preset-angular/setup-jest'; + +// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +globalThis.ngJest = { + testEnvironmentOptions: { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + }, +}; diff --git a/libs/spa/feature/deployment/tsconfig.json b/libs/spa/feature/deployment/tsconfig.json new file mode 100644 index 00000000..e75ec232 --- /dev/null +++ b/libs/spa/feature/deployment/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es2022", + "useDefineForClassFields": false, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/spa/feature/deployment/tsconfig.lib.json b/libs/spa/feature/deployment/tsconfig.lib.json new file mode 100644 index 00000000..c6f0d007 --- /dev/null +++ b/libs/spa/feature/deployment/tsconfig.lib.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts" + ], + "include": ["src/**/*.ts", "../../../../reset.d.ts"] +} diff --git a/libs/spa/feature/deployment/tsconfig.spec.json b/libs/spa/feature/deployment/tsconfig.spec.json new file mode 100644 index 00000000..a8576090 --- /dev/null +++ b/libs/spa/feature/deployment/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "esModuleInterop": true + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/spa/view/dashboard/src/lib/dashboard.component.html b/libs/spa/view/dashboard/src/lib/dashboard.component.html index aa9be872..b25e538f 100644 --- a/libs/spa/view/dashboard/src/lib/dashboard.component.html +++ b/libs/spa/view/dashboard/src/lib/dashboard.component.html @@ -1,22 +1,22 @@ - +
- +
  • {{ (user$ | async)?.firstName }} {{ (user$ | async)?.lastName }}
  • -
  • Abmelden
  • +
  • Abmelden
  • -
  • +
  • Credits und Lizenzen
@@ -24,14 +24,14 @@
-
-
Protocol
+
+
Protocol
-
-
Operations
+
+
Operations
-
-
Deployments
+
+
diff --git a/libs/spa/view/dashboard/src/lib/dashboard.component.spec.ts b/libs/spa/view/dashboard/src/lib/dashboard.component.spec.ts index 0c9531e6..960f6b3a 100644 --- a/libs/spa/view/dashboard/src/lib/dashboard.component.spec.ts +++ b/libs/spa/view/dashboard/src/lib/dashboard.component.spec.ts @@ -33,10 +33,6 @@ describe('DashboardComponent', () => { fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - it('should call authService.logout() when logout is called', () => { component.logout(); expect(authServiceMock.logout).toHaveBeenCalled(); diff --git a/libs/spa/view/dashboard/src/lib/dashboard.component.ts b/libs/spa/view/dashboard/src/lib/dashboard.component.ts index 621807f1..e30bc087 100644 --- a/libs/spa/view/dashboard/src/lib/dashboard.component.ts +++ b/libs/spa/view/dashboard/src/lib/dashboard.component.ts @@ -14,6 +14,7 @@ import { NzMenuModule } from 'ng-zorro-antd/menu'; import { NzModalModule, NzModalService } from 'ng-zorro-antd/modal'; import { AUTH_SERVICE } from '@kordis/spa/core/auth'; +import { DeploymentsComponent } from '@kordis/spa/feature/deployment'; @Component({ selector: 'krd-dashboard-view', @@ -28,6 +29,7 @@ import { AUTH_SERVICE } from '@kordis/spa/core/auth'; NzMenuModule, NzModalModule, NzAvatarModule, + DeploymentsComponent, ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.css', diff --git a/package-lock.json b/package-lock.json index 80c9ee1b..6fec4366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "graphql-sse": "^2.5.2", "jsonwebtoken": "^9.0.1", "jwks-rsa": "^3.0.1", + "minisearch": "^6.3.0", "mongodb-client-encryption": "^6.0.0", "mongoose": "^8.0.0", "nestjs-graphql-connection": "^1.0.3", @@ -25061,6 +25062,11 @@ "node": ">=8" } }, + "node_modules/minisearch": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-6.3.0.tgz", + "integrity": "sha512-ihFnidEeU8iXzcVHy74dhkxh/dn8Dc08ERl0xwoMMGqp4+LvRSCgicb+zGqWthVokQKvCSxITlh3P08OzdTYCQ==" + }, "node_modules/minizlib": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", diff --git a/package.json b/package.json index efe2a857..30c016dc 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "graphql-sse": "^2.5.2", "jsonwebtoken": "^9.0.1", "jwks-rsa": "^3.0.1", + "minisearch": "^6.3.0", "mongodb-client-encryption": "^6.0.0", "mongoose": "^8.0.0", "nestjs-graphql-connection": "^1.0.3", diff --git a/tsconfig.base.json b/tsconfig.base.json index 4c220c65..34faffb0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -32,6 +32,7 @@ "@kordis/shared/test-helpers": ["libs/shared/test-helpers/src/index.ts"], "@kordis/spa/core/auth": ["libs/spa/core/auth/src/index.ts"], "@kordis/spa/core/graphql": ["libs/spa/core/graphql/src/index.ts"], + "@kordis/spa/core/misc": ["libs/spa/core/misc/src/index.ts"], "@kordis/spa/core/observability": [ "libs/spa/core/observability/src/index.ts" ], From 3c10f4459c70ff5bc5daee795a33827f4f46cb49 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 10:47:18 +0200 Subject: [PATCH 08/26] fix: run prettier --- CONTRIBUTING.md | 10 +- .../deployment-unit-details.component.html | 37 ++----- .../deployment/deployments.component.html | 46 ++++---- .../rescue-station-edit-modal.component.html | 103 ++++++++++++------ .../src/lib/dashboard.component.html | 24 ++-- 5 files changed, 121 insertions(+), 99 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec7b0a2b..948da6dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,11 +67,11 @@ generate a new component, run `npx nx g @nrwl/angular:component --project=spa- --name=`. This will generate a new component in the correct folder. For more information, check out the [NX documentation]https://nx.dev/more-concepts/nx-and-angular). By -default the Component will be standalone and with `OnPush` Change Detection. -Please try to stick to this as much as possible. You can serve the SPA with -`npm run serve:spa`. Instead of an OAuth Provider, in development environments -you will get redirected to a login page where you can choose a persona. This -will set a JWT in the local storage. +default the Component will be standalone and with `OnPush` Change Detection. Please +try to stick to this as much as possible. You can serve the SPA with `npm run serve:spa`. +Instead of an OAuth Provider, in development environments you will get redirected +to a login page where you can choose a persona. This will set a JWT in the local +storage. We agreed on supporting only Chromium based browsers with their 2 latest major versions. This allows us to use features that are available for these browsers diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html index 81a08f3e..d4e74e9f 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html @@ -10,38 +10,23 @@ (ngModelChange)="updateStatus()" nzButtonStyle="solid" > - 1 - 2 - 3 - 4 5 - 6 +
-
+
@if (rescueStation.signedIn) { - } @else { }
- diff --git a/libs/spa/view/dashboard/src/lib/dashboard.component.html b/libs/spa/view/dashboard/src/lib/dashboard.component.html index b25e538f..75827ab7 100644 --- a/libs/spa/view/dashboard/src/lib/dashboard.component.html +++ b/libs/spa/view/dashboard/src/lib/dashboard.component.html @@ -1,22 +1,22 @@ - +
- +
  • {{ (user$ | async)?.firstName }} {{ (user$ | async)?.lastName }}
  • -
  • Abmelden
  • +
  • Abmelden
  • -
  • +
  • Credits und Lizenzen
@@ -24,13 +24,13 @@
-
-
Protocol
+
+
Protocol
-
-
Operations
+
+
Operations
-
+
From 115ed91c00834e706ccb7f8bbe14949b71a7a0b4 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 14:28:16 +0200 Subject: [PATCH 09/26] chore: remove dashboard component test due to https://github.com/angular/angular/issues/48432 It is not possible to override standalone components. Thus, leaving us with the only possibility of mocking everything the dashboard component provides (which is quite a lot) --- .../src/lib/dashboard.component.spec.ts | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 libs/spa/view/dashboard/src/lib/dashboard.component.spec.ts diff --git a/libs/spa/view/dashboard/src/lib/dashboard.component.spec.ts b/libs/spa/view/dashboard/src/lib/dashboard.component.spec.ts deleted file mode 100644 index 960f6b3a..00000000 --- a/libs/spa/view/dashboard/src/lib/dashboard.component.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { createMock } from '@golevelup/ts-jest'; -import { NzModalModule, NzModalService } from 'ng-zorro-antd/modal'; -import { of } from 'rxjs'; - -import { AUTH_SERVICE, AuthService } from '@kordis/spa/core/auth'; - -import { DashboardComponent } from './dashboard.component'; - -describe('DashboardComponent', () => { - let component: DashboardComponent; - let fixture: ComponentFixture; - const authServiceMock = createMock({ - user$: of(null), - }); - const modalServiceMock = createMock(); - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BrowserAnimationsModule], - providers: [{ provide: AUTH_SERVICE, useValue: authServiceMock }], - }).compileComponents(); - - TestBed.overrideModule(NzModalModule, { - set: { - providers: [{ provide: NzModalService, useValue: modalServiceMock }], - }, - }); - - fixture = TestBed.createComponent(DashboardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should call authService.logout() when logout is called', () => { - component.logout(); - expect(authServiceMock.logout).toHaveBeenCalled(); - }); - - it('should call modalService.create() when showCreditsAndLicensesModal is called', () => { - component.showCreditsAndLicensesModal(); - expect(modalServiceMock.create).toHaveBeenCalled(); - }); -}); From fc365ebf67c60c065a704fba5be614cc073d8484 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 14:32:21 +0200 Subject: [PATCH 10/26] chore(spa-deployment): minor refactorings --- .../components/deployment/deployments.component.html | 2 +- libs/spa/view/dashboard/src/lib/dashboard.component.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html index 28bd6712..4b5e60f5 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html @@ -34,7 +34,7 @@
@for (station of signedOffRescueStations$ | async; track station.id) { - {{ station.name }} } diff --git a/libs/spa/view/dashboard/src/lib/dashboard.component.ts b/libs/spa/view/dashboard/src/lib/dashboard.component.ts index e6ce4211..e9955c11 100644 --- a/libs/spa/view/dashboard/src/lib/dashboard.component.ts +++ b/libs/spa/view/dashboard/src/lib/dashboard.component.ts @@ -22,15 +22,15 @@ import { DeploymentsComponent } from '@kordis/spa/feature/deployment'; standalone: true, imports: [ CommonModule, - NzLayoutComponent, - NzHeaderComponent, + DeploymentsComponent, + NzAvatarModule, NzContentComponent, - NzDropdownMenuComponent, NzDropDownDirective, + NzDropdownMenuComponent, + NzHeaderComponent, + NzLayoutComponent, NzMenuModule, NzModalModule, - NzAvatarModule, - DeploymentsComponent, ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.css', From 12786593b29045614925e5656c291d25f14fbf7c Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 14:36:00 +0200 Subject: [PATCH 11/26] chore: run prettier --- .../src/lib/components/deployment/deployments.component.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html index 4b5e60f5..deb083e3 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html @@ -34,7 +34,9 @@
@for (station of signedOffRescueStations$ | async; track station.id) { - {{ station.name }} } From ae3486216830174f55db681d97451fdd2f427b90 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 14:43:41 +0200 Subject: [PATCH 12/26] chore(spa-deployment): remove test due to !thymikee/jest-preset-angular/issues/2194 --- .../unit-selection-option.component.spec.ts | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts deleted file mode 100644 index 14af4dbd..00000000 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { UnitSelectionOptionComponent } from './unit-selection-option.component'; - -describe('StrengthComponent', () => { - let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [UnitSelectionOptionComponent], - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(UnitSelectionOptionComponent); - }); - - it('should create', async () => { - fixture.componentRef.setInput('unit', { - id: 1, - callSign: 'unit1', - name: 'name', - }); - fixture.detectChanges(); - - expect(fixture.componentInstance).toBeTruthy(); - }); -}); From f4046e7066a8ee6318bafc83063e8ba3c3f4d9f0 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 22:41:02 +0200 Subject: [PATCH 13/26] chore(api-deployment): add tests --- .../deployment-alert-group.component.spec.ts | 6 - .../deployment-unit-details.component.spec.ts | 15 +- .../deployment-unit.component.spec.ts | 7 - .../deployment/deployments.component.spec.ts | 39 +++++ .../deployment/deployments.component.ts | 4 +- .../deplyoment-card.component.spec.ts | 94 ++++++++++++ .../deployment/deplyoment-card.component.ts | 7 +- .../deployment/status-badge.component.spec.ts | 6 +- ...alert-group-autocomplete.component.spec.ts | 28 ++++ .../alert-group-autocomplete.component.ts | 12 +- .../alert-group-selection.component.spec.ts | 32 ++++ .../alert-group-selection.component.ts | 6 +- .../alert-group-selections.component.spec.ts | 25 ++++ .../alert-group-selections.component.ts | 53 +++---- .../component/protocol-data.component.spec.ts | 33 +++++ .../component/protocol-data.component.ts | 11 +- .../component/strength.component.spec.ts | 5 - .../unit-selection-option.component.spec.ts | 22 +++ ...escue-station-edit-modal.component.spec.ts | 138 ++++++++++++++++++ .../rescue-station-edit-modal.component.ts | 4 +- libs/spa/feature/deployment/src/test-setup.ts | 14 ++ 21 files changed, 468 insertions(+), 93 deletions(-) create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts create mode 100644 libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts index 17e825c2..eebbe9aa 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts @@ -5,12 +5,6 @@ import { DeploymentAlertGroupComponent } from './deployment-alert-group.componen describe('DeploymentAlertGroupComponent', () => { let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DeploymentAlertGroupComponent], - }).compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(DeploymentAlertGroupComponent); }); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts index eb45cb02..ba1555f0 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts @@ -8,19 +8,10 @@ import { DeploymentUnitDetailsComponent } from './deployment-unit-details.compon describe('DeploymentUnitDetailsComponent', () => { let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - providers: [ - { - provide: GraphqlService, - useValue: createMock(), - }, - ], - imports: [DeploymentUnitDetailsComponent], - }).compileComponents(); - }); - beforeEach(() => { + TestBed.overrideProvider(GraphqlService, { + useValue: createMock(), + }); fixture = TestBed.createComponent(DeploymentUnitDetailsComponent); fixture.componentRef.setInput('unit', { id: '1', diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts index 10e2a86b..a82936e7 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts @@ -1,17 +1,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { DeploymentUnitComponent } from './deployment-unit.component'; describe('DeploymentUnitComponent', () => { let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DeploymentUnitComponent, NoopAnimationsModule], - }).compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(DeploymentUnitComponent); }); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.spec.ts new file mode 100644 index 00000000..965dcc33 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { NzModalService } from 'ng-zorro-antd/modal'; +import { of } from 'rxjs'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { DeploymentsComponent } from './deployments.component'; +import { DeploymentCardComponent } from './deplyoment-card.component'; + +describe('DeploymentsComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + TestBed.overrideProvider(GraphqlService, { + useValue: createMock({ + query: jest.fn().mockReturnValue({ + $: of(), + }), + }), + }); + TestBed.overrideProvider(NzModalService, { + useValue: createMock(), + }); + TestBed.overrideComponent(DeploymentCardComponent, { + set: { + selector: 'krd-deployment-card', + template: '
', + }, + }); + + fixture = TestBed.createComponent(DeploymentsComponent); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts index b40cbd96..9d6747e5 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts @@ -100,8 +100,8 @@ const DEPLOYMENTS_QUERY = gql` NzIconDirective, NzTooltipDirective, ], - templateUrl: `./deployments.component.html`, - styleUrl: `./deployments.component.css`, + templateUrl: './deployments.component.html', + styleUrl: './deployments.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeploymentsComponent { diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.spec.ts new file mode 100644 index 00000000..726bb4f2 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.spec.ts @@ -0,0 +1,94 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { + AlertGroup, + DeploymentAlertGroup, + DeploymentAssignment, + DeploymentUnit, + Unit, +} from '@kordis/shared/model'; + +import { DeploymentCardComponent } from './deplyoment-card.component'; + +global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); + +describe('DeploymentCardComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(DeploymentCardComponent); + }); + + it('should create', () => { + const component = fixture.componentInstance; + fixture.componentRef.setInput('assignments', []); + fixture.componentRef.setInput('name', 'Deployment Name'); + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should sort assignments correctly', () => { + const mockAssignments: DeploymentAssignment[] = [ + { + __typename: 'DeploymentUnit', + unit: { id: '3', status: { status: 4 } } as Unit, + }, + { + __typename: 'DeploymentUnit', + unit: { id: '1', status: { status: 1 } } as Unit, + }, + { + __typename: 'DeploymentAlertGroup', + alertGroup: { id: '1' } as AlertGroup, + assignedUnits: [ + { + __typename: 'DeploymentUnit', + unit: { id: '3', status: { status: 6 } } as Unit, + }, + { + __typename: 'DeploymentUnit', + unit: { id: '4', status: { status: 1 } } as Unit, + }, + ], + }, + { + __typename: 'DeploymentUnit', + unit: { id: '2', status: { status: 3 } } as Unit, + }, + ]; + + fixture.componentRef.setInput('assignments', mockAssignments); + fixture.componentRef.setInput('name', 'Deployment Name'); + fixture.detectChanges(); + + const sortedAssignments = fixture.componentInstance.sortedAssignments(); + expect(sortedAssignments).toEqual([ + { + __typename: 'DeploymentUnit', + unit: { id: '3', status: { status: 4 } }, + }, + { + __typename: 'DeploymentUnit', + unit: { id: '2', status: { status: 3 } }, + }, + { + __typename: 'DeploymentUnit', + unit: { id: '1', status: { status: 1 } }, + }, + { + __typename: 'DeploymentAlertGroup', + alertGroup: { id: '1' }, + assignedUnits: [ + { + __typename: 'DeploymentUnit', + unit: { id: '4', status: { status: 1 } }, + }, + { + __typename: 'DeploymentUnit', + unit: { id: '3', status: { status: 6 } }, + }, + ], + }, + ]); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts index dc664b79..2608b368 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts @@ -7,7 +7,6 @@ import { } from '@angular/core'; import { NzCardComponent } from 'ng-zorro-antd/card'; -import '@kordis/shared/model'; import { DeploymentAssignment } from '@kordis/shared/model'; import { DeploymentAlertGroupComponent } from './deployment-alert-group.component'; @@ -97,15 +96,11 @@ export class DeploymentCardComponent { this.assignments(), ).map((assignment) => { if (assignment.__typename === 'DeploymentAlertGroup') { - const assignedUnits = assignment.assignedUnits.sort( + assignment.assignedUnits.sort( (a, b) => STATUS_SORT_ORDER[a.unit.status?.status ?? -1] - STATUS_SORT_ORDER[b.unit.status?.status ?? -1], ); - return { - ...assignment, - assignedUnits, - }; } return assignment; }); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts index b2bed4bb..a086fc36 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts @@ -5,11 +5,7 @@ import { StatusBadgeComponent } from './status-badge.component'; describe('StrengthBadgeComponent', () => { let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [StatusBadgeComponent], - }).compileComponents(); - + beforeEach(() => { fixture = TestBed.createComponent(StatusBadgeComponent); }); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.spec.ts new file mode 100644 index 00000000..e0fbaf80 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.spec.ts @@ -0,0 +1,28 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; + +import { PossibleAlertGroupSelectionsService } from '../../service/alert-group-selection.service'; +import { AlertGroupAutocompleteComponent } from './alert-group-autocomplete.component'; + +describe('AlertGroupAutocompleteComponent', () => { + let component: AlertGroupAutocompleteComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + { + provide: PossibleAlertGroupSelectionsService, + useValue: createMock(), + }, + ], + }); + fixture = TestBed.createComponent(AlertGroupAutocompleteComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts index ef4c03b8..30ab08d2 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts @@ -18,12 +18,12 @@ import { PossibleAlertGroupSelectionsService } from '../../service/alert-group-s selector: 'krd-alert-group-autocomplete', standalone: true, imports: [ - NzInputDirective, - NzAutocompleteModule, AsyncPipe, - ReactiveFormsModule, - NzSelectModule, + NzAutocompleteModule, + NzInputDirective, NzNoAnimationDirective, + NzSelectModule, + ReactiveFormsModule, ], template: ` (false); + readonly showErrorState = input(false); readonly searchInput = new FormControl(''); readonly possibleAlertGroupSelectionService = inject( @@ -74,7 +74,7 @@ export class AlertGroupAutocompleteComponent { ); private readonly alertGroupSelectedSubject$ = new Subject(); // eslint-disable-next-line rxjs/finnish - @Output() alertGroupSelected = this.alertGroupSelectedSubject$ + @Output() readonly alertGroupSelected = this.alertGroupSelectedSubject$ .asObservable() .pipe(share()); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.spec.ts new file mode 100644 index 00000000..ecb88821 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.spec.ts @@ -0,0 +1,32 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormArray, FormGroup } from '@angular/forms'; + +import { AlertGroupSelectionComponent } from './alert-group-selection.component'; + +describe('AlertGroupSelectionComponent', () => { + let component: AlertGroupSelectionComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + TestBed.overrideComponent(AlertGroupSelectionComponent, { + set: { + selector: 'krd-units-select', + template: 'unit selection', + }, + }); + fixture = TestBed.createComponent(AlertGroupSelectionComponent); + }); + + it('should create', () => { + fixture.componentRef.setInput( + 'formGroup', + new FormGroup({ + alertGroup: new FormGroup({}), + assignedUnits: new FormArray([]), + }), + ); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts index 62fabc6f..fedf8477 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts @@ -45,14 +45,14 @@ import { UnitsSelectComponent } from '../unit/units-select.component'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class AlertGroupSelectionComponent { - readonly removed = output(); - - formGroup = input.required< + readonly formGroup = input.required< FormGroup<{ alertGroup: FormControl; assignedUnits: FormControl; }> >(); + readonly removed = output(); + private unitSelectionEle = viewChild(UnitsSelectComponent); removeAlertGroup(): void { diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts new file mode 100644 index 00000000..a18141b7 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormArray } from '@angular/forms'; +import { createMock } from '@golevelup/ts-jest'; + +import { PossibleAlertGroupSelectionsService } from '../../service/alert-group-selection.service'; +import { AlertGroupSelectionsComponent } from './alert-group-selections.component'; + +describe('AlertGroupSelectionsComponent', () => { + let component: AlertGroupSelectionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + TestBed.overrideProvider(PossibleAlertGroupSelectionsService, { + useValue: createMock(), + }); + fixture = TestBed.createComponent(AlertGroupSelectionsComponent); + }); + + it('should create', () => { + fixture.componentRef.setInput('formArray', new FormArray([])); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts index 57be20b0..610a2ded 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts @@ -1,5 +1,3 @@ -import { CdkTrapFocus } from '@angular/cdk/a11y'; -import { AsyncPipe, NgIf } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -12,22 +10,14 @@ import { FormArray, FormControl, FormGroup, - FormsModule, NonNullableFormBuilder, - ReactiveFormsModule, } from '@angular/forms'; import { NzCardComponent } from 'ng-zorro-antd/card'; -import { NzIconDirective } from 'ng-zorro-antd/icon'; -import { NzSelectModule } from 'ng-zorro-antd/select'; -import { NzTagComponent } from 'ng-zorro-antd/tag'; import { AlertGroup, Unit } from '@kordis/shared/model'; -import { StatusBadgeComponent } from '../../../deployment/status-badge.component'; import { PossibleAlertGroupSelectionsService } from '../../service/alert-group-selection.service'; import { PossibleUnitSelectionsService } from '../../service/unit-selection.service'; -import { UnitSelectionOptionComponent } from '../unit/unit-selection-option.component'; -import { UnitsSelectComponent } from '../unit/units-select.component'; import { AlertGroupAutocompleteComponent } from './alert-group-autocomplete.component'; import { AlertGroupSelectionComponent } from './alert-group-selection.component'; @@ -35,39 +25,30 @@ import { AlertGroupSelectionComponent } from './alert-group-selection.component' selector: 'krd-alert-group-selections', standalone: true, imports: [ - AsyncPipe, - ReactiveFormsModule, - NzSelectModule, - StatusBadgeComponent, - FormsModule, - UnitSelectionOptionComponent, - CdkTrapFocus, - NzTagComponent, AlertGroupAutocompleteComponent, - NzCardComponent, - UnitsSelectComponent, - NzIconDirective, AlertGroupSelectionComponent, - NgIf, + NzCardComponent, ], template: ` - -
- @for ( - alertGroupAssignment of formArray().controls; - track alertGroupAssignment.value.alertGroup!.id - ) { - - } -
-
+ @if (formArray().length) { + +
+ @for ( + alertGroupAssignment of formArray().controls; + track alertGroupAssignment.value.alertGroup!.id + ) { + + } +
+
+ } `, styles: ` :host { @@ -98,7 +79,7 @@ import { AlertGroupSelectionComponent } from './alert-group-selection.component' changeDetection: ChangeDetectionStrategy.OnPush, }) export class AlertGroupSelectionsComponent { - formArray = input.required< + readonly formArray = input.required< FormArray< FormGroup<{ alertGroup: FormControl; diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.spec.ts new file mode 100644 index 00000000..3045a208 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.spec.ts @@ -0,0 +1,33 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup } from '@angular/forms'; + +import { ProtocolDataComponent } from './protocol-data.component'; + +describe('ProtocolDataComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(ProtocolDataComponent); + }); + + it('should focus initially', async () => { + fixture.componentRef.setInput( + 'formGroup', + new FormGroup({ + sender: new FormControl(''), + recipient: new FormControl(''), + channel: new FormControl(''), + }), + ); + + fixture.componentRef.setInput('focusInitially', true); + + fixture.detectChanges(); + + await fixture.whenStable(); + const senderInput = fixture.nativeElement.querySelector( + '[data-testid="sender-input"]', + ); + expect(document.activeElement).toBe(senderInput); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts index e4fd03f7..c94835eb 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts @@ -21,9 +21,14 @@ import { Unit } from '@kordis/shared/model'; imports: [CommonModule, ReactiveFormsModule, NzInputDirective], template: `
- + -
`, @@ -45,7 +50,7 @@ export class ProtocolDataComponent implements OnInit { channel: FormControl; }> >(); - focusInitially = input(false, { + readonly focusInitially = input(false, { transform: (x: unknown) => x !== false, }); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts index eed38a24..233bd4a1 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts @@ -5,11 +5,6 @@ import { StrengthComponent } from './strength.component'; describe('StrengthComponent', () => { let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [StrengthComponent], - }).compileComponents(); - }); beforeEach(() => { fixture = TestBed.createComponent(StrengthComponent); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts new file mode 100644 index 00000000..b5b102cf --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UnitSelectionOptionComponent } from './unit-selection-option.component'; + +describe('UnitSelectionOptionComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(UnitSelectionOptionComponent); + }); + + it('should create', async () => { + fixture.componentRef.setInput('unit', { + id: 1, + callSign: 'unit1', + name: 'name', + }); + fixture.detectChanges(); + + expect(fixture.componentInstance).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts new file mode 100644 index 00000000..16221267 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts @@ -0,0 +1,138 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createMock } from '@golevelup/ts-jest'; +import { NZ_MODAL_DATA, NzModalRef } from 'ng-zorro-antd/modal'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { RescueStationEditModalComponent } from './rescue-station-edit-modal.component'; +import { RescueStationEditService } from './service/rescue-station-edit.service'; + +describe('RescueStationEditModalComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.overrideProvider(NzModalRef, { + useValue: createMock(), + }); + TestBed.overrideProvider(RescueStationEditService, { + useValue: createMock(), + }); + TestBed.overrideProvider(GraphqlService, { + useValue: createMock(), + }); + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule], + }); + }); + + it('should default units if not signed in', () => { + TestBed.overrideProvider(NZ_MODAL_DATA, { + useValue: { + strength: { + helpers: 3, + subLeaders: 2, + leaders: 1, + }, + note: '', + defaultUnits: [ + { + id: '1', + name: 'Unit 1', + __typename: 'Unit', + }, + ], + assignments: [], + signedIn: false, + }, + }); + fixture = TestBed.createComponent(RescueStationEditModalComponent); + fixture.detectChanges(); + + expect( + fixture.componentInstance.formGroup.controls.rescueStationData.value + .units, + ).toEqual([ + { + id: '1', + name: 'Unit 1', + __typename: 'Unit', + }, + ]); + }); + + it('should set unit and alert groups from assignments', async () => { + TestBed.overrideProvider(NZ_MODAL_DATA, { + useValue: { + id: '1', + strength: { + helpers: 3, + subLeaders: 2, + leaders: 1, + }, + note: '', + defaultUnits: [], + assignments: [ + { + unit: { + id: '1', + name: 'Unit 1', + __typename: 'Unit', + }, + __typename: 'DeploymentUnit', + }, + { + __typename: 'DeploymentAlertGroup', + alertGroup: { + id: '1', + name: 'Group 1', + __typename: 'AlertGroup', + }, + assignedUnits: [ + { + unit: { + id: '2', + name: 'Unit 2', + __typename: 'Unit', + }, + }, + ], + }, + ], + signedIn: true, + }, + }); + fixture = TestBed.createComponent(RescueStationEditModalComponent); + fixture.detectChanges(); + + expect( + fixture.componentInstance.formGroup.controls.rescueStationData.value + .units, + ).toEqual([ + { + id: '1', + name: 'Unit 1', + __typename: 'Unit', + }, + ]); + expect( + fixture.componentInstance.formGroup.controls.rescueStationData.value + .alertGroups, + ).toEqual([ + { + alertGroup: { + __typename: 'AlertGroup', + id: '1', + name: 'Group 1', + }, + assignedUnits: [ + { + __typename: 'Unit', + id: '2', + name: 'Unit 2', + }, + ], + }, + ]); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts index f15e4c1b..a20db6ad 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts @@ -59,8 +59,8 @@ export type AlertGroupAssignmentFormGroup = FormGroup<{ StrengthComponent, UnitsSelectComponent, ], - templateUrl: `./rescue-station-edit-modal.component.html`, - styleUrl: `./rescue-station-edit-modal.component.css`, + templateUrl: './rescue-station-edit-modal.component.html', + styleUrl: './rescue-station-edit-modal.component.css', providers: [ PossibleUnitSelectionsService, PossibleAlertGroupSelectionsService, diff --git a/libs/spa/feature/deployment/src/test-setup.ts b/libs/spa/feature/deployment/src/test-setup.ts index 58672d7b..25321c0f 100644 --- a/libs/spa/feature/deployment/src/test-setup.ts +++ b/libs/spa/feature/deployment/src/test-setup.ts @@ -7,3 +7,17 @@ globalThis.ngJest = { errorOnUnknownProperties: true, }, }; + +// workaround for current css parser issue with @layer https://github.com/thymikee/jest-preset-angular/issues/2194 +let consoleSpy: jest.SpyInstance; +beforeAll(() => { + consoleSpy = jest + .spyOn(global.console, 'error') + .mockImplementation((message) => { + if (!message?.message?.includes('Could not parse CSS stylesheet')) { + global.console.warn(message); + } + }); +}); + +afterAll(() => consoleSpy.mockRestore()); From 5d6d46c2c41649c6c3f044fad15a90a49c1f6ab6 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 12 Jul 2024 23:18:25 +0200 Subject: [PATCH 14/26] chore: small changes --- .../lib/components/deployment/deployment-unit.component.ts | 2 +- .../rescue-station-edit-modal.component.html | 4 ++-- .../rescue-station-edit-modal.component.ts | 6 ++++-- tsconfig.base.json | 1 - 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts index a55d5439..b0894445 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts @@ -80,7 +80,7 @@ import { StatusBadgeComponent } from './status-badge.component'; `, }) export class DeploymentUnitComponent { - unit = input.required(); + readonly unit = input.required(); protected showPopover = signal(false); constructor(iconService: NzIconService) { diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html index b5ba3d65..24f72d0e 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html @@ -104,8 +104,8 @@

RW-Daten

} @else { +
+ `, + providers: [PossibleUnitSelectionsService], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: ` + span { + font-weight: 500; + } + + .actions { + margin-top: 10px; + display: flex; + justify-content: flex-end; + } + `, +}) +export class AlertGroupEditModalComponent { + readonly loadingState = signal<'INITIAL' | 'UPDATE' | null>(null); + readonly #modal = inject(NzModalRef); + + private readonly fb = inject(NonNullableFormBuilder); + readonly formArray = this.fb.array( + [], + alertGroupMinUnitsValidator, + ); + + private readonly possibleUnitSelectionsService = inject( + PossibleUnitSelectionsService, + ); + private readonly notificationService = inject(NzNotificationService); + private readonly gql = inject(GraphqlService); + + constructor() { + this.loadAlertGroups(); + } + + loadAlertGroups(): void { + this.loadingState.set('INITIAL'); + this.gql + .queryOnce$<{ alertGroups: Query['alertGroups'] }>(gql` + fragment UnitData on Unit { + id + name + callSign + callSignAbbreviation + } + query GetAlertGroups { + alertGroups { + id + name + currentUnits { + ...UnitData + } + } + } + `) + .subscribe(({ alertGroups }) => { + for (const alertGroup of alertGroups) { + alertGroup.currentUnits.forEach((unit) => + this.possibleUnitSelectionsService.markAsSelected(unit), + ); + this.formArray.push( + this.fb.group({ + alertGroup: this.fb.control(alertGroup), + assignedUnits: this.fb.control(alertGroup.currentUnits), + }), + ); + } + + this.loadingState.set(null); + }); + } + + updateAlertGroups(): void { + this.loadingState.set('UPDATE'); + + const updateOperations = this.formArray.controls + .filter((alertGroupControl) => alertGroupControl.dirty) + .map((alertGroupControl) => + this.gql.mutate$( + gql` + mutation UpdateCurrentAlertGroupUnits( + $alertGroupId: ID! + $unitIds: [ID!]! + ) { + setCurrentAlertGroupUnits( + alertGroupId: $alertGroupId + unitIds: $unitIds + ) { + id + currentUnits { + id + name + callSign + callSignAbbreviation + } + } + } + `, + { + alertGroupId: alertGroupControl.value.alertGroup?.id, + unitIds: alertGroupControl.value.assignedUnits?.map( + (unit: Unit) => unit.id, + ), + }, + ), + ); + + if (updateOperations.length === 0) { + this.loadingState.set(null); + this.#modal.close(); + return; + } + + forkJoin(updateOperations) + .subscribe({ + next: () => { + this.notificationService.success( + 'Alarmgruppen aktualisiert', + `Die Standardeinheiten der Alarmgruppen wurden aktualisiert.`, + ); + this.#modal.close(); + }, + error: () => + this.notificationService.error( + 'Fehler beim Aktualisieren', + `Die Standardeinheiten der Alarmgruppen konnten nicht aktualisiert werden.`, + ), + }) + .add(() => this.loadingState.set(null)); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/alert-group/deployment-alert-group.component.spec.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/deployment/alert-group/deployment-alert-group.component.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/alert-group/deployment-alert-group.component.ts similarity index 79% rename from libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.ts rename to libs/spa/feature/deployment/src/lib/components/deployment/alert-group/deployment-alert-group.component.ts index 7fe9143d..e19e2ff4 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-alert-group.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/alert-group/deployment-alert-group.component.ts @@ -4,19 +4,14 @@ import { NzCardComponent } from 'ng-zorro-antd/card'; import { AlertGroup, DeploymentUnit } from '@kordis/shared/model'; -import { DeploymentUnitComponent } from './deployment-unit.component'; +import { DeploymentUnitComponent } from '../unit/deployment-unit.component'; @Component({ selector: 'krd-deployment-alert-group', standalone: true, - imports: [CommonModule, NzCardComponent, DeploymentUnitComponent], + imports: [CommonModule, DeploymentUnitComponent, NzCardComponent], template: ` - +
{{ alertGroup().name }} @for ( @@ -34,12 +29,19 @@ import { DeploymentUnitComponent } from './deployment-unit.component'; .alert-group { display: flex; flex-direction: column; - gap: 5px; + gap: 4px; } .name { font-weight: 500; } + + nz-card { + .ant-card-body { + padding: 4px 8px; + background-color: #fafafa; + } + } `, changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.spec.ts new file mode 100644 index 00000000..6d535c3c --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { DeploymentNotePopupComponent } from './deployment-note-popup.component'; + +describe('DeploymentNotePopupComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.overrideProvider( + GraphqlService, + createMock(), + ).createComponent(DeploymentNotePopupComponent); + }); + + it('should create', () => { + fixture.componentRef.setInput('note', 'some note'); + fixture.detectChanges(); + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should clear note', () => { + fixture.componentRef.setInput('note', 'some note'); + fixture.detectChanges(); + fixture.nativeElement.querySelector('.reset-icon').click(); + fixture.detectChanges(); + expect(fixture.componentInstance.note()).toBe(''); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.ts new file mode 100644 index 00000000..35ae7a7b --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.ts @@ -0,0 +1,82 @@ +import { + ChangeDetectionStrategy, + Component, + input, + model, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { CloseOutline } from '@ant-design/icons-angular/icons'; +import { NzIconDirective, NzIconService } from 'ng-zorro-antd/icon'; +import { NzInputDirective, NzInputGroupComponent } from 'ng-zorro-antd/input'; +import { NzSpinComponent } from 'ng-zorro-antd/spin'; + +@Component({ + selector: 'krd-note-popup', + standalone: true, + imports: [ + FormsModule, + NzInputDirective, + NzSpinComponent, + NzInputGroupComponent, + NzIconDirective, + ], + template: ` + @if (isLoading()) { + + } + + + + + + @if (note()) { + + } + + `, + styles: ` + nz-spin { + position: absolute; + top: 0; + right: 0; + padding: 4px 16px 4px; + } + + .ant-input { + padding: 0; + } + + textarea { + resize: none; + width: 100%; + } + + .reset-icon { + cursor: pointer; + } + + .reset-icon:hover { + color: var(--ant-primary-color); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeploymentNotePopupComponent { + readonly note = model.required(); + readonly isLoading = input(false); + + constructor(iconService: NzIconService) { + iconService.addIcon(CloseOutline); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-search-wrapper.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-search-wrapper.component.spec.ts new file mode 100644 index 00000000..82b1dc46 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-search-wrapper.component.spec.ts @@ -0,0 +1,110 @@ +import { Component, input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; + +import { DeploymentAssignment } from '@kordis/shared/model'; + +import { DeploymentsSearchStateService } from '../../services/deployments-search-state.service'; +import { DeploymentAssignmentsSearchService } from '../../services/deplyoment-assignments-search.service'; +import { DeploymentSearchWrapperComponent } from './deployment-search-wrapper.component'; + +@Component({ + selector: 'krd-search-wrapper-child', + template: ` + + hello + + `, +}) +class MockSearchWrapperComponent { + readonly assignments = input([]); + readonly name = input(''); + readonly alwaysShow = input(false); +} + +describe('DeploymentSearchWrapperComponent', () => { + let fixture: ComponentFixture; + let mockSearchService: DeepMocked; + + beforeEach(() => { + mockSearchService = createMock(); + fixture = TestBed.configureTestingModule({ + imports: [DeploymentSearchWrapperComponent], + declarations: [MockSearchWrapperComponent], + providers: [DeploymentsSearchStateService], + }) + .overrideProvider(DeploymentAssignmentsSearchService, { + useValue: mockSearchService, + }) + + .createComponent(MockSearchWrapperComponent); + }); + + describe('isVisible', () => { + describe('alwaysShow', () => { + beforeEach(() => { + // no match + TestBed.inject(DeploymentsSearchStateService).searchValue.set('lorem'); + fixture.componentRef.setInput('name', 'ipsum'); + }); + + it('should be always visible if true and no match', async () => { + fixture.componentRef.setInput('alwaysShow', true); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('[data-testid="child-content"]'), + ).not.toBeNull(); + }); + + it('should not be visible if `alwaysShow` is false', () => { + fixture.componentRef.setInput('alwaysShow', false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('[data-testid="child-content"]'), + ).toBeNull(); + }); + }); + + it('should show if search value matches name', () => { + fixture.componentRef.setInput('name', 'test'); + TestBed.inject(DeploymentsSearchStateService).searchValue.set('te'); + + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('[data-testid="child-content"]'), + ).not.toBeNull(); + }); + + it('should show if no search value', () => { + TestBed.inject(DeploymentsSearchStateService).searchValue.set(''); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('[data-testid="child-content"]'), + ).not.toBeNull(); + }); + + it('should show if search results found', async () => { + fixture.autoDetectChanges(); + + mockSearchService.search.mockResolvedValueOnce([ + { + __typename: 'DeploymentUnit', + unit: { name: 'test' }, + } as DeploymentAssignment, + ]); + TestBed.inject(DeploymentsSearchStateService).searchValue.set('te'); + + expect( + fixture.nativeElement.querySelector('[data-testid="child-content"]'), + ).not.toBeNull(); + }); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-search-wrapper.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-search-wrapper.component.ts new file mode 100644 index 00000000..eb11355f --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-search-wrapper.component.ts @@ -0,0 +1,93 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + Component, + Signal, + TemplateRef, + booleanAttribute, + computed, + contentChild, + effect, + inject, + input, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { NzIconDirective } from 'ng-zorro-antd/icon'; +import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; +import { of, switchMap } from 'rxjs'; + +import { DeploymentAssignment } from '@kordis/shared/model'; + +import { DeploymentsSearchStateService } from '../../services/deployments-search-state.service'; +import { DeploymentAssignmentsSearchService } from '../../services/deplyoment-assignments-search.service'; +import { DeploymentCardComponent } from './deplyoment-card.component'; + +@Component({ + selector: 'krd-deployment-search-wrapper', + standalone: true, + imports: [ + DeploymentCardComponent, + NzIconDirective, + NzTooltipDirective, + NgTemplateOutlet, + ], + providers: [DeploymentAssignmentsSearchService], + template: ` + @if (isVisible()) { + + } + `, +}) +export class DeploymentSearchWrapperComponent { + readonly assignments = input.required(); + readonly name = input(''); + readonly alwaysShow = input(false, { + transform: booleanAttribute, + }); + + protected readonly templateRef = contentChild(TemplateRef); + + private readonly searchStateService = inject(DeploymentsSearchStateService); + private readonly searchService = inject(DeploymentAssignmentsSearchService); + + private readonly searchResults: Signal = toSignal( + this.searchStateService.searchValueChange$.pipe( + switchMap((searchValue) => + searchValue ? this.searchService.search(searchValue) : of([]), + ), + ), + { + initialValue: [], + }, + ); + + private readonly hasNameMatch = computed(() => + this.name() + .toLowerCase() + .includes(this.searchStateService.searchValue().toLowerCase()), + ); + readonly isVisible = computed( + () => + this.alwaysShow() || + // no search + !this.searchStateService.searchValue() || + // the user searches the rescue station + this.hasNameMatch() || + // or the user searches the assignments + this.searchResults().length, + ); + + readonly filteredAssignments = computed(() => + this.searchStateService.searchValue() && !this.hasNameMatch() + ? this.searchResults() + : this.assignments(), + ); + + constructor() { + effect(() => this.searchService.updateAssignments(this.assignments())); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html deleted file mode 100644 index d4e74e9f..00000000 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.html +++ /dev/null @@ -1,39 +0,0 @@ -@if (isLoading()) { - -} -
- - - - - - - - - -
diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts deleted file mode 100644 index ba1555f0..00000000 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { createMock } from '@golevelup/ts-jest'; - -import { GraphqlService } from '@kordis/spa/core/graphql'; - -import { DeploymentUnitDetailsComponent } from './deployment-unit-details.component'; - -describe('DeploymentUnitDetailsComponent', () => { - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.overrideProvider(GraphqlService, { - useValue: createMock(), - }); - fixture = TestBed.createComponent(DeploymentUnitDetailsComponent); - fixture.componentRef.setInput('unit', { - id: '1', - callSign: 'Alpha', - name: 'Unit Alpha', - status: { status: 1 }, - note: 'Initial note', - }); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(fixture.componentInstance).toBeTruthy(); - }); -}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.ts deleted file mode 100644 index 610a62de..00000000 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit-details.component.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - effect, - inject, - input, - output, - signal, -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; -import { NzInputDirective } from 'ng-zorro-antd/input'; -import { NzRadioModule } from 'ng-zorro-antd/radio'; -import { NzSpinComponent } from 'ng-zorro-antd/spin'; -import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; -import { Subject, debounceTime, delay, switchMap, tap } from 'rxjs'; - -import { Unit } from '@kordis/shared/model'; -import { GraphqlService, gql } from '@kordis/spa/core/graphql'; - -import { STATUS_EXPLANATIONS } from '../../status-explanations'; -import { StatusBadgeComponent } from './status-badge.component'; - -@Component({ - selector: 'krd-deployment-unit-details', - standalone: true, - imports: [ - FormsModule, - NzInputDirective, - NzRadioModule, - NzSpinComponent, - NzTooltipDirective, - StatusBadgeComponent, - ], - templateUrl: './deployment-unit-details.component.html', - styles: ` - nz-spin { - position: absolute; - top: 0; - right: 0; - padding: 5px 16px 4px; - } - - textarea { - resize: none; - } - - .status { - display: flex; - flex-direction: column; - gap: 5px; - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DeploymentUnitDetailsComponent { - readonly unit = input.required(); - readonly unitStatusUpdated = output(); - note = ''; - status?: number; - readonly isLoading = signal(false); - - protected readonly STATUS_EXPLANATIONS = STATUS_EXPLANATIONS; - private readonly noteUpdatedSubject$ = new Subject(); - private readonly gqlService = inject(GraphqlService); - private readonly statusUpdatedSubject$ = new Subject(); - - constructor() { - effect(() => { - this.status = this.unit().status?.status; - }); - effect(() => { - this.note = this.unit().note; - }); - this.watchNoteUpdates(); - } - - updateNote(): void { - this.noteUpdatedSubject$.next(); - } - - updateStatus(): void { - this.isLoading.set(true); - this.gqlService - .mutate$( - gql` - mutation UpdateUnitStatus($unitId: String!, $status: Int!) { - updateUnitStatus(unitId: $unitId, status: $status) { - id - status { - status - source - receivedAt - } - } - } - `, - { - unitId: this.unit().id, - status: this.status, - }, - ) - .subscribe(() => this.unitStatusUpdated.emit()) - .add(() => this.isLoading.set(false)); - } - - private watchNoteUpdates(): void { - this.noteUpdatedSubject$ - .pipe( - debounceTime(300), - tap(() => this.isLoading.set(true)), - switchMap(() => - this.gqlService.mutate$( - gql` - mutation UpdateUnitNote($unitId: String!, $note: String!) { - updateUnitNote(unitId: $unitId, note: $note) { - id - note - } - } - `, - { - unitId: this.unit().id, - note: this.note, - }, - ), - ), - delay(300), // eye candy for loading spinner - takeUntilDestroyed(), - ) - .subscribe(() => this.isLoading.set(false)); - } -} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts deleted file mode 100644 index b0894445..00000000 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Component, input, signal } from '@angular/core'; -import { InfoCircleOutline } from '@ant-design/icons-angular/icons'; -import { NzCardComponent } from 'ng-zorro-antd/card'; -import { NzIconDirective, NzIconService } from 'ng-zorro-antd/icon'; -import { NzPopoverModule } from 'ng-zorro-antd/popover'; -import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; - -import { Unit } from '@kordis/shared/model'; - -import { DeploymentUnitDetailsComponent } from './deployment-unit-details.component'; -import { StatusBadgeComponent } from './status-badge.component'; - -@Component({ - selector: 'krd-deployment-unit', - standalone: true, - imports: [ - DeploymentUnitDetailsComponent, - NzCardComponent, - NzIconDirective, - NzPopoverModule, - NzTooltipDirective, - StatusBadgeComponent, - ], - template: ` - - - - -
-
-
- {{ unit().callSign }} - @if (unit().note) { - - } -
-
- -
-
-
- {{ unit().name }} -
-
-
- `, - styles: ` - .card-body { - display: flex; - flex-direction: column; - } - - .header-row { - display: flex; - justify-content: space-between; - align-items: center; - } - - .name { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 0.9rem; - color: grey; - } - `, -}) -export class DeploymentUnitComponent { - readonly unit = input.required(); - protected showPopover = signal(false); - - constructor(iconService: NzIconService) { - iconService.addIcon(InfoCircleOutline); - } -} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css index 459bd9dd..4692d624 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css @@ -1,33 +1,52 @@ -.container { +:host { + --deployment-card-width: 230px; + display: flex; + flex-direction: column; + gap: 5px; height: 100%; - max-height: 500px; - align-items: center; - nz-divider { - height: 90%; - } + .header-bar { + display: flex; + flex-direction: row; + justify-content: space-between; - .deployment-section { - height: 100%; - } + nz-input-group { + width: 230px; - .logged-out-stations-container { - display: flex; - flex-direction: column; - flex-wrap: wrap; - gap: 5px; + .reset-icon { + cursor: pointer; + } + + .reset-icon:hover { + color: var(--ant-primary-color); + } + } + + div > :not(:last-child) { + margin-right: 5px; + } } - .rescue-stations { + .deployments { display: flex; flex-direction: row; - gap: 5px; + flex-grow: 1; + overflow: auto; - #sub-header { + nz-divider { + height: 90%; + align-self: center; + } + + .deployment-section { display: flex; flex-direction: row; - justify-content: space-between; + + .deployment-card { + width: var(--deployment-card-width); + height: 100%; + } } } } diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html index deb083e3..be7663f8 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.html @@ -1,44 +1,62 @@ -
-
- + + + + + + +
+ +
- -
- @for (station of signedInRescueStations$ | async; track station.id) { - -
- {{ station.strength.leaders }}/{{ station.strength.subLeaders }}/{{ - station.strength.helpers - }}//{{ - station.strength.leaders + - station.strength.subLeaders + - station.strength.helpers - }} - @if (station.note) { - - } +
+ +
+
+ + +
+
- - } +
+
+
+ +
+
-
- @for (station of signedOffRescueStations$ | async; track station.id) { - {{ station.name }} - - } +
+
diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.spec.ts index 965dcc33..47c523e7 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.spec.ts @@ -12,24 +12,23 @@ describe('DeploymentsComponent', () => { let fixture: ComponentFixture; beforeEach(async () => { - TestBed.overrideProvider(GraphqlService, { + fixture = TestBed.overrideProvider(GraphqlService, { useValue: createMock({ query: jest.fn().mockReturnValue({ $: of(), }), }), - }); - TestBed.overrideProvider(NzModalService, { - useValue: createMock(), - }); - TestBed.overrideComponent(DeploymentCardComponent, { - set: { - selector: 'krd-deployment-card', - template: '
', - }, - }); - - fixture = TestBed.createComponent(DeploymentsComponent); + }) + .overrideProvider(NzModalService, { + useValue: createMock(), + }) + .overrideComponent(DeploymentCardComponent, { + set: { + selector: 'krd-deployment-card', + template: '
', + }, + }) + .createComponent(DeploymentsComponent); fixture.detectChanges(); }); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts index 9d6747e5..c857b8cd 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.ts @@ -1,67 +1,40 @@ -import { AsyncPipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { InfoCircleOutline } from '@ant-design/icons-angular/icons'; -import { NzCardComponent } from 'ng-zorro-antd/card'; +import { + ChangeDetectionStrategy, + Component, + Signal, + inject, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { + CloseOutline, + EditOutline, + InfoCircleOutline, + UndoOutline, +} from '@ant-design/icons-angular/icons'; +import { NzButtonComponent } from 'ng-zorro-antd/button'; import { NzDividerComponent } from 'ng-zorro-antd/divider'; import { NzIconDirective, NzIconService } from 'ng-zorro-antd/icon'; +import { NzInputDirective, NzInputGroupComponent } from 'ng-zorro-antd/input'; import { NzModalService } from 'ng-zorro-antd/modal'; -import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; -import { Observable, map, merge, shareReplay } from 'rxjs'; +import { NzNotificationService } from 'ng-zorro-antd/notification'; +import { NzPopconfirmDirective } from 'ng-zorro-antd/popconfirm'; +import { merge } from 'rxjs'; -import { - DeploymentAssignment, - Query, - RescueStationDeployment, -} from '@kordis/shared/model'; +import { Query } from '@kordis/shared/model'; import { GraphqlService, gql } from '@kordis/spa/core/graphql'; -import { RescueStationEditModalComponent } from '../rescue-station-edit-modal/rescue-station-edit-modal.component'; +import { DeploymentsSearchStateService } from '../../services/deployments-search-state.service'; +import { AlertGroupEditModalComponent } from '../alert-group-edit-modal/alert-group-edit-modal.component'; +import { DeploymentSearchWrapperComponent } from './deployment-search-wrapper.component'; import { DeploymentCardComponent } from './deplyoment-card.component'; +import { RescueStationDeploymentCardHeaderComponent } from './rescue-station/rescue-station-deployment-card-header.component'; +import { RESCUE_STATION_FRAGMENT } from './rescue-station/rescue-station.fragment'; +import { SignedInRescueStationsComponent } from './signed-in-rescue-stations.component'; +import { SignedOffDeploymentsComponent } from './signed-off-deployments.component'; const DEPLOYMENTS_QUERY = gql` - fragment UnitData on Unit { - id - callSign - name - note - status { - status - receivedAt - } - } - fragment RescueStationData on RescueStationDeployment { - id - name - note - signedIn - defaultUnits { - ...UnitData - } - strength { - helpers - subLeaders - leaders - } - assignments { - ... on DeploymentUnit { - unit { - ...UnitData - } - } - ... on DeploymentAlertGroup { - assignedUnits { - unit { - ...UnitData - } - } - alertGroup { - id - name - } - } - } - } + ${RESCUE_STATION_FRAGMENT} query { signedInStations: rescueStationDeployments(signedIn: true) { ...RescueStationData @@ -77,6 +50,7 @@ const DEPLOYMENTS_QUERY = gql` } ... on DeploymentAlertGroup { alertGroup { + id name } assignedUnits { @@ -93,26 +67,43 @@ const DEPLOYMENTS_QUERY = gql` selector: 'krd-deployments', standalone: true, imports: [ - AsyncPipe, - DeploymentCardComponent, - NzCardComponent, + FormsModule, + NzButtonComponent, NzDividerComponent, NzIconDirective, - NzTooltipDirective, + NzInputDirective, + NzInputGroupComponent, + NzPopconfirmDirective, + RescueStationDeploymentCardHeaderComponent, + SignedInRescueStationsComponent, + SignedOffDeploymentsComponent, + DeploymentSearchWrapperComponent, + DeploymentCardComponent, ], + providers: [DeploymentsSearchStateService], templateUrl: './deployments.component.html', styleUrl: './deployments.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class DeploymentsComponent { - readonly signedInRescueStations$: Observable; - readonly signedOffRescueStations$: Observable; - readonly unassigned$: Observable; + readonly deployments: Signal<{ + signedInStations: Query['rescueStationDeployments']; + signedOffStations: Query['rescueStationDeployments']; + unassignedEntities: Query['unassignedEntities']; + }>; + readonly searchStateService = inject(DeploymentsSearchStateService); + private readonly gqlService = inject(GraphqlService); private readonly modalService = inject(NzModalService); + private readonly notificationService = inject(NzNotificationService); constructor(iconService: NzIconService) { - iconService.addIcon(InfoCircleOutline); + iconService.addIcon( + InfoCircleOutline, + UndoOutline, + EditOutline, + CloseOutline, + ); const deploymentsQuery = this.gqlService.query<{ signedInStations: Query['rescueStationDeployments']; @@ -120,10 +111,13 @@ export class DeploymentsComponent { unassignedEntities: Query['unassignedEntities']; }>(DEPLOYMENTS_QUERY); - const deployments$ = deploymentsQuery.$.pipe( - takeUntilDestroyed(), - shareReplay({ bufferSize: 1, refCount: true }), - ); + this.deployments = toSignal(deploymentsQuery.$.pipe(takeUntilDestroyed()), { + initialValue: { + signedInStations: [], + signedOffStations: [], + unassignedEntities: [], + }, + }); // right now we greedily get all deployments, as a change in a deployment or a unit can result in changes in multiple deployment // a better way would be to have more fine events for all actions taken that could replay the action in the frontend @@ -153,24 +147,54 @@ export class DeploymentsComponent { .pipe(takeUntilDestroyed()) .subscribe(() => deploymentsQuery.refresh()); - this.signedInRescueStations$ = deployments$.pipe( - map(({ signedInStations }) => signedInStations), - ); - this.signedOffRescueStations$ = deployments$.pipe( - map(({ signedOffStations }) => signedOffStations), - ); - this.unassigned$ = deployments$.pipe( - map(({ unassignedEntities }) => unassignedEntities), - ); + // subscribe to note updates, will update the cache + this.gqlService + .subscribe$(gql` + subscription { + rescueStationNoteUpdated { + id + note + } + } + `) + .pipe(takeUntilDestroyed()) + .subscribe(); } - openRescueStationEditModal(station: RescueStationDeployment): void { + openAlertGroupsEditModal(): void { this.modalService.create({ - nzContent: RescueStationEditModalComponent, - nzData: station, + nzContent: AlertGroupEditModalComponent, nzFooter: null, - nzClosable: false, + nzClosable: true, nzNoAnimation: true, }); } + + resetDeployments(): void { + this.gqlService + .mutate$(gql` + mutation { + resetRescueStations { + id + signedIn + } + resetCurrentAlertGroupUnits { + id + currentUnits { + id + } + } + resetUnitNotes { + id + note + } + } + `) + .subscribe(() => + this.notificationService.success( + 'Zurückgesetzt', + 'Zurücksetzen der Einheiten, Alarmgruppen und Rettungswachen erfolgreich', + ), + ); + } } diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.spec.ts index 6d4fd4b6..059676bc 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.spec.ts @@ -1,6 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; import { AlertGroup, DeploymentAssignment, Unit } from '@kordis/shared/model'; +import { GraphqlService } from '@kordis/spa/core/graphql'; import { DeploymentCardComponent } from './deplyoment-card.component'; @@ -10,7 +12,10 @@ describe('DeploymentCardComponent', () => { let fixture: ComponentFixture; beforeEach(() => { - fixture = TestBed.createComponent(DeploymentCardComponent); + fixture = TestBed.overrideProvider( + GraphqlService, + createMock(), + ).createComponent(DeploymentCardComponent); }); it('should create', () => { diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts index 849f70c7..3ab82a34 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts @@ -6,11 +6,13 @@ import { output, } from '@angular/core'; import { NzCardComponent } from 'ng-zorro-antd/card'; +import { NzEmptyComponent, NzEmptySimpleComponent } from 'ng-zorro-antd/empty'; import { DeploymentAssignment } from '@kordis/shared/model'; -import { DeploymentAlertGroupComponent } from './deployment-alert-group.component'; -import { DeploymentUnitComponent } from './deployment-unit.component'; +import { DeploymentAlertGroupComponent } from './alert-group/deployment-alert-group.component'; +import { DeploymentNotePopupComponent } from './deployment-note-popup.component'; +import { DeploymentUnitComponent } from './unit/deployment-unit.component'; // status - rank mapping const STATUS_SORT_ORDER: Readonly> = Object.freeze({ @@ -27,15 +29,18 @@ const STATUS_SORT_ORDER: Readonly> = Object.freeze({ standalone: true, imports: [ DeploymentAlertGroupComponent, + DeploymentNotePopupComponent, DeploymentUnitComponent, NzCardComponent, + NzEmptyComponent, + NzEmptySimpleComponent, ], template: ` - +

{{ name() }}

- +
@@ -47,28 +52,36 @@ const STATUS_SORT_ORDER: Readonly> = Object.freeze({ } @else if (assignment.__typename === 'DeploymentAlertGroup') { } } @empty { - Keine Zuordnungen + }
`, styles: ` nz-card { + display: flex; + flex-direction: column; + height: 100%; + h3 { margin-bottom: 0; } - height: 100%; - width: 250px; - overflow: auto; - .ant-card-body { padding: 18px; + display: flex; + flex-direction: column; + flex-grow: 1; + height: 100%; } .sub-header { @@ -78,9 +91,22 @@ const STATUS_SORT_ORDER: Readonly> = Object.freeze({ .assignments { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; + width: 100%; + overflow-y: auto; + padding-right: 4px; + box-sizing: content-box; } } + + .clickable:hover:not(:has(.assignments:hover)) { + border-color: var(--ant-primary-color); + cursor: pointer; + } + + krd-deployment-alert-group { + cursor: auto; + } `, changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/name-search-wrapper.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/name-search-wrapper.component.spec.ts new file mode 100644 index 00000000..e0b62647 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/name-search-wrapper.component.spec.ts @@ -0,0 +1,59 @@ +import { Component, input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DeploymentsSearchStateService } from '../../services/deployments-search-state.service'; +import { NameSearchWrapperComponent } from './name-search-wrapper.component'; + +@Component({ + selector: 'krd-name-search-wrapper-child', + template: ` + + hello + + `, +}) +class MockNameSearchWrapperComponent { + readonly name = input(''); +} + +describe('NameSearchWrapperComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.configureTestingModule({ + imports: [NameSearchWrapperComponent], + declarations: [MockNameSearchWrapperComponent], + providers: [DeploymentsSearchStateService], + }).createComponent(MockNameSearchWrapperComponent); + }); + + it('should be visible if no search value set', () => { + fixture.componentRef.setInput('name', 'lorem'); + TestBed.inject(DeploymentsSearchStateService).searchValue.set(''); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('[data-testid="child-content"]'), + ).not.toBeNull(); + }); + + it('should be visible on name match', () => { + fixture.componentRef.setInput('name', 'lorem'); + TestBed.inject(DeploymentsSearchStateService).searchValue.set('lor'); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('[data-testid="child-content"]'), + ).not.toBeNull(); + }); + + it('should not be visible on name mismatch', () => { + fixture.componentRef.setInput('name', 'ipsum'); + TestBed.inject(DeploymentsSearchStateService).searchValue.set('lorem'); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('[data-testid="child-content"]'), + ).toBeNull(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/name-search-wrapper.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/name-search-wrapper.component.ts new file mode 100644 index 00000000..db177544 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/name-search-wrapper.component.ts @@ -0,0 +1,26 @@ +import { Component, computed, inject, input } from '@angular/core'; + +import { DeploymentsSearchStateService } from '../../services/deployments-search-state.service'; + +@Component({ + standalone: true, + selector: 'krd-name-search-wrapper', + template: ` + @if (isVisible()) { + + } + `, +}) +export class NameSearchWrapperComponent { + readonly name = input.required(); + private readonly searchStateService = inject(DeploymentsSearchStateService); + readonly isVisible = computed( + () => + // no search + !this.searchStateService.searchValue() || + // the user searches the name + this.name() + .toLowerCase() + .includes(this.searchStateService.searchValue().toLowerCase()), + ); +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/rescue-station/rescue-station-deployment-card-header.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/rescue-station/rescue-station-deployment-card-header.component.spec.ts new file mode 100644 index 00000000..ffe3f9b6 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/rescue-station/rescue-station-deployment-card-header.component.spec.ts @@ -0,0 +1,43 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RescueStationDeploymentCardHeaderComponent } from './rescue-station-deployment-card-header.component'; + +describe('RescueStationDeploymentCardHeaderComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent( + RescueStationDeploymentCardHeaderComponent, + ); + }); + + it('should show note icon only if note is set', () => { + fixture.componentRef.setInput('rescueStation', { + strength: { leaders: 0, subLeaders: 0, helpers: 0 }, + note: '', + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('i')).toBeNull(); + + fixture.componentRef.setInput('rescueStation', { + strength: { leaders: 0, subLeaders: 0, helpers: 0 }, + note: 'some note', + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('i')).toBeTruthy(); + }); + + it('should show correct strength', () => { + fixture.componentRef.setInput('rescueStation', { + strength: { leaders: 1, subLeaders: 2, helpers: 3 }, + note: '', + }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('span').textContent).toEqual( + '1/2/3//6', + ); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/rescue-station/rescue-station-deployment-card-header.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/rescue-station/rescue-station-deployment-card-header.component.ts new file mode 100644 index 00000000..6d9a9766 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/rescue-station/rescue-station-deployment-card-header.component.ts @@ -0,0 +1,39 @@ +import { Component, computed, input } from '@angular/core'; +import { NzIconDirective } from 'ng-zorro-antd/icon'; +import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; + +import { RescueStationDeployment } from '@kordis/shared/model'; + +import { DeploymentCardComponent } from '../deplyoment-card.component'; + +@Component({ + selector: 'krd-rescue-station-deployment-card-header', + standalone: true, + imports: [DeploymentCardComponent, NzTooltipDirective, NzIconDirective], + template: ` + {{ rescueStation().strength.leaders }}/{{ + rescueStation().strength.subLeaders + }}/{{ rescueStation().strength.helpers }}//{{ totalStrength() }} + @if (rescueStation().note) { + + } + `, + styles: ` + :host { + display: flex; + flex-direction: row; + justify-content: space-between; + } + `, +}) +export class RescueStationDeploymentCardHeaderComponent { + readonly rescueStation = input.required(); + protected readonly totalStrength = computed( + () => + this.rescueStation().strength.leaders + + this.rescueStation().strength.subLeaders + + this.rescueStation().strength.helpers, + ); +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/rescue-station/rescue-station.fragment.ts b/libs/spa/feature/deployment/src/lib/components/deployment/rescue-station/rescue-station.fragment.ts new file mode 100644 index 00000000..f83bccf3 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/rescue-station/rescue-station.fragment.ts @@ -0,0 +1,51 @@ +import { gql } from '@kordis/spa/core/graphql'; + +export const UNIT_FRAGMENT = gql` + fragment UnitData on Unit { + id + callSign + callSignAbbreviation + name + note + status { + status + receivedAt + } + } +`; + +export const RESCUE_STATION_FRAGMENT = gql` + ${UNIT_FRAGMENT} + fragment RescueStationData on RescueStationDeployment { + id + name + note + signedIn + defaultUnits { + ...UnitData + } + strength { + helpers + subLeaders + leaders + } + assignments { + ... on DeploymentUnit { + unit { + ...UnitData + } + } + ... on DeploymentAlertGroup { + assignedUnits { + unit { + ...UnitData + } + } + alertGroup { + id + name + } + } + } + } +`; diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.spec.ts new file mode 100644 index 00000000..02e225d8 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.spec.ts @@ -0,0 +1,35 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { of } from 'rxjs'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { DeploymentsSearchStateService } from '../../services/deployments-search-state.service'; +import { SignedInRescueStationsComponent } from './signed-in-rescue-stations.component'; + +describe('SignedInRescueStationsComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = TestBed.overrideProvider(GraphqlService, { + useValue: createMock({ + query: jest.fn().mockReturnValue({ + $: of(), + }), + }), + }) + .overrideProvider(DeploymentsSearchStateService, { + useValue: createMock(), + }) + .createComponent(SignedInRescueStationsComponent); + }); + + it('should show no search results if no rescue stations provided', () => { + fixture.componentRef.setInput('rescueStations', []); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.empty-state').textContent, + ).toContain('Keine angemeldeten Rettungswachen gefunden'); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.ts new file mode 100644 index 00000000..6459a331 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.ts @@ -0,0 +1,150 @@ +import { + Component, + computed, + inject, + input, + signal, + viewChildren, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NzCardComponent } from 'ng-zorro-antd/card'; +import { NzPopoverDirective } from 'ng-zorro-antd/popover'; +import { Subject, debounceTime, delay, switchMap, tap } from 'rxjs'; + +import { RescueStationDeployment } from '@kordis/shared/model'; +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +import { DeploymentsSearchStateService } from '../../services/deployments-search-state.service'; +import { DeploymentNotePopupComponent } from './deployment-note-popup.component'; +import { DeploymentSearchWrapperComponent } from './deployment-search-wrapper.component'; +import { DeploymentCardComponent } from './deplyoment-card.component'; +import { RescueStationDeploymentCardHeaderComponent } from './rescue-station/rescue-station-deployment-card-header.component'; + +@Component({ + standalone: true, + selector: 'krd-signed-in-deployments', + template: ` + @if (showNoSearchResults()) { +
+

Keine angemeldeten Rettungswachen gefunden

+
+ } + + + @for (station of rescueStations(); track station.id) { + + +
+ + + + + + +
+
+
+ } @empty { + @if (!showNoSearchResults()) { +
+

Keine angemeldeten Rettungswachen

+
+ } + } + `, + styles: ` + :host { + display: flex; + flex-direction: row; + gap: 5px; + height: 100%; + } + + .deployment-card { + width: var(--deployment-card-width, 230px); + height: 100%; + } + + .empty-state { + align-self: center; + width: 250px; + } + `, + imports: [ + DeploymentCardComponent, + DeploymentNotePopupComponent, + DeploymentSearchWrapperComponent, + NzCardComponent, + NzPopoverDirective, + RescueStationDeploymentCardHeaderComponent, + ], +}) +export class SignedInRescueStationsComponent { + readonly rescueStations = input.required(); + + readonly isNoteUpdating = signal(false); + + private readonly searchWrappers = viewChildren( + DeploymentSearchWrapperComponent, + ); + private readonly searchStateService = inject(DeploymentsSearchStateService); + readonly showNoSearchResults = computed( + () => + // if no cards are visible and search value is not empty, this is a bit weird but right now the best I have got + // the alternative would be to filter everything from the parent, which, imo, would result in a total mess + !this.searchWrappers().some((wrapper) => wrapper.isVisible()) && + this.searchStateService.searchValue(), + ); + private noteUpdatedSubject$ = new Subject<{ id: string; note: string }>(); + private gqlService = inject(GraphqlService); + + constructor() { + this.noteUpdatedSubject$ + .pipe( + debounceTime(300), + tap(() => this.isNoteUpdating.set(true)), + switchMap(({ note, id }) => + this.gqlService.mutate$( + gql` + mutation UpdateRescueStationNote($id: ID!, $note: String!) { + updateRescueStationNote(id: $id, note: $note) { + id + note + } + } + `, + { + id, + note, + }, + ), + ), + delay(300), // eye candy for loading spinner + takeUntilDestroyed(), + ) + .subscribe(() => this.isNoteUpdating.set(false)); + } + + updateNote(id: string, note: string): void { + this.noteUpdatedSubject$.next({ id, note }); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.spec.ts new file mode 100644 index 00000000..2ba796e4 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.spec.ts @@ -0,0 +1,29 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { NzModalService } from 'ng-zorro-antd/modal'; + +import { DeploymentsSearchStateService } from '../../services/deployments-search-state.service'; +import { SignedOffDeploymentsComponent } from './signed-off-deployments.component'; + +describe('SignedOffDeploymentsComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = TestBed.overrideProvider(DeploymentsSearchStateService, { + useValue: createMock(), + }) + .overrideProvider(NzModalService, { + useValue: createMock(), + }) + .createComponent(SignedOffDeploymentsComponent); + }); + + it('should show no search results if no rescue stations provided', () => { + fixture.componentRef.setInput('rescueStations', []); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.empty-state').textContent, + ).toContain('Keine abgemeldeten Rettungswachen gefunden'); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.ts new file mode 100644 index 00000000..7e83ac23 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.ts @@ -0,0 +1,89 @@ +import { + Component, + computed, + inject, + input, + viewChildren, +} from '@angular/core'; +import { NzCardComponent } from 'ng-zorro-antd/card'; +import { NzModalService } from 'ng-zorro-antd/modal'; + +import { RescueStationDeployment } from '@kordis/shared/model'; + +import { DeploymentsSearchStateService } from '../../services/deployments-search-state.service'; +import { RescueStationEditModalComponent } from '../rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component'; +import { NameSearchWrapperComponent } from './name-search-wrapper.component'; + +@Component({ + standalone: true, + selector: 'krd-signed-off-deployments', + template: ` + @if (showNoSearchResults()) { +
+

Keine abgemeldeten Rettungswachen gefunden

+
+ } + + + @for (station of rescueStations(); track station.id) { + + {{ station.name }} + + + } @empty { + @if (!showNoSearchResults()) { +
+

Keine abgemeldeten Rettungswachen

+
+ } + } + `, + styles: ` + :host { + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 5px; + height: 100%; + + .empty-state { + margin: auto; + width: 250px; + } + + nz-card { + width: var(--deployment-card-width, 230px); + } + + nz-card:hover { + border-color: var(--ant-primary-color); + cursor: pointer; + } + } + `, + imports: [NameSearchWrapperComponent, NzCardComponent], +}) +export class SignedOffDeploymentsComponent { + readonly rescueStations = input.required(); + + private readonly searchWrappers = viewChildren(NameSearchWrapperComponent); + private readonly searchStateService = inject(DeploymentsSearchStateService); + readonly showNoSearchResults = computed( + () => + !this.searchWrappers().some((wrapper) => wrapper.isVisible()) && + this.searchStateService.searchValue(), + ); + + private readonly modalService = inject(NzModalService); + + openRescueStationEditModal(station: RescueStationDeployment): void { + this.modalService.create({ + nzContent: RescueStationEditModalComponent, + nzData: station, + nzFooter: null, + nzClosable: false, + nzNoAnimation: true, + }); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.spec.ts similarity index 73% rename from libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.spec.ts index a82936e7..018ea8c9 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-unit.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.spec.ts @@ -1,4 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; import { DeploymentUnitComponent } from './deployment-unit.component'; @@ -6,18 +9,10 @@ describe('DeploymentUnitComponent', () => { let fixture: ComponentFixture; beforeEach(() => { - fixture = TestBed.createComponent(DeploymentUnitComponent); - }); - - it('should be defined', () => { - fixture.componentRef.setInput('unit', { - id: '1', - callSign: 'Alpha', - name: 'Unit Alpha', - status: { status: 1 }, + TestBed.overrideProvider(GraphqlService, { + useValue: createMock(), }); - fixture.detectChanges(); - expect(fixture.componentInstance).toBeDefined(); + fixture = TestBed.createComponent(DeploymentUnitComponent); }); it('should show a note if the unit has a note', () => { diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.ts new file mode 100644 index 00000000..70a79031 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.ts @@ -0,0 +1,127 @@ +import { Component, inject, input, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { InfoCircleOutline } from '@ant-design/icons-angular/icons'; +import { NzCardComponent } from 'ng-zorro-antd/card'; +import { NzIconDirective, NzIconService } from 'ng-zorro-antd/icon'; +import { NzPopoverModule } from 'ng-zorro-antd/popover'; +import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; +import { Subject, debounceTime, delay, switchMap, tap } from 'rxjs'; + +import { Unit } from '@kordis/shared/model'; +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +import { DeploymentNotePopupComponent } from '../deployment-note-popup.component'; +import { StatusBadgeComponent } from './status-badge.component'; + +@Component({ + selector: 'krd-deployment-unit', + standalone: true, + imports: [ + DeploymentNotePopupComponent, + NzCardComponent, + NzIconDirective, + NzPopoverModule, + NzTooltipDirective, + StatusBadgeComponent, + ], + template: ` + + + + +
+
+ + {{ unit().callSign }} + @if (unit().note) { + + } + + +
+
+ {{ unit().name }} +
+
+
+ `, + styles: ` + nz-card { + .card-body { + display: flex; + flex-direction: column; + } + + .header-row { + display: flex; + justify-content: space-between; + align-items: center; + } + + .name { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9rem; + color: grey; + } + } + + nz-card:hover { + border-color: var(--ant-primary-color); + cursor: pointer; + } + `, +}) +export class DeploymentUnitComponent { + readonly unit = input.required(); + protected isNoteUpdating = signal(false); + private noteUpdatedSubject$ = new Subject(); + private gqlService = inject(GraphqlService); + + constructor(iconService: NzIconService) { + iconService.addIcon(InfoCircleOutline); + + // a deployment unit is updatable in every context, so we can safely implement it here (not like deployment card, which not always has a note) + this.noteUpdatedSubject$ + .pipe( + debounceTime(300), + tap(() => this.isNoteUpdating.set(true)), + switchMap((note) => + this.gqlService.mutate$( + gql` + mutation UpdateUnitNote($unitId: ID!, $note: String!) { + updateUnitNote(unitId: $unitId, note: $note) { + id + note + } + } + `, + { + unitId: this.unit().id, + note, + }, + ), + ), + delay(300), // eye candy for loading spinner + takeUntilDestroyed(), + ) + .subscribe(() => this.isNoteUpdating.set(false)); + } + + updateNote(note: string): void { + this.noteUpdatedSubject$.next(note); + } +} diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/deployment/unit/status-badge.component.spec.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/deployment/unit/status-badge.component.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/unit/status-badge.component.ts similarity index 95% rename from libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.ts rename to libs/spa/feature/deployment/src/lib/components/deployment/unit/status-badge.component.ts index 606a2f4e..f2616ff5 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/status-badge.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/unit/status-badge.component.ts @@ -7,7 +7,7 @@ import { } from '@angular/core'; import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; -import { STATUS_EXPLANATIONS } from '../../status-explanations'; +import { STATUS_EXPLANATIONS } from '../../../status-explanations'; @Component({ selector: 'krd-status-badge', diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/alert-group-selection.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/alert-group-selection.service.ts deleted file mode 100644 index 38e473f9..00000000 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/alert-group-selection.service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { TypedDocumentNode } from 'apollo-angular'; - -import { AlertGroup, Query } from '@kordis/shared/model'; -import { gql } from '@kordis/spa/core/graphql'; - -import { AlertGroupSearchService } from '../../../services/alert-group-search.service'; -import { EntitySearchService } from '../../../services/entity-search.service'; -import { PossibleEntitySelectionsService } from './possible-entity-selection.service'; - -@Injectable() -export class PossibleAlertGroupSelectionsService extends PossibleEntitySelectionsService< - AlertGroup, - { alertGroups: Query['alertGroups'] } -> { - protected override query: TypedDocumentNode<{ - alertGroups: Query['alertGroups']; - }> = gql` - query { - alertGroups { - id - name - units { - id - name - callSign - status { - status - } - assignment { - __typename - id - name - } - } - assignment { - __typename - id - name - } - } - } - `; - protected queryName = 'alertGroups' as const; - - protected searchService: EntitySearchService = inject( - AlertGroupSearchService, - ); -} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/unit-selection.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/unit-selection.service.ts deleted file mode 100644 index 8c077f3a..00000000 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/unit-selection.service.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { TypedDocumentNode } from 'apollo-angular'; - -import { Query, Unit } from '@kordis/shared/model'; -import { gql } from '@kordis/spa/core/graphql'; - -import { EntitySearchService } from '../../../services/entity-search.service'; -import { UnitSearchService } from '../../../services/unit-search.service'; -import { PossibleEntitySelectionsService } from './possible-entity-selection.service'; - -/* - This service handles the selection of units in a context where a unit can only be selected once. - */ -@Injectable() -export class PossibleUnitSelectionsService extends PossibleEntitySelectionsService< - Unit, - { units: Query['units'] } -> { - protected query: TypedDocumentNode<{ units: Query['units'] }> = gql` - query { - units { - id - callSign - callSignAbbreviation - name - status { - status - receivedAt - source - } - assignment { - __typename - id - name - } - } - } - `; - protected queryName = 'units' as const; - protected searchService: EntitySearchService = - inject(UnitSearchService); -} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-assignment.model.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-assignment.model.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-assignment.model.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-assignment.model.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.spec.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts similarity index 94% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts index 30ab08d2..620c5df5 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-autocomplete.component.ts @@ -1,5 +1,5 @@ import { AsyncPipe } from '@angular/common'; -import { Component, Output, inject, input } from '@angular/core'; +import { Component, Output, inject } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { NzAutocompleteModule, @@ -31,7 +31,6 @@ import { PossibleAlertGroupSelectionsService } from '../../service/alert-group-s nz-input (focus)="onSearchInputFocus()" [nzAutocomplete]="auto" - [nzStatus]="showErrorState() ? 'error' : ''" /> (false); - readonly searchInput = new FormControl(''); readonly possibleAlertGroupSelectionService = inject( PossibleAlertGroupSelectionsService, diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.spec.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts similarity index 66% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts index fedf8477..2d0b98c8 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selection.component.ts @@ -5,12 +5,18 @@ import { output, viewChild, } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { FormControl, FormGroup } from '@angular/forms'; import { NzCardComponent } from 'ng-zorro-antd/card'; +import { + NzFormControlComponent, + NzFormDirective, + NzFormItemComponent, +} from 'ng-zorro-antd/form'; import { NzIconDirective } from 'ng-zorro-antd/icon'; import { AlertGroup, Unit } from '@kordis/shared/model'; +import { UnitSelectionOptionComponent } from '../unit/unit-selection-option.component'; import { UnitsSelectComponent } from '../unit/units-select.component'; @Component({ @@ -18,9 +24,12 @@ import { UnitsSelectComponent } from '../unit/units-select.component'; standalone: true, imports: [ NzCardComponent, + NzFormControlComponent, + NzFormItemComponent, NzIconDirective, - ReactiveFormsModule, + UnitSelectionOptionComponent, UnitsSelectComponent, + NzFormDirective, ], template: `
@@ -28,7 +37,15 @@ import { UnitsSelectComponent } from '../unit/units-select.component';
- + + + + + + + + + `, styles: ` .alert-group-header { diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts similarity index 87% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts index a18141b7..292500f6 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.spec.ts @@ -6,7 +6,6 @@ import { PossibleAlertGroupSelectionsService } from '../../service/alert-group-s import { AlertGroupSelectionsComponent } from './alert-group-selections.component'; describe('AlertGroupSelectionsComponent', () => { - let component: AlertGroupSelectionsComponent; let fixture: ComponentFixture; beforeEach(async () => { @@ -18,8 +17,7 @@ describe('AlertGroupSelectionsComponent', () => { it('should create', () => { fixture.componentRef.setInput('formArray', new FormArray([])); - component = fixture.componentInstance; fixture.detectChanges(); - expect(component).toBeTruthy(); + expect(fixture.componentInstance).toBeTruthy(); }); }); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts similarity index 78% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts index 173991c6..ef376edc 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + effect, inject, input, viewChildren, @@ -13,6 +14,10 @@ import { NonNullableFormBuilder, } from '@angular/forms'; import { NzCardComponent } from 'ng-zorro-antd/card'; +import { + NzFormControlComponent, + NzFormItemComponent, +} from 'ng-zorro-antd/form'; import { AlertGroup, Unit } from '@kordis/shared/model'; @@ -28,12 +33,17 @@ import { AlertGroupSelectionComponent } from './alert-group-selection.component' AlertGroupAutocompleteComponent, AlertGroupSelectionComponent, NzCardComponent, + NzFormControlComponent, + NzFormItemComponent, ], template: ` - + + + + + @if (formArray().length) {
@@ -75,6 +85,10 @@ import { AlertGroupSelectionComponent } from './alert-group-selection.component' margin-top: 5px; } + + nz-form-item { + margin-bottom: 0; + } `, changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -102,28 +116,40 @@ export class AlertGroupSelectionsComponent { private readonly fb = inject(NonNullableFormBuilder); private readonly cd = inject(ChangeDetectorRef); + constructor() { + effect(() => { + // initially mark alert groups as selected + this.formArray() + .getRawValue() + .forEach((value) => + this.possibleAlertGroupSelectionsService.markAsSelected( + value.alertGroup, + ), + ); + }); + } + removeAlertGroup(index: number): void { - const formValue = this.formArray().at(index).value; + const formValue = this.formArray().at(index).getRawValue(); // unmark assigned units of the alert group and the alert group itself - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - for (const unit of formValue.assignedUnits!) { + for (const unit of formValue.assignedUnits) { this.possibleUnitSelectionsService?.unmarkAsSelected(unit); } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.possibleAlertGroupSelectionsService.unmarkAsSelected( - formValue.alertGroup!, + formValue.alertGroup, ); + this.formArray().removeAt(index); } addAlertGroup(alertGroup: AlertGroup): void { this.possibleAlertGroupSelectionsService.markAsSelected(alertGroup); - // newly assigned alert groups should have default units initially + // newly assigned alert groups should have current units initially const alertGroupAssignment = this.fb.group({ alertGroup: this.fb.control(alertGroup), assignedUnits: this.fb.control( - alertGroup.units.filter( + alertGroup.currentUnits.filter( (unit) => !(this.possibleUnitSelectionsService?.isSelected(unit) ?? false), ), diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/protocol-data.component.spec.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/protocol-data.component.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/protocol-data.component.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/protocol-data.component.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/protocol-data.component.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.spec.ts similarity index 98% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.spec.ts index 233bd4a1..5c00be13 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.spec.ts @@ -23,6 +23,6 @@ describe('StrengthComponent', () => { await fixture.autoDetectChanges(); await fixture.whenStable(); - expect(fixture.nativeElement.textContent).toMatch(new RegExp(`6 $`)); + expect(fixture.nativeElement.textContent).toMatch(new RegExp(`6$`)); }); }); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.ts similarity index 60% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.ts index a18fe48c..22ab1fbd 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/strength.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.ts @@ -7,6 +7,11 @@ import { input, } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { + NzFormControlComponent, + NzFormDirective, + NzFormItemComponent, +} from 'ng-zorro-antd/form'; import { NzInputNumberComponent } from 'ng-zorro-antd/input-number'; import { NzTooltipDirective } from 'ng-zorro-antd/tooltip'; import { Subject, map, of, startWith, takeUntil } from 'rxjs'; @@ -15,46 +20,40 @@ import { Subject, map, of, startWith, takeUntil } from 'rxjs'; selector: 'krd-strength', standalone: true, template: ` -
- - / - - / - - // {{ total$ | async }} +
+ + + + + + / + + + + + + / + + + + + + // {{ total$ | async }}
`, imports: [ @@ -62,6 +61,9 @@ import { Subject, map, of, startWith, takeUntil } from 'rxjs'; NzInputNumberComponent, NzTooltipDirective, ReactiveFormsModule, + NzFormItemComponent, + NzFormControlComponent, + NzFormDirective, ], styles: ` div { @@ -70,6 +72,10 @@ import { Subject, map, of, startWith, takeUntil } from 'rxjs'; align-items: center; gap: 5px; } + + .ant-form-item { + margin: 0; + } `, changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/unit-selection-option.component.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts similarity index 93% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts index 1fd3547d..e61d1d49 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { Unit } from '@kordis/shared/model'; -import { StatusBadgeComponent } from '../../../deployment/status-badge.component'; +import { StatusBadgeComponent } from '../../../../deployment/unit/status-badge.component'; @Component({ selector: 'krd-unit-selection-option', diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/units-select.component.spec.ts similarity index 87% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/units-select.component.spec.ts index 69738e2a..604f9267 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/units-select.component.spec.ts @@ -5,7 +5,6 @@ import { createMock } from '@golevelup/ts-jest'; import { GraphqlService } from '@kordis/spa/core/graphql'; -import { UnitSearchService } from '../../../../services/unit-search.service'; import { PossibleUnitSelectionsService } from '../../service/unit-selection.service'; import { UnitsSelectComponent } from './units-select.component'; @@ -20,10 +19,6 @@ describe('StrengthComponent', () => { provide: GraphqlService, useValue: createMock(), }, - { - provide: UnitSearchService, - useValue: createMock(), - }, ], }).compileComponents(); }); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/units-select.component.ts similarity index 64% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/units-select.component.ts index a73724de..f0d77371 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/component/unit/units-select.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/units-select.component.ts @@ -1,23 +1,30 @@ -import { AsyncPipe } from '@angular/common'; +import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, OnDestroy, + TemplateRef, + contentChild, effect, inject, input, viewChild, } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { + NzFormControlComponent, + NzFormItemComponent, +} from 'ng-zorro-antd/form'; import { NzSelectComponent, NzSelectModule } from 'ng-zorro-antd/select'; +import { NzSpinComponent } from 'ng-zorro-antd/spin'; import { Observable, Subject, - debounceTime, filter, merge, scan, share, + startWith, switchMap, takeUntil, } from 'rxjs'; @@ -35,49 +42,73 @@ import { UnitSelectionOptionComponent } from './unit-selection-option.component' NzSelectModule, ReactiveFormsModule, UnitSelectionOptionComponent, + NgTemplateOutlet, + NzSpinComponent, + NzFormControlComponent, + NzFormItemComponent, ], template: ` - - @for (unit of unitResults$ | async; track unit.id) { - + + - - - } - - @for (unit of control().value; track unit.id) { - - } - + @for (unit of unitResults$ | async; track unit.id) { + + @if (templateRef()) { + + } @else { + {{ unit.callSign }} + {{ unit.name }} + } + + } + + @for (unit of control().value; track unit.id) { + + } + + + `, styles: ` nz-select { width: 100%; height: 100%; } + + .unit-name { + color: grey; + font-size: 0.9em; + } `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class UnitsSelectComponent implements OnDestroy { + readonly templateRef = contentChild(TemplateRef); + readonly control = input.required>(); // must be a form control, as nz-select does not support form arrays private readonly searchValueChangedSubject$ = new Subject(); @@ -100,17 +131,15 @@ export class UnitsSelectComponent implements OnDestroy { () => this.possibleUnitSelectionsService.allPossibleEntitiesToSelect$, ), ), - // search + // on search this.searchValueChanged$.pipe( filter((value) => value !== ''), - debounceTime(300), switchMap((value) => - value - ? this.possibleUnitSelectionsService.searchAllPossibilities(value) - : [], + this.possibleUnitSelectionsService.searchAllPossibilities(value), ), ), ); + private selectEle = viewChild(NzSelectComponent); private readonly destroySubject$ = new Subject(); @@ -120,6 +149,7 @@ export class UnitsSelectComponent implements OnDestroy { // units selected should not be visible in the search while units that are unselected should become visible again this.control() .valueChanges.pipe( + startWith(this.control().value), scan((prev, curr) => { // naively unmark all previously selected units prev.forEach((unit) => diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.css b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.css similarity index 68% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.css rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.css index 87203434..8c8b11f5 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.css +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.css @@ -17,11 +17,19 @@ .actions { margin-top: 10px; display: flex; - justify-content: flex-end; - gap: 10px; +} + +.actions.signed-in-actions { + justify-content: space-between; +} + +.actions.signed-off-actions { + justify-content: end; } nz-card { + margin-bottom: 5px; + .ant-card-body { padding: 10px; } diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.html similarity index 88% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.html index 24f72d0e..95d366fb 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.html +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.html @@ -1,9 +1,10 @@

@if (rescueStation.signedIn) { - Nachmeldung {{ rescueStation.name }} + Nachmeldung } @else { - Anmeldung {{ rescueStation.name }} + Anmeldung } + {{ rescueStation.name }}

@@ -14,10 +15,10 @@

Funkspruch

/> @if ( formGroup.controls.protocolData.dirty && + formGroup.controls.protocolData.invalid && formGroup.controls.protocolData.controls.sender.touched && formGroup.controls.protocolData.controls.channel.touched && - formGroup.controls.protocolData.controls.recipient.touched && - formGroup.controls.protocolData.invalid + formGroup.controls.protocolData.controls.recipient.touched ) { Funkspruch }
- +

RW-Daten

@@ -80,28 +81,30 @@

RW-Daten

-
- @if (rescueStation.signedIn) { +@if (rescueStation.signedIn) { +
- } @else { +
+} @else { +
- } -
+
+} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts similarity index 90% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts index 16221267..fce259f7 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.spec.ts @@ -14,16 +14,16 @@ describe('RescueStationEditModalComponent', () => { beforeEach(() => { TestBed.overrideProvider(NzModalRef, { useValue: createMock(), - }); - TestBed.overrideProvider(RescueStationEditService, { - useValue: createMock(), - }); - TestBed.overrideProvider(GraphqlService, { - useValue: createMock(), - }); - TestBed.configureTestingModule({ - imports: [NoopAnimationsModule], - }); + }) + .overrideProvider(RescueStationEditService, { + useValue: createMock(), + }) + .overrideProvider(GraphqlService, { + useValue: createMock(), + }) + .configureTestingModule({ + imports: [NoopAnimationsModule], + }); }); it('should default units if not signed in', () => { diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.ts similarity index 85% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.ts index b669acb6..29b1e23c 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/rescue-station-edit-modal.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.ts @@ -72,8 +72,8 @@ export class RescueStationEditModalComponent { readonly loadingState = signal<'UPDATE' | 'SIGN_OFF' | 'SIGN_IN' | false>( false, ); - readonly #modal = inject(NzModalRef); + private readonly modal = inject(NzModalRef); private readonly fb = inject(NonNullableFormBuilder); readonly formGroup = this.fb.group({ rescueStationData: this.fb.group({ @@ -119,30 +119,10 @@ export class RescueStationEditModalComponent { private readonly rescueStationService = inject(RescueStationEditService); private readonly notificationService = inject(NzNotificationService); - constructor() { - this.markAssignmentsAsSelected(); - } - updateSignedInStation(): void { - this.formGroup.markAllAsTouched(); - - // if the protocol data is dirty, we want to make sure it is valid as it is an optional form for updates - // first check if some fields are filled, if not, we mark the form as pristine to handle the case where a user can enter a value, validates it, deletes it => should not be required - if ( - Object.values(this.formGroup.controls.protocolData.controls).every( - (control) => control.value === null || control.value === '', - ) - ) { - this.formGroup.controls.protocolData.markAsPristine(); - } - if ( - (this.formGroup.controls.protocolData.dirty && - this.formGroup.controls.protocolData.invalid) || - this.formGroup.controls.rescueStationData.invalid - ) { + if (!this.isFormValidForUpdate()) { return; } - this.loadingState.set('UPDATE'); this.rescueStationService .update$( @@ -157,7 +137,7 @@ export class RescueStationEditModalComponent { 'RW Nachmeldung', `${this.rescueStation.name} wurde erfolgreich nachgemeldet.`, ); - this.#modal.destroy(); + this.modal.destroy(); }, error: () => this.notificationService.error( @@ -184,7 +164,7 @@ export class RescueStationEditModalComponent { 'RW Anmeldung', `${this.rescueStation.name} wurde erfolgreich angemeldet.`, ); - this.#modal.destroy(); + this.modal.destroy(); }, error: () => this.notificationService.error( @@ -211,7 +191,7 @@ export class RescueStationEditModalComponent { 'RW Abmeldung', `${this.rescueStation.name} wurde erfolgreich abgemeldet.`, ); - this.#modal.destroy(); + this.modal.destroy(); }, error: () => this.notificationService.error( @@ -222,21 +202,6 @@ export class RescueStationEditModalComponent { .add(() => this.loadingState.set(false)); } - private markAssignmentsAsSelected(): void { - for (const assignment of this.rescueStation.assignments) { - if (assignment.__typename === 'DeploymentUnit') { - this.possibleUnitSelectionsService.markAsSelected(assignment.unit); - } else if (assignment.__typename === 'DeploymentAlertGroup') { - this.possibleAlertGroupSelectionsService.markAsSelected( - assignment.alertGroup, - ); - for (const { unit } of assignment.assignedUnits) { - this.possibleUnitSelectionsService.markAsSelected(unit); - } - } - } - } - private getInitialUnitsFromStation(): Unit[] { let units: Unit[]; // if the station is not signed in, we want to preselect the default units @@ -256,13 +221,14 @@ export class RescueStationEditModalComponent { private getInitialAlertGroupsFromStation(): AlertGroupAssignmentFormGroup[] { return this.rescueStation.assignments.reduce((acc, assignment) => { if (assignment.__typename === 'DeploymentAlertGroup') { - const fg = this.fb.group({ - alertGroup: this.fb.control(assignment.alertGroup), - assignedUnits: this.fb.control( - assignment.assignedUnits.map(({ unit }) => unit), - ), - }); - acc.push(fg); + acc.push( + this.fb.group({ + alertGroup: this.fb.control(assignment.alertGroup), + assignedUnits: this.fb.control( + assignment.assignedUnits.map(({ unit }) => unit), + ), + }), + ); } return acc; }, [] as AlertGroupAssignmentFormGroup[]); @@ -287,4 +253,24 @@ export class RescueStationEditModalComponent { channel: protocolData.channel, }; } + + /* + If the protocol data is dirty, we want to make sure it is valid as it is an optional form + first check if some fields are filled, if not, we mark the form as pristine to handle the case where a user enters a value, validates it, deletes it => should not be required + */ + private isFormValidForUpdate(): boolean { + if ( + Object.values(this.formGroup.controls.protocolData.controls).every( + (control) => control.value === null || control.value === '', + ) + ) { + this.formGroup.controls.protocolData.markAsPristine(); + } + + return !( + (this.formGroup.controls.protocolData.dirty && + this.formGroup.controls.protocolData.invalid) || + this.formGroup.controls.rescueStationData.invalid + ); + } } diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/alert-group-selection.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/alert-group-selection.service.ts new file mode 100644 index 00000000..2e1d067a --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/alert-group-selection.service.ts @@ -0,0 +1,127 @@ +import { Injectable, inject } from '@angular/core'; +import { TypedDocumentNode } from 'apollo-angular'; +import { firstValueFrom, map } from 'rxjs'; + +import { AlertGroup, Query } from '@kordis/shared/model'; +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +import { + AbstractEntitySearchService, + EntitySearchService, + SearchEntityProvider, +} from '../../../../services/entity-search.service'; +import { EntitySelectionSearchService } from './entity-selection-search.service'; + +@Injectable({ + providedIn: 'root', +}) +class SelectableAlertGroupsProvider + implements SearchEntityProvider +{ + private readonly gqlService = inject(GraphqlService); + + provideByIds(ids: string[]): Promise { + return firstValueFrom( + this.gqlService + .queryOnce$<{ alertGroups: Query['alertGroups'] }>( + gql` + query GetAlertGroupsByIds($ids: [String!]!) { + alertGroups(filter: { ids: $ids }) { + id + name + currentUnits { + id + name + callSign + note + status { + status + } + assignment { + __typename + id + name + } + } + assignment { + __typename + id + name + } + } + } + `, + { + ids, + }, + ) + .pipe(map(({ alertGroups }) => alertGroups)), + ); + } + + provideInitial(): Promise { + return firstValueFrom( + this.gqlService + .queryOnce$<{ alertGroups: Query['alertGroups'] }>(gql` + query GetAlertGroups { + alertGroups { + id + name + } + } + `) + .pipe(map(({ alertGroups }) => alertGroups)), + ); + } +} + +@Injectable({ + providedIn: 'root', +}) +class SelectableAlertGroupSearchService extends AbstractEntitySearchService { + protected constructor(alertGroupsProvider: SelectableAlertGroupsProvider) { + super(alertGroupsProvider, ['name']); + } +} + +/* + This service handles the selection of alert groups in a context where a unit can only be selected once. + */ +@Injectable() +export class PossibleAlertGroupSelectionsService extends EntitySelectionSearchService< + AlertGroup, + { alertGroups: Query['alertGroups'] } +> { + protected override query: TypedDocumentNode<{ + alertGroups: Query['alertGroups']; + }> = gql` + query { + alertGroups { + id + name + currentUnits { + id + name + callSign + status { + status + } + assignment { + __typename + id + name + } + } + assignment { + __typename + id + name + } + } + } + `; + protected queryName = 'alertGroups' as const; + protected searchService: EntitySearchService = inject( + SelectableAlertGroupSearchService, + ); +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/entity-selection-search.service.spec.ts similarity index 86% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/entity-selection-search.service.spec.ts index dd8fbb8f..bbbda4c6 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.spec.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/entity-selection-search.service.spec.ts @@ -4,10 +4,10 @@ import { of } from 'rxjs'; import { GraphqlService } from '@kordis/spa/core/graphql'; -import { EntitySearchService } from '../../../services/entity-search.service'; -import { PossibleEntitySelectionsService } from './possible-entity-selection.service'; +import { EntitySearchService } from '../../../../services/entity-search.service'; +import { EntitySelectionSearchService } from './entity-selection-search.service'; -class TestEntitySelectionService extends PossibleEntitySelectionsService< +class TestEntitySelectionService extends EntitySelectionSearchService< { id: string; name: string }, { testEntities: { id: string; name: string }[]; @@ -19,8 +19,8 @@ class TestEntitySelectionService extends PossibleEntitySelectionsService< createMock>(); // Mock search service } -describe('PossibleEntitySelectionsService', () => { - let service: PossibleEntitySelectionsService< +describe('EntitySelectionSearchService', () => { + let service: EntitySelectionSearchService< { id: string; name: string }, { testEntities: { id: string; name: string }[]; diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/entity-selection-search.service.ts similarity index 81% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/entity-selection-search.service.ts index 9a34fb60..6d0e3ca1 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/possible-entity-selection.service.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/entity-selection-search.service.ts @@ -4,12 +4,13 @@ import { Subject, map, shareReplay, startWith, switchMap } from 'rxjs'; import { GraphqlService } from '@kordis/spa/core/graphql'; -import { EntitySearchService } from '../../../services/entity-search.service'; +import { EntitySearchService } from '../../../../services/entity-search.service'; /* - This service handles the selection of entities in a context where an entity can only be selected once. + * This service handles the selection of entities in a context where an entity can only be selected once. + * If a entity is selected, it will not be visible in the search results anymore. It can be deselected to be visible again. */ -export abstract class PossibleEntitySelectionsService< +export abstract class EntitySelectionSearchService< TEntity extends { id: string }, TQuery extends Record, > { @@ -21,7 +22,7 @@ export abstract class PossibleEntitySelectionsService< private readonly gqlService = inject(GraphqlService); private readonly selectionChangedSubject$ = new Subject(); readonly allPossibleEntitiesToSelect$ = this.selectionChangedSubject$.pipe( - startWith(null), + startWith(null), // trigger initial query switchMap(() => this.gqlService .queryOnce$(this.query) diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/rescue-station-edit.service.spec.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/rescue-station-edit.service.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/rescue-station-edit.service.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/service/rescue-station-edit.service.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/rescue-station-edit.service.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/unit-selection.service.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/unit-selection.service.ts new file mode 100644 index 00000000..0397417c --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/service/unit-selection.service.ts @@ -0,0 +1,120 @@ +import { Injectable, inject } from '@angular/core'; +import { TypedDocumentNode } from 'apollo-angular'; +import { firstValueFrom, map } from 'rxjs'; + +import { Query, Unit } from '@kordis/shared/model'; +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +import { + AbstractEntitySearchService, + EntitySearchService, + SearchEntityProvider, +} from '../../../../services/entity-search.service'; +import { EntitySelectionSearchService } from './entity-selection-search.service'; + +@Injectable({ + providedIn: 'root', +}) +class SelectableUnitsProvider implements SearchEntityProvider { + private readonly gqlService = inject(GraphqlService); + + provideByIds(ids: string[]): Promise { + return firstValueFrom( + this.gqlService + .queryOnce$<{ units: Query['units'] }>( + gql` + query GetUnitsByIds($ids: [ID!]!) { + units(filter: { ids: $ids }) { + id + callSign + callSignAbbreviation + name + status { + status + } + assignment { + __typename + name + } + } + } + `, + { + ids, + }, + ) + .pipe(map(({ units }) => units)), + ); + } + + provideInitial(): Promise { + return firstValueFrom( + this.gqlService + .queryOnce$<{ units: Query['units'] }>(gql` + query GetUnits { + units { + id + callSign + callSignAbbreviation + name + status { + status + } + assignment { + __typename + name + } + } + } + `) + .pipe(map(({ units }) => units)), + ); + } +} + +@Injectable({ + providedIn: 'root', +}) +class SelectableUnitSearchService extends AbstractEntitySearchService { + constructor(unitSearchEntityProvider: SelectableUnitsProvider) { + super(unitSearchEntityProvider, [ + 'callSign', + 'callSignAbbreviation', + 'name', + ]); + } +} + +/* + This service handles the selection of units in a context where a unit can only be selected once. + */ +@Injectable() +export class PossibleUnitSelectionsService extends EntitySelectionSearchService< + Unit, + { units: Query['units'] } +> { + protected query: TypedDocumentNode<{ units: Query['units'] }> = gql` + query { + units { + id + callSign + callSignAbbreviation + name + status { + status + receivedAt + source + } + assignment { + __typename + id + name + } + } + } + `; + protected queryName = 'units' as const; + protected searchService: EntitySearchService = inject( + SelectableUnitSearchService, + ); +} diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/validator/alert-group-min-units.validator.spec.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/validator/alert-group-min-units.validator.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/validator/alert-group-min-units.validator.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/alert-group-min-units.validator.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/validator/alert-group-min-units.validator.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/validator/min-strength.validator.spec.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.spec.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/validator/min-strength.validator.spec.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/validator/min-strength.validator.ts similarity index 100% rename from libs/spa/feature/deployment/src/lib/components/rescue-station-edit-modal/validator/min-strength.validator.ts rename to libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/validator/min-strength.validator.ts diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.spec.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.spec.ts new file mode 100644 index 00000000..77f1abd0 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.spec.ts @@ -0,0 +1,29 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { createMock } from '@golevelup/ts-jest'; +import { NZ_MODAL_DATA, NzModalRef } from 'ng-zorro-antd/modal'; + +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { RescueStationNoteModalComponent } from './rescue-station-note-modal.component'; + +describe('RescueStationNoteModalComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.overrideProvider(NZ_MODAL_DATA, { + useValue: { id: '1', name: 'Test Rescue Station' }, // ... provide the mock data + }) + .overrideProvider(NzModalRef, { + useValue: createMock(), // ... provide the mock data + }) + .overrideProvider(GraphqlService, { + useValue: createMock(), + }) + .createComponent(RescueStationNoteModalComponent); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(fixture.componentInstance).toBeTruthy(); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.ts new file mode 100644 index 00000000..46267419 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.ts @@ -0,0 +1,86 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; +import { FormsModule, NonNullableFormBuilder } from '@angular/forms'; +import { NzButtonComponent } from 'ng-zorro-antd/button'; +import { NzInputDirective } from 'ng-zorro-antd/input'; +import { NZ_MODAL_DATA, NzModalRef } from 'ng-zorro-antd/modal'; +import { NzNotificationService } from 'ng-zorro-antd/notification'; + +import { RescueStationDeployment } from '@kordis/shared/model'; +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +@Component({ + selector: 'krd-rescue-station-note-modal', + standalone: true, + imports: [NzButtonComponent, FormsModule, NzInputDirective], + template: ` +

{{ rescueStation.name }}

+ + + +
+ +
+ `, + styles: ` + .actions { + display: flex; + justify-content: flex-end; + margin-top: 10px; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RescueStationNoteModalComponent { + readonly rescueStation: RescueStationDeployment = inject(NZ_MODAL_DATA); + readonly isLoading = signal(false); + readonly note = signal(this.rescueStation.note); + + private readonly modal = inject(NzModalRef); + private readonly gqlService = inject(GraphqlService); + private readonly fb = inject(NonNullableFormBuilder); + private readonly notificationService = inject(NzNotificationService); + + updateNote(): void { + this.isLoading.set(true); + this.gqlService + .mutate$( + gql` + mutation UpdateRescueStationNote($id: ID!, $note: String!) { + updateRescueStationNote(id: $id, note: $note) { + id + note + } + } + `, + { + id: this.rescueStation.id, + note: this.note(), + }, + ) + .subscribe(() => { + this.notificationService.success( + 'Erfolgreich', + 'Notiz wurde aktualisiert', + ); + this.modal.close(); + }); + } +} diff --git a/libs/spa/feature/deployment/src/lib/services/alert-group-search.service.ts b/libs/spa/feature/deployment/src/lib/services/alert-group-search.service.ts deleted file mode 100644 index 3ee7dec5..00000000 --- a/libs/spa/feature/deployment/src/lib/services/alert-group-search.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { AlertGroup, Query } from '@kordis/shared/model'; -import { gql } from '@kordis/spa/core/graphql'; - -import { AbstractEntitySearchService } from './entity-search.service'; - -@Injectable({ - providedIn: 'root', -}) -export class AlertGroupSearchService extends AbstractEntitySearchService< - AlertGroup, - { alertGroups: Query['alertGroups'] }, - { alertGroup: Query['alertGroup'] } -> { - protected constructor() { - super( - gql` - query GetAlertGroups { - alertGroups { - id - name - } - } - `, - gql` - query GetAlertGroup($id: String!) { - alertGroup(id: $id) { - id - name - units { - id - name - callSign - note - status { - status - } - assignment { - __typename - id - name - } - } - assignment { - __typename - id - name - } - } - } - `, - 'alertGroups', - 'alertGroup', - ['name'], - ); - } -} diff --git a/libs/spa/feature/deployment/src/lib/services/deployments-search-state.service.spec.ts b/libs/spa/feature/deployment/src/lib/services/deployments-search-state.service.spec.ts new file mode 100644 index 00000000..0d89ae9a --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/services/deployments-search-state.service.spec.ts @@ -0,0 +1,27 @@ +import { TestBed } from '@angular/core/testing'; + +import { DeploymentsSearchStateService } from './deployments-search-state.service'; + +describe('DeploymentsSearchStateService', () => { + let service: DeploymentsSearchStateService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DeploymentsSearchStateService], + }); + + service = TestBed.inject(DeploymentsSearchStateService); + }); + + it('should emit new value when searchValue is set', () => + new Promise((done) => { + const newValue = 'test search'; + + service.searchValueChange$.subscribe((value) => { + expect(value).toBe(newValue); + done(); + }); + + service.searchValue.set(newValue); + })); +}); diff --git a/libs/spa/feature/deployment/src/lib/services/deployments-search-state.service.ts b/libs/spa/feature/deployment/src/lib/services/deployments-search-state.service.ts new file mode 100644 index 00000000..65737c03 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/services/deployments-search-state.service.ts @@ -0,0 +1,11 @@ +import { signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { share } from 'rxjs'; + +/* + * This service is simply used to manage the search state of the deployments search. + */ +export class DeploymentsSearchStateService { + readonly searchValue = signal(''); + readonly searchValueChange$ = toObservable(this.searchValue).pipe(share()); +} diff --git a/libs/spa/feature/deployment/src/lib/services/deplyoment-assignments-search.service.spec.ts b/libs/spa/feature/deployment/src/lib/services/deplyoment-assignments-search.service.spec.ts new file mode 100644 index 00000000..6a484310 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/services/deplyoment-assignments-search.service.spec.ts @@ -0,0 +1,197 @@ +import { TestBed } from '@angular/core/testing'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { of } from 'rxjs'; + +import { + AlertGroup, + DeploymentAlertGroup, + DeploymentAssignment, + DeploymentUnit, +} from '@kordis/shared/model'; +import { GraphqlService } from '@kordis/spa/core/graphql'; + +import { DeploymentAssignmentsSearchService } from './deplyoment-assignments-search.service'; + +describe('DeploymentAssignmentsSearchService', () => { + let service: DeploymentAssignmentsSearchService; + let gqlService: DeepMocked; + + beforeEach(() => { + gqlService = createMock(); + + TestBed.configureTestingModule({ + providers: [ + DeploymentAssignmentsSearchService, + { provide: GraphqlService, useValue: gqlService }, + ], + }); + + service = TestBed.inject(DeploymentAssignmentsSearchService); + }); + + describe('updateAssignments', () => { + it('should index only new items', () => { + const assignments: DeploymentAssignment[] = [ + { + __typename: 'DeploymentUnit', + unit: { id: '1', name: 'Unit 1', callSign: 'U1' }, + } as DeploymentUnit, + { + __typename: 'DeploymentAlertGroup', + alertGroup: { id: '2', name: 'Group 1' } as AlertGroup, + assignedUnits: [], + }, + ]; + + const unitSearchEngineSpy = jest.spyOn( + service['unitSearchEngine'], + 'add', + ); + const alertGroupSearchEngineSpy = jest.spyOn( + service['alertGroupSearchEngine'], + 'add', + ); + + service.updateAssignments(assignments); + + expect(service['currentIndexedUnits'].has('1')).toBe(true); + expect(service['currentIndexedAlertGroups'].has('2')).toBe(true); + + service.updateAssignments(assignments); + + expect(unitSearchEngineSpy).toHaveBeenCalledTimes(1); + expect(alertGroupSearchEngineSpy).toHaveBeenCalledTimes(1); + }); + + it('should remove items that are not present anymore', () => { + const initialAssignments: DeploymentAssignment[] = [ + { + __typename: 'DeploymentUnit', + unit: { id: '1', name: 'Unit 1', callSign: 'U1' }, + } as DeploymentUnit, + ]; + + service.updateAssignments(initialAssignments); + + const newAssignments: DeploymentAssignment[] = [ + { + __typename: 'DeploymentUnit', + unit: { id: '2', name: 'Unit 2', callSign: 'U2' }, + } as DeploymentUnit, + ]; + + service.updateAssignments(newAssignments); + + expect(service['currentIndexedUnits'].has('1')).toBe(false); + expect(service['currentIndexedUnits'].has('2')).toBe(true); + }); + }); + + describe('search', () => { + it('should find by name', async () => { + const assignments: DeploymentAssignment[] = [ + { + __typename: 'DeploymentUnit', + unit: { id: '1', name: 'Unit 1', callSign: 'U1' }, + } as DeploymentUnit, + { + __typename: 'DeploymentAlertGroup', + alertGroup: { id: '2', name: 'Group 1' } as AlertGroup, + assignedUnits: [], + }, + { + __typename: 'DeploymentAlertGroup', + alertGroup: { id: '2', name: 'Group 2' } as AlertGroup, + assignedUnits: [], + }, + ]; + + service.updateAssignments(assignments); + + gqlService.queryOnce$ + .mockReturnValueOnce( + of({ unit: { id: '1', name: 'Unit 1', callSign: 'U1' } }), + ) + .mockReturnValueOnce(of({ alertGroup: { id: '2', name: 'Group 1' } })); + + const results = await service.search('1'); + + expect(results).toEqual([ + { + __typename: 'DeploymentUnit', + unit: { id: '1', name: 'Unit 1', callSign: 'U1' }, + }, + { + __typename: 'DeploymentAlertGroup', + alertGroup: { id: '2', name: 'Group 1' }, + assignedUnits: [], + }, + ]); + }); + + it('should find by call sign', async () => { + const assignments: DeploymentAssignment[] = [ + { + __typename: 'DeploymentUnit', + unit: { id: '1', name: 'Unit 1', callSign: 'some callsign' }, + } as DeploymentUnit, + ]; + + service.updateAssignments(assignments); + + gqlService.queryOnce$.mockReturnValueOnce( + of({ unit: { id: '1', name: 'Unit 1', callSign: 'some callsign' } }), + ); + + const results = await service.search('som'); + + expect(results).toEqual([ + { + __typename: 'DeploymentUnit', + unit: { id: '1', name: 'Unit 1', callSign: 'some callsign' }, + }, + ]); + }); + + it('should find by call sign abbreviation', async () => { + const assignments: DeploymentAssignment[] = [ + { + __typename: 'DeploymentUnit', + unit: { + id: '1', + name: 'Unit 1', + callSign: 'some callsign', + callSignAbbreviation: 'abbr', + }, + } as DeploymentUnit, + ]; + + service.updateAssignments(assignments); + + gqlService.queryOnce$.mockReturnValueOnce( + of({ + unit: { + id: '1', + name: 'Unit 1', + callSign: 'some callsign', + callSignAbbreviation: 'abr', + }, + }), + ); + + const results = await service.search('abb'); + + expect(results).toEqual([ + { + __typename: 'DeploymentUnit', + unit: { + id: '1', + name: 'Unit 1', + callSign: 'some callsign', + callSignAbbreviation: 'abr', + }, + }, + ]); + }); + }); +}); diff --git a/libs/spa/feature/deployment/src/lib/services/deplyoment-assignments-search.service.ts b/libs/spa/feature/deployment/src/lib/services/deplyoment-assignments-search.service.ts new file mode 100644 index 00000000..5dd78769 --- /dev/null +++ b/libs/spa/feature/deployment/src/lib/services/deplyoment-assignments-search.service.ts @@ -0,0 +1,277 @@ +import { Injectable, inject } from '@angular/core'; +import MiniSearch from 'minisearch'; +import { firstValueFrom, map } from 'rxjs'; + +import { + AlertGroup, + DeploymentAlertGroup, + DeploymentAssignment, + DeploymentUnit, + Query, + Unit, +} from '@kordis/shared/model'; +import { GraphqlService, gql } from '@kordis/spa/core/graphql'; + +const UNIT_FRAGMENT = gql` + fragment UnitFragment on Unit { + id + callSign + name + status { + status + receivedAt + } + } +`; + +type SearchableUnit = Unit & { alertGroupId?: string }; + +@Injectable() +export class DeploymentAssignmentsSearchService { + private readonly gqlService = inject(GraphqlService); + + private currentIndexedUnits = new Set(); + private currentIndexedAlertGroups = new Map(); // keep alert group units indexed by alert group id to show them if we have a alert group match but no unit matches + + private readonly unitSearchEngine = new MiniSearch({ + fields: ['name', 'callSign', 'callSignAbbreviation'], + storeFields: ['alertGroupId'], + }); + private readonly alertGroupSearchEngine = new MiniSearch({ + fields: ['name'], + }); + + updateAssignments(assignments: DeploymentAssignment[]): void { + const newIndexedUnits = new Set(); + const newIndexedAlertGroups = new Map(); + + // check new assignments for any newly added assignments + this.indexNewAssignments( + assignments, + newIndexedUnits, + newIndexedAlertGroups, + ); + // Remove old assignments that are no longer present + this.removeNonPresentAssignments(newIndexedUnits, newIndexedAlertGroups); + + this.currentIndexedUnits = newIndexedUnits; + this.currentIndexedAlertGroups = newIndexedAlertGroups; + } + + /* + * Searches for Units and Alert Groups, that have been indexed by `updateAssignments`. + * If a unit is found which is assigned to an alert group, the alert group will show only with matching units. + * If an alert group is found, it will show all assigned units, but only if there has not been a unit match. + */ + async search( + query: string, + ): Promise<(DeploymentUnit | DeploymentAlertGroup)[]> { + const unitsResult = this.unitSearchEngine.search(query, { + prefix: true, + combineWith: 'AND', + }); + const alertGroupsResult = this.alertGroupSearchEngine.search(query, { + prefix: true, + combineWith: 'AND', + }); + + const alertGroups = new Map(); + const standAloneUnits = []; + + // populate units, if they are assigned to an alert group, add them to the alert group which first will be populated + for (const r of unitsResult) { + const unit = await this.populateUnit(r.id); + if (r.alertGroupId) { + await this.addUnitToAlertGroup(alertGroups, r.alertGroupId, unit); + } else { + standAloneUnits.push({ + __typename: 'DeploymentUnit' as const, + unit, + }); + } + } + + // populate missing alert groups that have not been added yet + for (const r of alertGroupsResult) { + if (!alertGroups.has(r.id)) { + await this.setAlertGroup(r.id, alertGroups, unitsResult.length > 0); + } + } + + return [...standAloneUnits, ...alertGroups.values()]; + } + + private indexNewAssignments( + assignments: DeploymentAssignment[], + newIndexedUnits: Set, + newIndexedAlertGroups: Map, + ): void { + for (const assignment of assignments) { + if (assignment.__typename === 'DeploymentUnit') { + this.indexUnit(assignment.unit, newIndexedUnits); + } else if (assignment.__typename === 'DeploymentAlertGroup') { + this.indexAlertGroup( + assignment, + newIndexedUnits, + newIndexedAlertGroups, + ); + } + } + } + + private indexUnit(unit: SearchableUnit, newIndexedUnits: Set): void { + newIndexedUnits.add(unit.id); + if (!this.unitSearchEngine.has(unit.id)) { + this.unitSearchEngine.add(unit); + } + } + + private indexAlertGroup( + assignment: DeploymentAlertGroup, + newIndexedUnits: Set, + newIndexedAlertGroups: Map, + ): void { + newIndexedAlertGroups.set(assignment.alertGroup.id, []); + // index the alert group itself + if (!this.alertGroupSearchEngine.has(assignment.alertGroup.id)) { + this.alertGroupSearchEngine.add(assignment.alertGroup); + } + + // index assigned units of the alert group + for (const { unit } of assignment.assignedUnits) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + newIndexedAlertGroups.get(assignment.alertGroup.id)!.push(unit.id); + this.indexUnit( + { + ...unit, + alertGroupId: assignment.alertGroup.id, + }, + newIndexedUnits, + ); + } + } + + private removeNonPresentAssignments( + newIndexedUnits: Set, + newIndexedAlertGroups: Map, + ): void { + // for each ID that is not present in the new assignments, discard it from the search index + for (const id of this.currentIndexedUnits) { + if (!newIndexedUnits.has(id)) { + this.unitSearchEngine.discard(id); + } + } + + for (const [id] of this.currentIndexedAlertGroups) { + if (!newIndexedAlertGroups.has(id)) { + this.alertGroupSearchEngine.discard(id); + } + } + } + + private async setAlertGroup( + alertGroupId: string, + alertGroups: Map, + hasFoundUnits: boolean, + ): Promise { + const alertGroup = await this.populateAlertGroup(alertGroupId); + + // if units have been found, we will there is a match of units AND this alert group, therefor do not show assigned units unless we have found only alert groups + const assignedUnits = hasFoundUnits + ? [] + : await this.populateAssignedAlertGroupUnits(alertGroupId); + + alertGroups.set(alertGroupId, { + __typename: 'DeploymentAlertGroup' as const, + alertGroup, + assignedUnits, + }); + } + + /* + * Adds unit to an alert group. + * If the alert group has not been populated, it will be added t the alert groups map. + */ + private async addUnitToAlertGroup( + alertGroups: Map, + alertGroupId: string, + unit: Unit, + ): Promise { + // if the unit is assigned to an alert group, add it to the alert group + if (!alertGroups.has(alertGroupId)) { + // if the alert has not been added yet, add it + alertGroups.set(alertGroupId, { + __typename: 'DeploymentAlertGroup' as const, + alertGroup: await this.populateAlertGroup(alertGroupId), + assignedUnits: [], + }); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + alertGroups.get(alertGroupId)!.assignedUnits.push({ + __typename: 'DeploymentUnit' as const, + unit, + }); + } + + private async populateAssignedAlertGroupUnits( + alertGroupId: string, + ): Promise { + const populatedAssignedUnits = await Promise.all( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.currentIndexedAlertGroups + .get(alertGroupId)! + .map((id) => this.populateUnit(id)), + ); + + return populatedAssignedUnits.map((unit) => ({ + __typename: 'DeploymentUnit' as const, + unit, + })) as DeploymentUnit[]; + } + + private populateAlertGroup(id: string): Promise { + return firstValueFrom( + this.gqlService + .queryOnce$<{ alertGroup: Query['alertGroup'] }>( + gql` + ${UNIT_FRAGMENT} + query GetAlertGroupById($id: ID!) { + alertGroup(id: $id) { + id + name + currentUnitsOfAssignment { + unit { + ...UnitFragment + } + } + } + } + `, + { + id, + }, + ) + .pipe(map(({ alertGroup }) => alertGroup)), + ); + } + + private populateUnit(id: string): Promise { + return firstValueFrom( + this.gqlService + .queryOnce$<{ unit: Query['unit'] }>( + gql` + ${UNIT_FRAGMENT} + query GetUnitById($id: ID!) { + unit(id: $id) { + ...UnitFragment + } + } + `, + { + id, + }, + ) + .pipe(map(({ unit }) => unit)), + ); + } +} diff --git a/libs/spa/feature/deployment/src/lib/services/entity-search.service.spec.ts b/libs/spa/feature/deployment/src/lib/services/entity-search.service.spec.ts index 602f6677..39d1504f 100644 --- a/libs/spa/feature/deployment/src/lib/services/entity-search.service.spec.ts +++ b/libs/spa/feature/deployment/src/lib/services/entity-search.service.spec.ts @@ -1,9 +1,4 @@ import { TestBed } from '@angular/core/testing'; -import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { TypedDocumentNode } from 'apollo-angular'; -import { of } from 'rxjs'; - -import { GraphqlService, gql } from '@kordis/spa/core/graphql'; import { AbstractEntitySearchService } from './entity-search.service'; @@ -13,40 +8,17 @@ interface TestEntity { someMoreData: string; } -type QueryAllResponse = { - testEntities: TestEntity[]; -}; - -type QueryOneResponse = { - testEntity: TestEntity; +const TEST_ENTITY_PROVIDER = { + provideByIds: jest.fn(), + provideInitial: jest.fn().mockResolvedValue([ + { id: '1', name: 'Entity One' }, + { id: '2', name: 'Entity Two' }, + ]), }; -const getAllQuery: TypedDocumentNode = gql` - query GetAllTestEntities { - testEntities { - id - name - } - } -`; - -const getOneQuery: TypedDocumentNode = gql` - query GetTestEntity($id: String!) { - testEntity(id: $id) { - id - name - someMoreData - } - } -`; - -class TestEntitySearchService extends AbstractEntitySearchService< - TestEntity, - QueryAllResponse, - QueryOneResponse -> { +class TestEntitySearchService extends AbstractEntitySearchService { constructor() { - super(getAllQuery, getOneQuery, 'testEntities', 'testEntity', ['name']); + super(TEST_ENTITY_PROVIDER, ['name']); } } @@ -61,24 +33,10 @@ jest.mock('minisearch', () => describe('TestEntitySearchService', () => { let service: TestEntitySearchService; - let graphqlServiceMock: DeepMocked; beforeEach(() => { - graphqlServiceMock = createMock(); - graphqlServiceMock.queryOnce$.mockReturnValueOnce( - of({ - testEntities: [ - { id: '1', name: 'Entity One' }, - { id: '2', name: 'Entity Two' }, - ], - }), - ); - TestBed.configureTestingModule({ - providers: [ - { provide: GraphqlService, useValue: graphqlServiceMock }, - TestEntitySearchService, - ], + providers: [TestEntitySearchService], }); service = TestBed.inject(TestEntitySearchService); @@ -89,7 +47,7 @@ describe('TestEntitySearchService', () => { }); it('should fetch all entities initially', () => { - expect(graphqlServiceMock.queryOnce$).toHaveBeenCalledWith(getAllQuery); + expect(TEST_ENTITY_PROVIDER.provideInitial).toHaveBeenCalled(); expect(searchEngineAddAllMock).toHaveBeenCalledWith([ { id: '1', name: 'Entity One' }, { id: '2', name: 'Entity Two' }, @@ -100,16 +58,11 @@ describe('TestEntitySearchService', () => { searchEngineSearchMock.mockReturnValueOnce([ { id: '1', name: 'Entity One' }, ]); - graphqlServiceMock.queryOnce$.mockReturnValueOnce( - of({ - testEntity: { - id: '1', - name: 'Entity One', - someMoreData: 'Some more data', - }, - }), - ); + TEST_ENTITY_PROVIDER.provideByIds.mockResolvedValueOnce([ + { id: '1', name: 'Entity One', someMoreData: 'Some more data' }, + ]); const entities = await service.searchByTerm('One'); + expect(TEST_ENTITY_PROVIDER.provideByIds).toHaveBeenCalledWith(['1']); expect(entities).toEqual([ { id: '1', name: 'Entity One', someMoreData: 'Some more data' }, ]); diff --git a/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts b/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts index 2bc3fffc..88f8dd95 100644 --- a/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts +++ b/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts @@ -1,54 +1,56 @@ -import { inject } from '@angular/core'; -import { TypedDocumentNode } from 'apollo-angular'; import MiniSearch from 'minisearch'; -import { firstValueFrom, map } from 'rxjs'; -import { GraphqlService } from '@kordis/spa/core/graphql'; +/* + * This provider is used to provide the entities that should be searchable. + * provideInitial is used to provide the initial entities that should be indexed. It returns the necessary properties to search. + * provideByIds is used to provide the entities by their ids. It returns the complete entity. + */ +export interface SearchEntityProvider< + TEntity extends { + id: string; + }, +> { + provideByIds(ids: string[]): Promise; + + provideInitial(): Promise[]>; +} export interface EntitySearchService { searchByTerm(query: string): Promise; } +/* + * This service is used to search for entities by a search term. + * It indexes the entities and provides a search method to search for entities by a search term. + */ export abstract class AbstractEntitySearchService< TEntity extends { id: string; }, - TQueryAll extends Record, - TQueryOne extends Record, > implements EntitySearchService { - private readonly gqlService = inject(GraphqlService); - private readonly searchEngine: MiniSearch; + protected readonly searchEngine: MiniSearch; protected constructor( - getAllQuery: TypedDocumentNode, - private readonly populateEntityQuery: TypedDocumentNode, - queryAllName: keyof TQueryAll, - private readonly queryOneName: keyof TQueryOne, + private readonly entityProvider: SearchEntityProvider, searchFields: (keyof TEntity)[], ) { this.searchEngine = new MiniSearch({ fields: searchFields as string[], }); // first index all entities with the necessary data to search - this.gqlService - .queryOnce$(getAllQuery) - .pipe(map((res) => res[queryAllName])) - .subscribe((entities) => this.searchEngine.addAll(entities)); + entityProvider + .provideInitial() + .then((entities) => this.searchEngine.addAll(entities)); } searchByTerm(query: string): Promise { - const res = this.searchEngine.search(query); + const res = this.searchEngine.search(query, { + prefix: true, + combineWith: 'AND', + }); // populate the entities to get the whole entity - return Promise.all(res.map((sr) => this.getEntity(sr.id))); - } - - private getEntity(id: string): Promise { - return firstValueFrom( - this.gqlService - .queryOnce$(this.populateEntityQuery, { id }) - .pipe(map((res) => res[this.queryOneName])), - ); + return this.entityProvider.provideByIds(res.map((r) => r.id)); } } diff --git a/libs/spa/feature/deployment/src/lib/services/unit-search.service.ts b/libs/spa/feature/deployment/src/lib/services/unit-search.service.ts deleted file mode 100644 index a6aa50b2..00000000 --- a/libs/spa/feature/deployment/src/lib/services/unit-search.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { Query, Unit } from '@kordis/shared/model'; -import { gql } from '@kordis/spa/core/graphql'; - -import { AbstractEntitySearchService } from './entity-search.service'; - -@Injectable({ - providedIn: 'root', -}) -export class UnitSearchService extends AbstractEntitySearchService< - Unit, - { units: Query['units'] }, - { unit: Query['unit'] } -> { - constructor() { - super( - gql` - query GetUnits { - units { - id - callSign - callSignAbbreviation - name - assignment { - __typename - } - } - } - `, - gql` - query GetUnit($id: String!) { - unit(id: $id) { - id - callSign - callSignAbbreviation - name - status { - status - receivedAt - source - } - assignment { - __typename - id - name - } - } - } - `, - 'units', - 'unit', - ['callSign', 'callSignAbbreviation', 'name'], - ); - } -} From 21649d092ee01766b585b67303d21762ee0dd37e Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 20 Sep 2024 08:48:25 +0200 Subject: [PATCH 24/26] chore(spa): change spacing to var introduced in !1052 Signed-off-by: Timon Masberg --- .../alert-group-edit-modal.component.ts | 2 +- .../deployment-alert-group.component.ts | 4 +-- .../deployment-note-popup.component.ts | 3 +- .../deployment/deployments.component.css | 4 +-- .../deployment/deplyoment-card.component.ts | 8 +++--- .../signed-in-rescue-stations.component.ts | 2 +- .../signed-off-deployments.component.ts | 2 +- .../unit/deployment-unit.component.ts | 28 +++++++++---------- .../alert-group-selections.component.ts | 6 ++-- .../component/protocol-data.component.ts | 2 +- .../component/strength.component.ts | 2 +- .../unit/unit-selection-option.component.ts | 4 +-- .../rescue-station-edit-modal.component.css | 10 +++---- .../rescue-station-note-modal.component.ts | 2 +- 14 files changed, 39 insertions(+), 40 deletions(-) diff --git a/libs/spa/feature/deployment/src/lib/components/alert-group-edit-modal/alert-group-edit-modal.component.ts b/libs/spa/feature/deployment/src/lib/components/alert-group-edit-modal/alert-group-edit-modal.component.ts index a5b186f3..794d9d64 100644 --- a/libs/spa/feature/deployment/src/lib/components/alert-group-edit-modal/alert-group-edit-modal.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/alert-group-edit-modal/alert-group-edit-modal.component.ts @@ -69,7 +69,7 @@ import { alertGroupMinUnitsValidator } from '../rescue-station/rescue-station-ed } .actions { - margin-top: 10px; + margin-top: var(--base-spacing); display: flex; justify-content: flex-end; } diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/alert-group/deployment-alert-group.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/alert-group/deployment-alert-group.component.ts index e19e2ff4..07f5b9ed 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/alert-group/deployment-alert-group.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/alert-group/deployment-alert-group.component.ts @@ -29,7 +29,7 @@ import { DeploymentUnitComponent } from '../unit/deployment-unit.component'; .alert-group { display: flex; flex-direction: column; - gap: 4px; + gap: calc(var(--base-spacing) / 2); } .name { @@ -38,7 +38,7 @@ import { DeploymentUnitComponent } from '../unit/deployment-unit.component'; nz-card { .ant-card-body { - padding: 4px 8px; + padding: calc(var(--base-spacing) / 2) var(--base-spacing); background-color: #fafafa; } } diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.ts index 35ae7a7b..d66473c4 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployment-note-popup.component.ts @@ -50,7 +50,8 @@ import { NzSpinComponent } from 'ng-zorro-antd/spin'; position: absolute; top: 0; right: 0; - padding: 4px 16px 4px; + padding: calc(var(--base-spacing) / 2) calc(var(--base-spacing) * 2) + calc(var(--base-spacing) / 2); } .ant-input { diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css index 4692d624..50036f71 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deployments.component.css @@ -3,7 +3,7 @@ display: flex; flex-direction: column; - gap: 5px; + gap: calc(var(--base-spacing) / 2); height: 100%; .header-bar { @@ -24,7 +24,7 @@ } div > :not(:last-child) { - margin-right: 5px; + margin-right: calc(var(--base-spacing) / 2); } } diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts index 3ab82a34..95519de6 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/deplyoment-card.component.ts @@ -77,7 +77,7 @@ const STATUS_SORT_ORDER: Readonly> = Object.freeze({ } .ant-card-body { - padding: 18px; + padding: calc(var(--base-spacing) * 2); display: flex; flex-direction: column; flex-grow: 1; @@ -85,16 +85,16 @@ const STATUS_SORT_ORDER: Readonly> = Object.freeze({ } .sub-header { - margin-bottom: 10px; + margin-bottom: var(--base-spacing); } .assignments { display: flex; flex-direction: column; - gap: 4px; + gap: calc(var(--base-spacing) / 2); width: 100%; overflow-y: auto; - padding-right: 4px; + padding-right: calc(var(--base-spacing) / 2); box-sizing: content-box; } } diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.ts index 6459a331..10203b22 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/signed-in-rescue-stations.component.ts @@ -75,7 +75,7 @@ import { RescueStationDeploymentCardHeaderComponent } from './rescue-station/res :host { display: flex; flex-direction: row; - gap: 5px; + gap: calc(var(--base-spacing) / 2); height: 100%; } diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.ts index 7e83ac23..d9864d68 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/signed-off-deployments.component.ts @@ -44,7 +44,7 @@ import { NameSearchWrapperComponent } from './name-search-wrapper.component'; display: flex; flex-direction: column; flex-wrap: wrap; - gap: 5px; + gap: calc(var(--base-spacing) / 2); height: 100%; .empty-state { diff --git a/libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.ts b/libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.ts index 70a79031..30713922 100644 --- a/libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/deployment/unit/deployment-unit.component.ts @@ -36,32 +36,30 @@ import { StatusBadgeComponent } from './status-badge.component'; (click)="$event.stopPropagation()" nz-popover [nzPopoverBackdrop]="true" - [nzBodyStyle]="{ padding: '4px 8px' }" [nzPopoverTitle]="unit().callSign + ' - ' + unit().name" [nzPopoverContent]="notePopoverContent" nzPopoverTrigger="click" > -
-
- - {{ unit().callSign }} - @if (unit().note) { - - } - - -
-
- {{ unit().name }} -
+
+ + {{ unit().callSign }} + @if (unit().note) { + + } + + +
+
+ {{ unit().name }}
`, styles: ` nz-card { - .card-body { + .ant-card-body { display: flex; flex-direction: column; + padding: calc(var(--base-spacing) / 2) var(--base-spacing); } .header-row { diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts index ef376edc..2074541e 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/alert-group/alert-group-selections.component.ts @@ -75,15 +75,15 @@ import { AlertGroupSelectionComponent } from './alert-group-selection.component' overflow: auto; display: flex; flex-direction: column; - gap: 5px; + gap: calc(var(--base-spacing) / 2); } nz-card { .ant-card-body { - padding: 5px; + padding: calc(var(--base-spacing) / 2); } - margin-top: 5px; + margin-top: calc(var(--base-spacing) / 2); } nz-form-item { diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/protocol-data.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/protocol-data.component.ts index c94835eb..664c63a0 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/protocol-data.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/protocol-data.component.ts @@ -37,7 +37,7 @@ import { Unit } from '@kordis/shared/model'; display: flex; flex-direction: row; align-items: center; - gap: 5px; + gap: calc(var(--base-spacing) / 2); } `, changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.ts index 22ab1fbd..bb6dad95 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/strength.component.ts @@ -70,7 +70,7 @@ import { Subject, map, of, startWith, takeUntil } from 'rxjs'; display: flex; flex-direction: row; align-items: center; - gap: 5px; + gap: calc(var(--base-spacing) / 2); } .ant-form-item { diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts index e61d1d49..c6065481 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/component/unit/unit-selection-option.component.ts @@ -39,7 +39,7 @@ import { StatusBadgeComponent } from '../../../../deployment/unit/status-badge.c .call-sign { font-weight: 500; - margin-right: 5px; + margin-right: calc(var(--base-spacing) / 2); } .name { @@ -50,7 +50,7 @@ import { StatusBadgeComponent } from '../../../../deployment/unit/status-badge.c .assignment-note { font-size: 0.8em; - margin: -5px 0; + margin: calc(-1 * var(--base-spacing) / 2) 0; } } `, diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.css b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.css index 8c8b11f5..7c858b9e 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.css +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-edit-modal/rescue-station-edit-modal.component.css @@ -1,7 +1,7 @@ .form { display: flex; flex-direction: column; - gap: 10px; + gap: var(--base-spacing); .form-item { display: flex; @@ -15,7 +15,7 @@ } .actions { - margin-top: 10px; + margin-top: var(--base-spacing); display: flex; } @@ -28,13 +28,13 @@ } nz-card { - margin-bottom: 5px; + margin-bottom: calc(var(--base-spacing) / 2); .ant-card-body { - padding: 10px; + padding: var(--base-spacing); } } nz-alert { - margin-top: 5px; + margin-top: calc(var(--base-spacing) / 2); } diff --git a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.ts b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.ts index 46267419..fd1c1adc 100644 --- a/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.ts +++ b/libs/spa/feature/deployment/src/lib/components/rescue-station/rescue-station-note-modal/rescue-station-note-modal.component.ts @@ -43,7 +43,7 @@ import { GraphqlService, gql } from '@kordis/spa/core/graphql'; .actions { display: flex; justify-content: flex-end; - margin-top: 10px; + margin-top: var(--base-spacing); } `, changeDetection: ChangeDetectionStrategy.OnPush, From 1f3ff22c74cdf6601df3f5f4c5aec881a94270be Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 20 Sep 2024 11:36:00 +0200 Subject: [PATCH 25/26] ci: use install instead of clean install for api due to https://github.com/nrwl/nx/issues/15366 Signed-off-by: Timon Masberg --- apps/api/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index e06b0351..d1e24071 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -9,7 +9,7 @@ ENV NODE_ENV=production # Install dependencies separately for caching COPY ./dist/apps/api/package.json ./dist/apps/api/package-lock.json ./ -RUN npm ci --omit=dev +RUN npm i --omit=dev # ci is failing here due to nx bug https://github.com/nrwl/nx/issues/15366 (closed, but still an issue) COPY ./dist/apps/api ./ From 524319b47ea7af2f2c0365855c9661695c9ffbd4 Mon Sep 17 00:00:00 2001 From: Timon Masberg Date: Fri, 20 Sep 2024 11:52:33 +0200 Subject: [PATCH 26/26] fix: sonar findings Signed-off-by: Timon Masberg --- apps/api/Dockerfile | 3 ++- .../src/lib/services/entity-search.service.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index d1e24071..0413893b 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -9,7 +9,8 @@ ENV NODE_ENV=production # Install dependencies separately for caching COPY ./dist/apps/api/package.json ./dist/apps/api/package-lock.json ./ -RUN npm i --omit=dev # ci is failing here due to nx bug https://github.com/nrwl/nx/issues/15366 (closed, but still an issue) +# ci is failing here due to nx bug https://github.com/nrwl/nx/issues/15366 (closed, but still an issue) +RUN npm i --omit=dev COPY ./dist/apps/api ./ diff --git a/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts b/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts index 88f8dd95..66656501 100644 --- a/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts +++ b/libs/spa/feature/deployment/src/lib/services/entity-search.service.ts @@ -39,9 +39,7 @@ export abstract class AbstractEntitySearchService< fields: searchFields as string[], }); // first index all entities with the necessary data to search - entityProvider - .provideInitial() - .then((entities) => this.searchEngine.addAll(entities)); + this.indexInitialEntitiesAsync(); } searchByTerm(query: string): Promise { @@ -53,4 +51,10 @@ export abstract class AbstractEntitySearchService< // populate the entities to get the whole entity return this.entityProvider.provideByIds(res.map((r) => r.id)); } + + private indexInitialEntitiesAsync(): void { + this.entityProvider + .provideInitial() + .then((entities) => this.searchEngine.addAll(entities)); + } }