diff --git a/packages/config-svc-be/README.md b/packages/config-svc-be/README.md index b144f73..f179055 100644 --- a/packages/config-svc-be/README.md +++ b/packages/config-svc-be/README.md @@ -267,7 +267,35 @@ Open a new terminal and run the following command: curl -X POST "http://localhost:3202/platformRoles/basic-application/add_privileges" \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ ---data '["SECURITY_CREATE_RULE", "SECURITY_UPDATE_RULE", "SECURITY_GET_RULES", "SECURITY_GET_RULE", "SECURITY_GET_RULE_RULE_CONFIG", "SECURITY_DELETE_RULE", "SECURITY_DISABLE_RULE", "SECURITY_CREATE_RULE_CONFIG", "SECURITY_UPDATE_RULE_CONFIG", "SECURITY_GET_RULE_CONFIGS", "SECURITY_GET_RULE_CONFIG", "SECURITY_DELETE_RULE_CONFIG", "SECURITY_DISABLE_RULE_CONFIG", "SECURITY_CREATE_TYPOLOGY", "SECURITY_UPDATE_TYPOLOGY", "SECURITY_GET_TYPOLOGIES", "SECURITY_GET_TYPOLOGY", "SECURITY_DELETE_TYPOLOGY", "SECURITY_DISABLE_TYPOLOGY"]' +--data '[ + "SECURITY_CREATE_RULE", + "SECURITY_UPDATE_RULE", + "SECURITY_GET_RULES", + "SECURITY_GET_RULE", + "SECURITY_GET_RULE_RULE_CONFIG", + "SECURITY_DELETE_RULE", + "SECURITY_DISABLE_RULE", + "SECURITY_CREATE_RULE_CONFIG", + "SECURITY_UPDATE_RULE_CONFIG", + "SECURITY_GET_RULE_CONFIGS", + "SECURITY_GET_RULE_CONFIG", + "SECURITY_DELETE_RULE_CONFIG", + "SECURITY_DISABLE_RULE_CONFIG", + "SECURITY_CREATE_TYPOLOGY", + "SECURITY_UPDATE_TYPOLOGY", + "SECURITY_GET_TYPOLOGIES", + "SECURITY_GET_TYPOLOGY", + "SECURITY_DELETE_TYPOLOGY", + "SECURITY_DISABLE_TYPOLOGY", + "SECURITY_CREATE_NETWORK_MAP", + "SECURITY_UPDATE_NETWORK_MAP", + "SECURITY_GET_NETWORK_MAP", + "SECURITY_APPROVE_NETWORK_MAP", + "SECURITY_IMPORT_NETWORK_MAP", + "SECURITY_EXPORT_NETWORK_MAP", + "SECURITY_DISABLE_NETWORK_MAP", + "SECURITY_DELETE_NETWORK_MAP" +]' ``` - At this time you can test the config-svc-be on . diff --git a/packages/config-svc-be/src/app.module.ts b/packages/config-svc-be/src/app.module.ts index d29485a..a8b9016 100644 --- a/packages/config-svc-be/src/app.module.ts +++ b/packages/config-svc-be/src/app.module.ts @@ -12,6 +12,7 @@ import { ArangoDatabaseService } from './arango-database/arango-database.service import { PrivilegeService } from './privilege/privilege.service'; import { BandModule } from './band/band.module'; import { CaseModule } from './case/case.module'; +import { NetworkMapModule } from './network-map/network-map.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { CaseModule } from './case/case.module'; TypologyConfigModule, BandModule, CaseModule, + NetworkMapModule, ], controllers: [AppController], providers: [AppService, ArangoDatabaseService, PrivilegeService], diff --git a/packages/config-svc-be/src/arango-database/arango-database.service.ts b/packages/config-svc-be/src/arango-database/arango-database.service.ts index 25cb3ea..8313ece 100644 --- a/packages/config-svc-be/src/arango-database/arango-database.service.ts +++ b/packages/config-svc-be/src/arango-database/arango-database.service.ts @@ -11,6 +11,10 @@ import { TYPOLOGY_COLLECTION, typologySchema, } from '../typology/schema/typology.schema'; +import { + NETWORK_MAP_COLLECTION, + networkMapSchema, +} from '../network-map/schema/network-map.schema'; @Injectable() export class ArangoDatabaseService { @@ -64,6 +68,7 @@ export class ArangoDatabaseService { { name: RULE_COLLECTION, options: ruleSchema }, { name: RULE_CONFIG_COLLECTION, options: ruleConfigSchema }, { name: TYPOLOGY_COLLECTION, options: typologySchema }, + { name: NETWORK_MAP_COLLECTION, options: networkMapSchema }, ]; // Iterate over the collection names and create them if they don't exist diff --git a/packages/config-svc-be/src/network-map/dto/create-network-map.dto.ts b/packages/config-svc-be/src/network-map/dto/create-network-map.dto.ts new file mode 100644 index 0000000..11e04a9 --- /dev/null +++ b/packages/config-svc-be/src/network-map/dto/create-network-map.dto.ts @@ -0,0 +1,168 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + IsBoolean, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +class RuleConfigSummaryDto { + @ApiProperty({ + description: 'Database identifier for the rule configuration.', + example: 'rule_config/sample-uuid-4', + }) + @IsString() + readonly _id: string; + + @ApiProperty({ + description: 'Key within the database for the rule configuration.', + example: 'sample-uuid-4', + }) + @IsString() + readonly _key: string; + + @ApiProperty({ + description: 'Version of the rule configuration.', + example: '1.0.0', + }) + @IsString() + readonly cfg: string; +} + +class RuleSummaryDto { + @ApiProperty({ + description: 'Database identifier for the rule.', + example: 'rule/sample-uuid-3', + }) + @IsString() + readonly _id: string; + + @ApiProperty({ + description: 'Key within the database for the rule.', + example: 'sample-uuid-3', + }) + @IsString() + readonly _key: string; + + @ApiProperty({ description: 'Name of the rule.', example: 'Rule 3' }) + @IsString() + readonly name: string; + + @ApiProperty({ description: 'Version of the rule.', example: '1.0.0' }) + @IsString() + readonly cfg: string; +} + +class RulesWithConfigsDto { + @ApiProperty({ + description: 'Details of the rule', + type: RuleSummaryDto, + }) + @ValidateNested() + @Type(() => RuleSummaryDto) + readonly rule: RuleSummaryDto; + + @ApiProperty({ + description: 'Configurations associated with the rule.', + type: [RuleConfigSummaryDto], + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => RuleConfigSummaryDto) + readonly ruleConfigs?: RuleConfigSummaryDto[]; +} + +class TypologyDto { + @ApiProperty({ + description: 'Typology Processor ID with version.', + example: 'Typology Processor 1@1.0.0', + }) + @IsString() + readonly id: string; + + @ApiProperty({ + description: 'Name of the typology processor.', + example: 'Typology Processor 1', + }) + @IsString() + readonly name: string; + + @ApiProperty({ + description: 'Version of the configuration.', + example: '1.0.0', + }) + @IsString() + readonly cfg: string; + + @ApiProperty({ + description: 'Indicates if the typology processor is active.', + default: false, + }) + @IsBoolean() + readonly active: boolean; + + @ApiProperty({ + description: 'Array of rules with their respective configurations.', + type: [RulesWithConfigsDto], + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => RulesWithConfigsDto) + readonly rulesWithConfigs?: RulesWithConfigsDto[]; +} + +export class CreateEventDto { + @ApiProperty({ + description: 'Event ID from the events collection.', + example: 'event/sample-uuid-22', + }) + @IsString() + readonly eventId: string; + + @ApiProperty({ + description: 'Array of typologies associated with the event.', + type: [TypologyDto], + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => TypologyDto) + readonly typologies?: TypologyDto[]; +} + +export class CreateNetworkMapDto { + @ApiProperty({ + description: 'Flag to indicate if the network map is active.', + default: false, + }) + @IsBoolean() + readonly active: boolean; + + @ApiProperty({ + description: 'A x.y.z versioning for the network map', + example: '1.0.0', + }) + @IsString() + readonly cfg: string; + + @ApiProperty({ + description: 'Array of events associated with the network map.', + type: [CreateEventDto], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateEventDto) + readonly events: CreateEventDto[]; + + @ApiProperty({ + description: 'Source of the network map.', + example: 'user_created', + }) + @IsString() + @IsOptional() + readonly source?: string; +} diff --git a/packages/config-svc-be/src/network-map/dto/update-network-map.dto.ts b/packages/config-svc-be/src/network-map/dto/update-network-map.dto.ts new file mode 100644 index 0000000..097ffd4 --- /dev/null +++ b/packages/config-svc-be/src/network-map/dto/update-network-map.dto.ts @@ -0,0 +1,9 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateNetworkMapDto } from './create-network-map.dto'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateNetworkMapDto extends PartialType(CreateNetworkMapDto) { + @IsBoolean() + @IsOptional() + edited?: boolean; +} diff --git a/packages/config-svc-be/src/network-map/entities/network-map.entity.ts b/packages/config-svc-be/src/network-map/entities/network-map.entity.ts new file mode 100644 index 0000000..9cb948e --- /dev/null +++ b/packages/config-svc-be/src/network-map/entities/network-map.entity.ts @@ -0,0 +1,230 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { StateEnum } from '../../rule/schema/rule.schema'; + +class RuleConfigSummary { + @ApiProperty({ + description: 'Database identifier for the rule configuration.', + example: 'rule_config/sample-uuid-4', + }) + @IsString() + _id: string; + + @ApiProperty({ + description: 'Key within the database for the rule configuration.', + example: 'sample-uuid-4', + }) + @IsString() + _key: string; + + @ApiProperty({ + description: 'Version of the rule configuration.', + example: '1.0.0', + }) + @IsString() + cfg: string; +} + +class RuleSummary { + @ApiProperty({ + description: 'Database identifier for the rule.', + example: 'rule/sample-uuid-3', + }) + @IsString() + _id: string; + + @ApiProperty({ + description: 'Key within the database for the rule.', + example: 'sample-uuid-3', + }) + @IsString() + _key: string; + + @ApiProperty({ description: 'Name of the rule.', example: 'Rule 3' }) + @IsString() + name: string; + + @ApiProperty({ description: 'Version of the rule.', example: '1.0.0' }) + @IsString() + cfg: string; +} + +class RuleWithConfigs { + @ApiProperty({ description: 'Details of the rule', type: RuleSummary }) + @ValidateNested() + @Type(() => RuleSummary) + rule: RuleSummary; + + @ApiProperty({ + description: 'Configurations associated with the rule.', + type: [RuleConfigSummary], + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => RuleConfigSummary) + ruleConfigs?: RuleConfigSummary[]; +} + +class Typology { + @ApiProperty({ + description: 'Typology Processor ID with version.', + example: 'Typology Processor 1@1.0.0', + }) + @IsString() + id: string; + + @ApiProperty({ + description: 'Name of the typology processor.', + example: 'Typology Processor 1', + }) + @IsString() + name: string; + + @ApiProperty({ + description: 'Version of the configuration.', + example: '1.0.0', + }) + @IsString() + cfg: string; + + @ApiProperty({ + description: 'Indicates if the typology processor is active.', + default: false, + }) + @IsBoolean() + active: boolean; + + @ApiProperty({ + description: 'Array of rules with their respective configurations.', + type: [RuleWithConfigs], + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => RuleWithConfigs) + rulesWithConfigs?: RuleWithConfigs[]; +} + +class Event { + @ApiProperty({ + description: 'Event ID from the events collection.', + example: 'event/sample-uuid-22', + }) + @IsString() + eventId: string; + + @ApiProperty({ + description: 'Array of typologies associated with the event.', + type: [Typology], + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => Typology) + typologies?: Typology[]; +} + +export class NetworkMap { + @ApiProperty() + @IsString() + _key: string; + + @ApiProperty({ + description: 'Flag to indicate if the network map is active.', + default: false, + }) + @IsBoolean() + active: boolean; + + @ApiProperty({ + description: 'A x.y.z versioning for the network map', + example: '1.0.0', + }) + @IsString() + cfg: string; + + @ApiProperty({ + description: 'State of the network map.', + example: '01_DRAFT', + }) + @IsEnum(StateEnum) + state: StateEnum; + + @ApiProperty({ + description: 'Array of events associated with the network map.', + type: [Event], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Event) + events: Event[]; + + @ApiProperty() + @IsString() + createdAt?: string; + + @ApiProperty() + @IsString() + updatedAt?: string; + + @ApiProperty() + @IsString() + @IsOptional() + updatedBy?: string; + + @ApiProperty({ + description: 'Identifier for the owner of the network map.', + example: 'user@example.com', + }) + @IsString() + ownerId: string; + + @ApiProperty({ + description: 'Identifier for the approver of the network map.', + example: 'user@example.com', + }) + @IsString() + @IsOptional() + approverId?: string; + + @ApiProperty({ + description: 'Identifier for the network map that originated this one.', + example: 'network_map/sample-uuid-3', + }) + @IsOptional() + @IsString() + originatedId?: string | null; + + @ApiProperty({ + description: 'Flag to indicate if the network map has been edited.', + default: false, + }) + @IsBoolean() + @IsOptional() + edited?: boolean; + + @ApiProperty({ + description: 'Identifier for the existing imported network maps.', + }) + @IsNumber() + @IsOptional() + referenceId?: number | null; + + @ApiProperty({ + description: 'Source of the network map.', + example: 'user_created', + }) + @IsString() + @IsOptional() + source?: string; +} diff --git a/packages/config-svc-be/src/network-map/network-map.controller.spec.ts b/packages/config-svc-be/src/network-map/network-map.controller.spec.ts new file mode 100644 index 0000000..5f13d6f --- /dev/null +++ b/packages/config-svc-be/src/network-map/network-map.controller.spec.ts @@ -0,0 +1,168 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NetworkMapController } from './network-map.controller'; +import { NetworkMapService } from './network-map.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { PrivilegeService } from '../privilege/privilege.service'; +import { CreateNetworkMapDto } from './dto/create-network-map.dto'; +import { NetworkMap } from './entities/network-map.entity'; +import { StateEnum } from '../rule/schema/rule.schema'; +import { UpdateNetworkMapDto } from './dto/update-network-map.dto'; + +describe('NetworkMapController', () => { + let controller: NetworkMapController; + let service: NetworkMapService; + + const mockNetworkMapService = { + create: jest.fn((dto, req) => ({ + ...dto, + ownerId: req.user.username, + _key: 'generated-id', + state: '01_DRAFT', + })), + findOne: jest.fn((id) => ({ + _key: 'test-network-map-id', + active: true, + cfg: '1.0.0', + events: [ + { + eventId: 'event/test-event-id', + typologies: [], + }, + ], + ownerId: 'user@example.com', + state: '01_DRAFT', + createdAt: '2021-08-02T14:00:00Z', + updatedAt: '2021-08-02T14:00:00Z', + })), + duplicateNetworkMap: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [NetworkMapController], + providers: [ + { provide: NetworkMapService, useValue: mockNetworkMapService }, + { + provide: JwtAuthGuard, + useValue: { canActivate: jest.fn(() => true) }, + }, + { + provide: RolesGuard, + useValue: { canActivate: jest.fn(() => true) }, + }, + { provide: PrivilegeService, useValue: {} }, + ], + }).compile(); + + controller = module.get(NetworkMapController); + service = module.get(NetworkMapService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('create', () => { + it('should create a new network map', async () => { + const createNetworkMapDto: CreateNetworkMapDto = { + active: true, + cfg: '1.0.0', + events: [ + { + eventId: 'event/test-event-id', + typologies: [], + }, + ], + }; + + const req = { + user: { + clientId: 'test-client-id', + username: 'test-user@example.com', + participantRoleIds: undefined, + platformRoleIds: [1], + }, + }; + + const result = await controller.create(createNetworkMapDto, req as any); + expect(service.create).toHaveBeenCalledWith(createNetworkMapDto, req); + expect(result).toEqual({ + ...createNetworkMapDto, + ownerId: 'test-user@example.com', + _key: 'generated-id', + state: '01_DRAFT', + }); + }); + }); + + describe('findOne', () => { + it('should retrieve a network map by ID', async () => { + const id = 'test-network-map-id'; + const expectedNetworkMap: NetworkMap = { + _key: 'test-network-map-id', + active: true, + cfg: '1.0.0', + events: [ + { + eventId: 'event/test-event-id', + typologies: [], + }, + ], + ownerId: 'user@example.com', + state: StateEnum['01_DRAFT'], + createdAt: '2021-08-02T14:00:00Z', + updatedAt: '2021-08-02T14:00:00Z', + }; + + // Act + const result = await controller.findOne(id); + + // Assert + expect(service.findOne).toHaveBeenCalledWith(id); + expect(result).toEqual(expectedNetworkMap); + }); + }); + + describe('update', () => { + it('should update a network map by ID', async () => { + const id = 'test-network-map-id'; + const updateNetworkMapDto: UpdateNetworkMapDto = { + active: true, + cfg: '2.0.0', + }; + const req = { user: { username: 'user@example.com' } }; + + const expectedNetworkMap: NetworkMap = { + _key: 'test-network-map-id', + active: true, + cfg: '2.0.0', + events: [ + { + eventId: 'event/test-event-id', + typologies: [], + }, + ], + ownerId: 'user@example.com', + state: StateEnum['01_DRAFT'], + createdAt: '2021-08-02T14:00:00Z', + updatedAt: '2021-08-02T14:00:00Z', + }; + + mockNetworkMapService.duplicateNetworkMap.mockResolvedValue( + expectedNetworkMap, + ); + + // Act + const result = await controller.update(id, updateNetworkMapDto, req); + + // Assert + expect(mockNetworkMapService.duplicateNetworkMap).toHaveBeenCalledWith( + id, + updateNetworkMapDto, + req, + ); + expect(result).toEqual(expectedNetworkMap); + }); + }); +}); diff --git a/packages/config-svc-be/src/network-map/network-map.controller.ts b/packages/config-svc-be/src/network-map/network-map.controller.ts new file mode 100644 index 0000000..3952f8e --- /dev/null +++ b/packages/config-svc-be/src/network-map/network-map.controller.ts @@ -0,0 +1,78 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + UseGuards, + Request, +} from '@nestjs/common'; +import { NetworkMapService } from './network-map.service'; +import { CreateNetworkMapDto } from './dto/create-network-map.dto'; +// import { UpdateNetworkMapDto } from './dto/update-network-map.dto'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { NetworkMapPrivilege } from './privilege.constant'; +import { NetworkMap } from './entities/network-map.entity'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UpdateNetworkMapDto } from './dto/update-network-map.dto'; + +@ApiTags('Network Map') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('network-map') +export class NetworkMapController { + constructor(private readonly networkMapService: NetworkMapService) {} + + @Post() + @Roles(NetworkMapPrivilege.CREATE_NETWORK_MAP) + @ApiOperation({ summary: 'Create a new network map' }) + @ApiCreatedResponse({ + description: 'The network map has been successfully created.', + type: NetworkMap, + }) + create( + @Body() createNetworkMapDto: CreateNetworkMapDto, + @Request() req, + ): Promise { + return this.networkMapService.create(createNetworkMapDto, req); + } + + @Get(':id') + @Roles(NetworkMapPrivilege.GET_NETWORK_MAP) + @ApiOperation({ summary: 'Retrieve a network map by ID' }) + @ApiOkResponse({ + description: 'The network map has been successfully retrieved.', + type: NetworkMap, + }) + findOne(@Param('id') id: string): Promise { + return this.networkMapService.findOne(id); + } + + @Patch(':id') + @Roles(NetworkMapPrivilege.UPDATE_NETWORK_MAP) + @ApiOperation({ summary: 'Update a network map by ID' }) + @ApiOkResponse({ + description: 'The network map has been successfully updated.', + type: NetworkMap, + }) + update( + @Param('id') id: string, + @Body() updateNetworkMapDto: UpdateNetworkMapDto, + @Request() req, + ): Promise { + return this.networkMapService.duplicateNetworkMap( + id, + updateNetworkMapDto, + req, + ); + } +} diff --git a/packages/config-svc-be/src/network-map/network-map.module.ts b/packages/config-svc-be/src/network-map/network-map.module.ts new file mode 100644 index 0000000..18867b7 --- /dev/null +++ b/packages/config-svc-be/src/network-map/network-map.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { NetworkMapController } from './network-map.controller'; +import { NetworkMapService } from './network-map.service'; +import { ArangoDatabaseService } from '../arango-database/arango-database.service'; +import { PrivilegeService } from '../privilege/privilege.service'; + +@Module({ + controllers: [NetworkMapController], + providers: [NetworkMapService, ArangoDatabaseService, PrivilegeService], +}) +export class NetworkMapModule {} diff --git a/packages/config-svc-be/src/network-map/network-map.service.spec.ts b/packages/config-svc-be/src/network-map/network-map.service.spec.ts new file mode 100644 index 0000000..ae4ae17 --- /dev/null +++ b/packages/config-svc-be/src/network-map/network-map.service.spec.ts @@ -0,0 +1,167 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NetworkMapService } from './network-map.service'; +import { ArangoDatabaseService } from '../arango-database/arango-database.service'; +import { NetworkMap } from './entities/network-map.entity'; +import { StateEnum } from '../rule/schema/rule.schema'; +import { CreateNetworkMapDto } from './dto/create-network-map.dto'; +import { UpdateNetworkMapDto } from './dto/update-network-map.dto'; + +describe('NetworkMapService', () => { + let service: NetworkMapService; + + const mockCollection = { + save: jest.fn(), + document: jest.fn(), + update: jest.fn(), + }; + + const mockCursor = { + all: jest.fn().mockResolvedValue([]), + }; + + const dbMock = { + getDatabase: jest.fn(() => ({ + collection: jest.fn(() => mockCollection), + query: jest.fn(() => Promise.resolve(mockCursor)), + })), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NetworkMapService, + { provide: ArangoDatabaseService, useValue: dbMock }, + ], + }).compile(); + + service = module.get(NetworkMapService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create a new network map and return', async () => { + const createNetworkMapDto: CreateNetworkMapDto = { + active: true, + cfg: '1.0.0', + events: [ + { + eventId: 'event/test-event-id', + typologies: [], + }, + ], + }; + const req = { user: { username: 'user1' } }; + + const expectedSaveResult = { + ...createNetworkMapDto, + ownerId: 'user1', + _key: expect.any(String), + state: StateEnum['01_DRAFT'], + }; + + dbMock + .getDatabase() + .collection() + .save.mockResolvedValue({ new: expectedSaveResult }); + + // Act + const result = await service.create(createNetworkMapDto, req as any); + + // Assert + expect(result).toEqual(expectedSaveResult); + }); + }); + + describe('findOne', () => { + it('should return a network map by ID', async () => { + const id = 'test-network-map-id'; + const expectedNetworkMap: NetworkMap = { + _key: 'test-network-map-id', + active: true, + cfg: '1.0.0', + events: [ + { + eventId: 'event/test-event-id', + typologies: [], + }, + ], + ownerId: 'user@example.com', + state: StateEnum['01_DRAFT'], + createdAt: '2021-08-02T14:00:00Z', + updatedAt: '2021-08-02T14:00:00Z', + }; + + // Mock the collection save method + dbMock + .getDatabase() + .collection() + .document.mockResolvedValue(expectedNetworkMap); + + // Act + const result = await service.findOne(id); + + // Assert + expect(result).toEqual(expectedNetworkMap); + }); + }); + + describe('duplicateNetworkMap', () => { + it('should duplicate a network map by ID', async () => { + const id: string = 'test-network-map-id'; + const updateNetworkMapDto: UpdateNetworkMapDto = { + active: true, + cfg: '2.0.0', + }; + const req = { user: { username: 'user_2@example.com' } }; + + const expectedNetworkMap: NetworkMap = { + _key: 'new-test-network-map-id', + active: true, + cfg: '2.0.0', + events: [ + { + eventId: 'event/test-event-id', + typologies: [], + }, + ], + ownerId: 'user_2@example.com', + updatedBy: 'user_2@example.com', + state: StateEnum['01_DRAFT'], + createdAt: '2021-08-02T14:00:00Z', + updatedAt: '2021-08-02T14:00:00Z', + }; + + // Mock the findOne method + dbMock.getDatabase().collection().document.mockResolvedValue(true); + + // Mock the query method + dbMock.getDatabase().query.mockResolvedValue(mockCursor); + + // Mock the save method + dbMock + .getDatabase() + .collection() + .save.mockResolvedValue({ new: expectedNetworkMap }); + + // Mock the collection update method + dbMock.getDatabase().collection().update.mockResolvedValue(true); + + // Act + const result = await service.duplicateNetworkMap( + id, + updateNetworkMapDto, + req as any, + ); + + // Assert + expect(result).toEqual(expectedNetworkMap); + }); + }); +}); diff --git a/packages/config-svc-be/src/network-map/network-map.service.ts b/packages/config-svc-be/src/network-map/network-map.service.ts new file mode 100644 index 0000000..1426b29 --- /dev/null +++ b/packages/config-svc-be/src/network-map/network-map.service.ts @@ -0,0 +1,177 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { ArangoDatabaseService } from '../arango-database/arango-database.service'; +import { CreateNetworkMapDto } from './dto/create-network-map.dto'; +import { NetworkMap } from './entities/network-map.entity'; +import { NETWORK_MAP_COLLECTION } from './schema/network-map.schema'; +import { v4 as uuidv4 } from 'uuid'; +import { StateEnum } from '../rule/schema/rule.schema'; +import { UpdateNetworkMapDto } from './dto/update-network-map.dto'; + +@Injectable() +export class NetworkMapService { + constructor(private readonly arangoDatabaseService: ArangoDatabaseService) {} + + async create( + createNetworkMapDto: CreateNetworkMapDto, + req: Request, + ): Promise { + const db = this.arangoDatabaseService.getDatabase(); + const collection = db.collection(NETWORK_MAP_COLLECTION); + + // check if the username is present in the request + if (!req['user'].username) { + throw new BadRequestException( + 'Failed to create network map: username is missing', + ); + } + + const newNetworkMap: NetworkMap = { + ...createNetworkMapDto, + _key: uuidv4(), + state: StateEnum['01_DRAFT'], + ownerId: req['user'].username, + }; + + try { + const result = await collection.save(newNetworkMap, { returnNew: true }); + return result.new; + } catch (error) { + if (error.isArangoError) { + switch (error.errorNum) { + case 1620: + throw new BadRequestException( + `Failed to create network map: ${error.message}`, + ); + default: + throw new InternalServerErrorException( + `An unexpected database error occurred: ${error.message}`, + ); + } + } else { + throw new InternalServerErrorException( + `An unexpected server error occurred: ${error.message}`, + ); + } + } + } + + async findOne(id: string): Promise { + const db = this.arangoDatabaseService.getDatabase(); + + try { + return await db.collection(NETWORK_MAP_COLLECTION).document(id); + } catch (error) { + throw new NotFoundException(`No network map found with ID ${id}.`); + } + } + + async duplicateNetworkMap( + id: string, + updateNetworkMapDto: UpdateNetworkMapDto, + req: Request, + ): Promise { + const db = this.arangoDatabaseService.getDatabase(); + const collection = db.collection(NETWORK_MAP_COLLECTION); + + // Fetch the existing network map + const existingNetworkMap = await this.findOne(id); + if (!existingNetworkMap) { + throw new NotFoundException(`Network map with ID ${id} not found.`); + } + + // Check for existing network map with the same originatedId + const cursor = await db.query( + `FOR network_map IN @@collection + FILTER network_map.originatedId == @id + LIMIT 1 + RETURN network_map`, + { '@collection': NETWORK_MAP_COLLECTION, id: id }, + ); + const childNetworkMap = await cursor.all(); + + if (childNetworkMap.length > 0) { + throw new BadRequestException( + `Could not update network map with ID ${id}: a network map already updated.`, + ); + } + + // Prepare the new typology + const { active, cfg, events } = existingNetworkMap; + + const newNetworkMap: NetworkMap = { + active, + cfg, + events, + ...updateNetworkMapDto, + _key: uuidv4(), + ownerId: req['user'].username, + updatedBy: req['user'].username, + state: StateEnum['01_DRAFT'], + originatedId: id, + }; + + // Save the new network map + try { + const result = await collection.save(newNetworkMap, { returnNew: true }); + await this.update(id, { edited: true }); + return result.new; + } catch (error) { + if (error.isArangoError) { + switch (error.errorNum) { + case 1620: + throw new BadRequestException( + `Failed to create network map: ${error.message}`, + ); + default: + throw new InternalServerErrorException( + `An unexpected database error occurred: ${error.message}`, + ); + } + } else { + throw new InternalServerErrorException( + `An unexpected server error occurred: ${error.message}`, + ); + } + } + } + + async update( + id: string, + updateNetworkMapDto: UpdateNetworkMapDto, + ): Promise { + const db = this.arangoDatabaseService.getDatabase(); + const collection = db.collection(NETWORK_MAP_COLLECTION); + + // Check if the network map exists + await this.findOne(id); + + try { + const result = await collection.update(id, updateNetworkMapDto, { + returnNew: true, + }); + return result.new; + } catch (error) { + if (error.isArangoError) { + switch (error.errorNum) { + case 1620: + throw new BadRequestException( + `Failed to update network map: ${error.message}`, + ); + default: + throw new InternalServerErrorException( + `An unexpected database error occurred: ${error.message}`, + ); + } + } else { + throw new InternalServerErrorException( + `An unexpected server error occurred: ${error.message}`, + ); + } + } + } +} diff --git a/packages/config-svc-be/src/network-map/privilege.constant.ts b/packages/config-svc-be/src/network-map/privilege.constant.ts new file mode 100644 index 0000000..9585fa5 --- /dev/null +++ b/packages/config-svc-be/src/network-map/privilege.constant.ts @@ -0,0 +1,55 @@ +import { PrivilegeType } from '../privilege/privilege.service'; + +export enum NetworkMapPrivilege { + CREATE_NETWORK_MAP = 'SECURITY_CREATE_NETWORK_MAP', + UPDATE_NETWORK_MAP = 'SECURITY_UPDATE_NETWORK_MAP', + GET_NETWORK_MAP = 'SECURITY_GET_NETWORK_MAP', + APPROVE_NETWORK_MAP = 'SECURITY_APPROVE_NETWORK_MAP', + IMPORT_NETWORK_MAP = 'SECURITY_IMPORT_NETWORK_MAP', + EXPORT_NETWORK_MAP = 'SECURITY_EXPORT_NETWORK_MAP', + DISABLE_NETWORK_MAP = 'SECURITY_DISABLE_NETWORK_MAP', + DELETE_NETWORK_MAP = 'SECURITY_DELETE_NETWORK_MAP', +} + +export const NetworkMapPrivilegesDefinition: PrivilegeType[] = [ + { + privId: NetworkMapPrivilege.CREATE_NETWORK_MAP, + labelName: 'Network Map Create', + description: 'Allows creating a network map', + }, + { + privId: NetworkMapPrivilege.UPDATE_NETWORK_MAP, + labelName: 'Network Map Update', + description: 'Allows updating a network map', + }, + { + privId: NetworkMapPrivilege.GET_NETWORK_MAP, + labelName: 'Network Map Get', + description: 'Allows returning a specific network map', + }, + { + privId: NetworkMapPrivilege.APPROVE_NETWORK_MAP, + labelName: 'Network Map Approve', + description: 'Allows approving a network map', + }, + { + privId: NetworkMapPrivilege.IMPORT_NETWORK_MAP, + labelName: 'Network Map Import', + description: 'Allows importing network maps', + }, + { + privId: NetworkMapPrivilege.EXPORT_NETWORK_MAP, + labelName: 'Network Map Export', + description: 'Allows exporting network maps', + }, + { + privId: NetworkMapPrivilege.DISABLE_NETWORK_MAP, + labelName: 'Network Map Disable', + description: 'Allows disabling a specific network map', + }, + { + privId: NetworkMapPrivilege.DELETE_NETWORK_MAP, + labelName: 'Network Map Delete', + description: 'Allows deleting a specific network map', + }, +]; diff --git a/packages/config-svc-be/src/network-map/schema/network-map.schema.ts b/packages/config-svc-be/src/network-map/schema/network-map.schema.ts new file mode 100644 index 0000000..bbaf082 --- /dev/null +++ b/packages/config-svc-be/src/network-map/schema/network-map.schema.ts @@ -0,0 +1,96 @@ +import { SchemaOptions } from 'arangojs/collection'; +import { StateEnum } from '../../rule/schema/rule.schema'; + +export const NETWORK_MAP_COLLECTION = 'network_map'; + +export const networkMapSchema: { schema: SchemaOptions; computedValues: any } = + { + schema: { + rule: { + level: 'moderate', + properties: { + _key: { type: 'string' }, + active: { type: 'boolean' }, + cfg: { type: 'string' }, + state: { + type: 'string', + enum: Object.values(StateEnum), + default: StateEnum['01_DRAFT'], + }, + events: { + type: 'array', + items: { + type: 'object', + properties: { + eventId: { type: 'string' }, + typologies: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + cfg: { type: 'string' }, + active: { type: 'boolean' }, + rulesWithConfigs: { + type: 'array', + items: { + type: 'object', + properties: { + rule: { + type: 'object', + properties: { + _id: { type: 'string' }, + _key: { type: 'string' }, + name: { type: 'string' }, + cfg: { type: 'string' }, + }, + }, + ruleConfigs: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + _key: { type: 'string' }, + cfg: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + updatedBy: { type: 'string' }, + approverId: { type: 'string' }, + ownerId: { type: 'string' }, + originatedId: { type: ['null', 'string'], default: null }, + edited: { type: 'boolean', default: false }, + referenceId: { type: ['null', 'number'], default: null }, + source: { type: 'string' }, + }, + additionalProperties: false, + }, + }, + computedValues: [ + { + name: 'createdAt', + expression: 'RETURN DATE_ISO8601(DATE_NOW())', + computeOn: ['insert'], + overwrite: false, + }, + { + name: 'updatedAt', + expression: 'RETURN DATE_ISO8601(DATE_NOW())', + computeOn: ['insert', 'update'], + overwrite: true, + }, + ], + }; diff --git a/packages/config-svc-be/src/privilege/privilege.module.ts b/packages/config-svc-be/src/privilege/privilege.module.ts index 270b0bd..b2c66d2 100644 --- a/packages/config-svc-be/src/privilege/privilege.module.ts +++ b/packages/config-svc-be/src/privilege/privilege.module.ts @@ -3,6 +3,7 @@ import { PrivilegeService } from './privilege.service'; import { RulePrivilegesDefinition } from '../rule/privilege.constant'; import { RuleConfigPrivilegesDefinition } from '../rule-config/privilege.constant'; import { TypologyPrivilegesDefinition } from '../typology/privilege.constant'; +import { NetworkMapPrivilegesDefinition } from '../network-map/privilege.constant'; @Module({ providers: [PrivilegeService], exports: [PrivilegeService], @@ -18,6 +19,7 @@ export class PrivilegeModule { ...RulePrivilegesDefinition, ...RuleConfigPrivilegesDefinition, ...TypologyPrivilegesDefinition, + ...NetworkMapPrivilegesDefinition, ]); await authorizationClient.bootstrap(true); await authorizationClient.init(); diff --git a/packages/config-svc-be/src/rule-config/entities/rule-config.entity.ts b/packages/config-svc-be/src/rule-config/entities/rule-config.entity.ts index 7bf6241..fa6bd96 100644 --- a/packages/config-svc-be/src/rule-config/entities/rule-config.entity.ts +++ b/packages/config-svc-be/src/rule-config/entities/rule-config.entity.ts @@ -158,11 +158,11 @@ export class RuleConfig { @ApiProperty() @IsString() - _id: string; + _id?: string; @ApiProperty() @IsString() - _rev: string; + _rev?: string; @ApiProperty() @IsString() @@ -178,19 +178,23 @@ export class RuleConfig { @ApiProperty() @IsString() - createdAt: string; + @IsOptional() + createdAt?: string; @ApiProperty() @IsString() - updatedAt: string; + @IsOptional() + updatedAt?: string; @ApiProperty() @IsString() - updatedBy: string; + @IsOptional() + updatedBy?: string; @ApiProperty() @IsString() - approverId: string; + @IsOptional() + approverId?: string; @ApiProperty() @IsString() @@ -200,6 +204,10 @@ export class RuleConfig { @IsString() ruleId: string; + @ApiProperty() + @IsString() + originatedID: string; + @ApiProperty() @IsOptional() @IsObject() diff --git a/packages/config-svc-be/src/rule-config/rule-config.controller.ts b/packages/config-svc-be/src/rule-config/rule-config.controller.ts index c29ae65..9ec2053 100644 --- a/packages/config-svc-be/src/rule-config/rule-config.controller.ts +++ b/packages/config-svc-be/src/rule-config/rule-config.controller.ts @@ -109,7 +109,7 @@ export class RuleConfigController { } @ApiExcludeEndpoint() - @Post(':id/disable') + @Patch(':id/disable') @Roles(RuleConfigPrivilege.DISABLE_RULE_CONFIG) @ApiOperation({ summary: 'Disable a rule configuration' }) @ApiOkResponse({ diff --git a/packages/config-svc-be/src/rule-config/rule-config.service.spec.ts b/packages/config-svc-be/src/rule-config/rule-config.service.spec.ts index 21ae021..114c564 100644 --- a/packages/config-svc-be/src/rule-config/rule-config.service.spec.ts +++ b/packages/config-svc-be/src/rule-config/rule-config.service.spec.ts @@ -115,7 +115,7 @@ describe('RuleConfigService', () => { await expect(service.findOne('123')).rejects.toThrow(NotFoundException); }); - it('should update and return the rule configuration', async () => { + it('should update the rule configuration', async () => { dbMock.getDatabase().collection().documentExists.mockResolvedValue(true); dbMock .getDatabase() diff --git a/packages/config-svc-be/src/rule-config/rule-config.service.ts b/packages/config-svc-be/src/rule-config/rule-config.service.ts index ffea36a..3361d0c 100644 --- a/packages/config-svc-be/src/rule-config/rule-config.service.ts +++ b/packages/config-svc-be/src/rule-config/rule-config.service.ts @@ -14,7 +14,6 @@ import { RuleConfig } from './entities/rule-config.entity'; import { v4 as uuidv4 } from 'uuid'; import { StateEnum } from '../rule/schema/rule.schema'; import { RuleService } from '../rule/rule.service'; -import { aql } from 'arangojs'; import { Rule } from '../rule/entities/rule.entity'; @Injectable() @@ -111,18 +110,19 @@ export class RuleConfigService { // Fetch the existing rule configuration to duplicate const existingRuleConfig = await this.findOne(id); if (!existingRuleConfig) { - throw new BadRequestException( - `No rule configuration found with ID ${id}.`, - ); + throw new NotFoundException(`No rule configuration found with ID ${id}.`); } - // Use AQL to check for existing rule configurations with the same originatedID - const cursor = await db.query(aql` - FOR doc IN ${collection} - FILTER doc.originatedID == ${id} - RETURN doc - `); - const childRuleConfigs = await cursor.all(); + // Check for existing rule configurations with the same originatedID + const cursor = await db.query( + ` + FOR ruleConfig IN @@collection + FILTER ruleConfig.originatedID == @id + RETURN ruleConfig + `, + { '@collection': RULE_CONFIG_COLLECTION, id: id }, + ); + const childRuleConfigs: RuleConfig[] = await cursor.all(); // If a child configuration already exists, throw an exception if (childRuleConfigs.length > 0) { @@ -132,8 +132,9 @@ export class RuleConfigService { } // Prepare the new rule configuration data - const { cfg, desc, ruleId, config, ...rest } = existingRuleConfig; - const newRuleConfig = { + const { cfg, desc, ruleId, config } = existingRuleConfig; + + const newRuleConfig: RuleConfig = { cfg, desc, ruleId, @@ -146,13 +147,13 @@ export class RuleConfigService { updatedBy: req['user'].username, }; - // Attempt to save the new rule configuration in the database + // Save the new rule configuration in the database try { - const ruleConfig: RuleConfig = ( - await collection.save(newRuleConfig) - ); + const ruleConfig = await collection.save(newRuleConfig, { + returnNew: true, + }); await this.update(id, { edited: true }); - return this.findOne(ruleConfig._id); + return ruleConfig.new; } catch (e) { throw new BadRequestException( `Failed to duplicate rule configuration: ${e.message}`, diff --git a/packages/config-svc-be/src/rule/entities/rule.entity.ts b/packages/config-svc-be/src/rule/entities/rule.entity.ts index 5b3a87d..0f8abea 100644 --- a/packages/config-svc-be/src/rule/entities/rule.entity.ts +++ b/packages/config-svc-be/src/rule/entities/rule.entity.ts @@ -18,11 +18,13 @@ export class Rule { @ApiProperty({ example: 'rule/123' }) @IsString() - _id: string; + @IsOptional() + _id?: string; @ApiProperty({ example: 'some-revision' }) @IsString() - _rev: string; + @IsOptional() + _rev?: string; @ApiProperty({ example: '1.0.0', description: 'Configuration version' }) @IsString() @@ -33,6 +35,7 @@ export class Rule { description: 'Type of data the rule deals with', }) @IsEnum(DataTypeEnum) + @IsOptional() dataType?: DataTypeEnum; @ApiProperty({ example: 'rule-001' }) @@ -53,19 +56,23 @@ export class Rule { @ApiProperty({ type: Date }) @IsDate() - createdAt: Date; + @IsOptional() + createdAt?: Date; @ApiProperty({ type: Date }) @IsDate() - updatedAt: Date; + @IsOptional() + updatedAt?: Date; @ApiProperty({ example: 'user@example.com' }) @IsString() - updatedBy: string; + @IsOptional() + updatedBy?: string; @ApiProperty({ example: 'user@example.com' }) @IsString() - approverId: string; + @IsOptional() + approverId?: string; @ApiProperty({ example: 'sample-uuid', required: false }) @IsOptional() @@ -77,11 +84,13 @@ export class Rule { description: 'Whether the rule has been edited', }) @IsBoolean() - edited: boolean; + @IsOptional() + edited?: boolean; @ApiProperty({ enum: SourceEnum, description: 'Where the rule come from' }) @IsEnum(SourceEnum) - source: SourceEnum; + @IsOptional() + source?: SourceEnum; } export class RuleWithConfig extends Rule { diff --git a/packages/config-svc-be/src/rule/rule.service.ts b/packages/config-svc-be/src/rule/rule.service.ts index a375366..334fe25 100644 --- a/packages/config-svc-be/src/rule/rule.service.ts +++ b/packages/config-svc-be/src/rule/rule.service.ts @@ -170,7 +170,7 @@ export class RuleService { const db = this.arangoDatabaseService.getDatabase(); const rule = await db .collection(RULE_COLLECTION) - .update(id, updateRuleDto); + .update(id, updateRuleDto, { returnNew: true }); return rule.new; } catch (error) { throw new BadRequestException(error.message); diff --git a/packages/config-svc-be/src/typology/dto/create-typology.dto.ts b/packages/config-svc-be/src/typology/dto/create-typology.dto.ts index 270e02f..e00e68b 100644 --- a/packages/config-svc-be/src/typology/dto/create-typology.dto.ts +++ b/packages/config-svc-be/src/typology/dto/create-typology.dto.ts @@ -1,14 +1,21 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsArray, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; -class RulesRuleConfigsDto { +export class RulesRuleConfigsDto { @ApiProperty({ description: 'Identifier of the rule document from the rule collection.', example: 'rule/sample-uuid-3', }) @IsString() - readonly ruleId: string; + @IsOptional() + readonly ruleId?: string; @ApiProperty({ description: @@ -16,8 +23,9 @@ class RulesRuleConfigsDto { example: ['rule_config/sample-uuid-4', 'rule_config/sample-uuid-5'], }) @IsArray() + @IsOptional() @IsString({ each: true }) - readonly ruleConfigId: string[]; + readonly ruleConfigId?: string[]; } export class CreateTypologyDto { diff --git a/packages/config-svc-be/src/typology/dto/update-typology.dto.ts b/packages/config-svc-be/src/typology/dto/update-typology.dto.ts index efe0958..5ca96be 100644 --- a/packages/config-svc-be/src/typology/dto/update-typology.dto.ts +++ b/packages/config-svc-be/src/typology/dto/update-typology.dto.ts @@ -1,4 +1,76 @@ import { PartialType } from '@nestjs/mapped-types'; -import { CreateTypologyDto } from './create-typology.dto'; +import { CreateTypologyDto, RulesRuleConfigsDto } from './create-typology.dto'; +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + IsBoolean, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; -export class UpdateTypologyDto extends PartialType(CreateTypologyDto) {} +export class UpdateTypologyDto extends PartialType(CreateTypologyDto) { + @ApiProperty({ + description: 'Name of the typology.', + example: 'Identity theft I', + }) + @IsString() + @IsOptional() + readonly name?: string; + + @ApiProperty({ + description: 'Semantic version of the typology.', + example: '1.0.0', + }) + @IsString() + @IsOptional() + readonly cfg?: string; + + @ApiProperty({ + description: 'Description of the typology.', + example: 'Identity theft I', + }) + @IsString() + @IsOptional() + readonly desc?: string; + + @ApiProperty({ + description: + 'Array of typology category identifiers that the typology belongs to.', + example: [ + 'typology_category/sample-uuid-7', + 'typology_category/sample-uuid-10', + ], + }) + @IsArray() + @IsString({ each: true }) + @IsOptional() + readonly typologyCategoryUUID?: string[]; + + @ApiProperty({ + description: + 'Array of objects linking this typology to specific rules and their configurations. Each object contains a ruleId and an array of associated ruleConfigIds.', + type: [RulesRuleConfigsDto], + }) + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => RulesRuleConfigsDto) + readonly rules_rule_configs?: RulesRuleConfigsDto[]; + + @ApiProperty({ + description: + 'The identifier for the existing imported typologies. The Typology processor ID in the source system.', + example: null, + type: Number, + }) + @IsNumber() + @IsOptional() + readonly referenceId?: number; + + @IsBoolean() + @IsOptional() + edited?: boolean; +} diff --git a/packages/config-svc-be/src/typology/entities/typology.entity.ts b/packages/config-svc-be/src/typology/entities/typology.entity.ts index 65f75b2..21f252f 100644 --- a/packages/config-svc-be/src/typology/entities/typology.entity.ts +++ b/packages/config-svc-be/src/typology/entities/typology.entity.ts @@ -140,12 +140,10 @@ class RuleConfigsSummary { @IsString() cfg: string; - @ApiProperty( - { - description: 'Identifier of the rule document from the rule collection.', - example: 'rule/sample-uuid-3', - } - ) + @ApiProperty({ + description: 'Identifier of the rule document from the rule collection.', + example: 'rule/sample-uuid-3', + }) @IsString() ruleId: string; diff --git a/packages/config-svc-be/src/typology/typology.controller.spec.ts b/packages/config-svc-be/src/typology/typology.controller.spec.ts index 85429fe..d27d31d 100644 --- a/packages/config-svc-be/src/typology/typology.controller.spec.ts +++ b/packages/config-svc-be/src/typology/typology.controller.spec.ts @@ -97,6 +97,7 @@ describe('TypologyController', () => { referenceId: 1, originatedId: null, })), + duplicateTypology: jest.fn(), }; beforeEach(async () => { @@ -253,4 +254,40 @@ describe('TypologyController', () => { expect(service.findOne).toHaveBeenCalledWith(typologyId); expect(result).toEqual(expectedTypology); }); + + it('should update a rule configuration and return the updated configuration', async () => { + const id = 'typology-id'; + const updateTypologyDto = { + cfg: '1.1.0', + desc: 'Updated description', + rules_rule_configs: [], + }; + const req = { user: { username: 'test-user' } }; + + const expectedUpdatedTypology = { + _key: id, + desc: updateTypologyDto.desc, + cfg: updateTypologyDto.cfg, + ownerId: 'test-user', + state: '01_DRAFT', + createdAt: '2021-08-31T00:00:00.000Z', + updatedAt: '2021-08-31T00:00:00.000Z', + ruleWithConfigs: [], + }; + + mockTypologyService.duplicateTypology.mockResolvedValue( + expectedUpdatedTypology, + ); + + // Act + const result = await controller.update(id, updateTypologyDto, req as any); + + // Assert + expect(mockTypologyService.duplicateTypology).toHaveBeenCalledWith( + id, + updateTypologyDto, + req, + ); + expect(result).toEqual(expectedUpdatedTypology); + }); }); diff --git a/packages/config-svc-be/src/typology/typology.controller.ts b/packages/config-svc-be/src/typology/typology.controller.ts index 727ff15..d1b00f7 100644 --- a/packages/config-svc-be/src/typology/typology.controller.ts +++ b/packages/config-svc-be/src/typology/typology.controller.ts @@ -85,12 +85,18 @@ export class TypologyController { } @Patch(':id') - @ApiExcludeEndpoint() + @Roles(TypologyPrivilege.UPDATE_TYPOLOGY) + @ApiOperation({ summary: 'Update a typology by ID' }) + @ApiOkResponse({ + description: 'The typology has been successfully updated.', + type: Typology, + }) update( @Param('id') id: string, @Body() updateTypologyDto: UpdateTypologyDto, + @Request() req, ) { - return this.typologyService.update(+id, updateTypologyDto); + return this.typologyService.duplicateTypology(id, updateTypologyDto, req); } @Delete(':id') diff --git a/packages/config-svc-be/src/typology/typology.service.spec.ts b/packages/config-svc-be/src/typology/typology.service.spec.ts index 21346be..4396f60 100644 --- a/packages/config-svc-be/src/typology/typology.service.spec.ts +++ b/packages/config-svc-be/src/typology/typology.service.spec.ts @@ -4,10 +4,12 @@ import { ArangoDatabaseService } from '../arango-database/arango-database.servic import { BadRequestException, InternalServerErrorException, + NotFoundException, } from '@nestjs/common'; import { CreateTypologyDto } from './dto/create-typology.dto'; -import { TypologyRuleWithConfigs } from './entities/typology.entity'; +import { Typology, TypologyRuleWithConfigs } from './entities/typology.entity'; import { MockArangoError } from '../../test/mocks/mock-arango-error'; +import { UpdateTypologyDto } from './dto/update-typology.dto'; describe('TypologyService', () => { let service: TypologyService; @@ -366,4 +368,116 @@ describe('TypologyService', () => { expect(result).toEqual(typology); }); }); + + describe('update', () => { + beforeEach(async (): Promise => { + const mockCollection = { + update: jest.fn(), + documentExists: jest.fn(), + }; + + dbMock = { + getDatabase: jest.fn(() => ({ + collection: jest.fn(() => mockCollection), + })), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TypologyService, + { provide: ArangoDatabaseService, useValue: dbMock }, + ], + }).compile(); + + service = module.get(TypologyService); + }); + + it('should update and return the updated typology', async () => { + const updateTypologyDto: UpdateTypologyDto = { + name: 'Updated Typology', + desc: 'Updated description', + cfg: '1.1.0', + typologyCategoryUUID: ['uuid2'], + rules_rule_configs: [ + { + ruleId: 'rule/sample-uuid-3', + ruleConfigId: ['rule_config/sample-uuid-6'], + }, + ], + }; + + dbMock.getDatabase().collection().documentExists.mockResolvedValue(true); + + dbMock + .getDatabase() + .collection() + .update.mockResolvedValue({ + new: { + ...updateTypologyDto, + _key: 'existing-id', + ownerId: 'existing-owner', + state: '01_DRAFT', + createdAt: '2021-08-31T00:00:00.000Z', + updatedAt: '2021-08-31T00:00:00.000Z', + updatedBy: 'user1', + }, + }); + + const result: Typology = await service.update( + 'existing-id', + updateTypologyDto, + ); + + expect(result).toEqual({ + ...updateTypologyDto, + _key: 'existing-id', + ownerId: 'existing-owner', + state: '01_DRAFT', + createdAt: '2021-08-31T00:00:00.000Z', + updatedAt: '2021-08-31T00:00:00.000Z', + updatedBy: 'user1', + }); + }); + + it('should throw NotFoundException if the typology does not exist', async () => { + const updateTypologyDto: UpdateTypologyDto = { + name: 'Non-Existent Typology', + desc: 'Description of non-existent typology', + cfg: '1.0.0', + typologyCategoryUUID: ['uuid3'], + rules_rule_configs: [], + }; + + dbMock.getDatabase().collection().documentExists.mockResolvedValue(false); + + await expect( + service.update('non-existent-id', updateTypologyDto), + ).rejects.toThrow( + new NotFoundException('Typology with ID non-existent-id not found.'), + ); + }); + + it('should handle database update errors', async () => { + const updateTypologyDto: UpdateTypologyDto = { + name: 'Typology with Update Error', + desc: 'This should fail on update', + cfg: '1.0.0', + typologyCategoryUUID: ['uuid4'], + rules_rule_configs: [], + }; + + dbMock.getDatabase().collection().documentExists.mockResolvedValue(true); + + const arangoError: MockArangoError = new MockArangoError( + 'Database update error', + 1621, + ); + + dbMock.getDatabase().collection().update.mockRejectedValue(arangoError); + + await expect( + service.update('existing-id', updateTypologyDto), + ).rejects.toThrow(new BadRequestException('Database update error')); + }); + }); }); diff --git a/packages/config-svc-be/src/typology/typology.service.ts b/packages/config-svc-be/src/typology/typology.service.ts index 7043ce8..34beb26 100644 --- a/packages/config-svc-be/src/typology/typology.service.ts +++ b/packages/config-svc-be/src/typology/typology.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, InternalServerErrorException, NotFoundException, @@ -144,8 +145,107 @@ export class TypologyService { } } - update(id: number, updateTypologyDto: UpdateTypologyDto) { - return `This action updates a #${id} typology`; + async duplicateTypology( + id: string, + updateTypologyDto: UpdateTypologyDto, + req: Request, + ): Promise { + const db = this.arangoDatabaseService.getDatabase(); + const collection = db.collection(TYPOLOGY_COLLECTION); + + // Fetch the existing typology by id + const existingTypology: Typology = await this.findOne(id); + if (!existingTypology) { + throw new NotFoundException(`No typology found with ID ${id}`); + } + + // Check for existing typology with the same originatedId + const cursor = await db.query( + `FOR typology IN @@collection + FILTER typology.originatedId == @id + RETURN typology`, + { '@collection': TYPOLOGY_COLLECTION, id: id }, + ); + const childTypology: Typology[] = await cursor.all(); + + // If a child typology already exists, throw an exception + if (childTypology.length > 0) { + throw new ForbiddenException( + `Could not update typology with id ${id}, typology is already updated`, + ); + } + + // Prepare the new typology + const { + name, + cfg, + desc, + typologyCategoryUUID, + rules_rule_configs, + referenceId, + } = existingTypology; + + const newTypology: Typology = { + name, + cfg, + desc, + typologyCategoryUUID, + rules_rule_configs, + referenceId, + ...updateTypologyDto, + _key: uuidv4(), + ownerId: req['user'].username, + updatedBy: req['user'].username, + state: StateEnum['01_DRAFT'], + originatedId: id, + }; + + // Save the new typology in the database + try { + const typology = await collection.save(newTypology, { returnNew: true }); + await this.update(id, { edited: true }); + return typology.new; + } catch (error) { + if (error.isArangoError) { + switch (error.errorNum) { + case 1620: + throw new BadRequestException( + `Failed to update typology: ${error.message}`, + ); + default: + throw new InternalServerErrorException( + `An unexpected database error occurred: ${error.message}`, + ); + } + } else { + throw new InternalServerErrorException( + `An unexpected server error occurred: ${error.message}`, + ); + } + } + } + + async update( + id: string, + updateTypologyDto: UpdateTypologyDto, + ): Promise { + const db = this.arangoDatabaseService.getDatabase(); + + // Check if the typology exists + const exists = await db.collection(TYPOLOGY_COLLECTION).documentExists(id); + if (!exists) { + throw new NotFoundException(`Typology with ID ${id} not found.`); + } + + // Perform the update operation + const result = await db + .collection(TYPOLOGY_COLLECTION) + .update(id, updateTypologyDto, { returnNew: true }); + if (!result) { + throw new InternalServerErrorException('Failed to update typology.'); + } + + return result.new; } remove(id: number) { diff --git a/packages/config-svc-be/test/global-teardown.ts b/packages/config-svc-be/test/global-teardown.ts new file mode 100644 index 0000000..e906101 --- /dev/null +++ b/packages/config-svc-be/test/global-teardown.ts @@ -0,0 +1,6 @@ +import { ArangoDatabaseService } from '../src/arango-database/arango-database.service'; + +export default async () => { + const arangoService = new ArangoDatabaseService(); + await arangoService.truncateCollections(); +}; diff --git a/packages/config-svc-be/test/jest-e2e.json b/packages/config-svc-be/test/jest-e2e.json index e9d912f..6c001c5 100644 --- a/packages/config-svc-be/test/jest-e2e.json +++ b/packages/config-svc-be/test/jest-e2e.json @@ -5,5 +5,6 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "globalTeardown": "./global-teardown.ts" } diff --git a/packages/config-svc-be/test/mocks/mocks.ts b/packages/config-svc-be/test/mocks/mocks.ts index 08c8be8..5757f9e 100644 --- a/packages/config-svc-be/test/mocks/mocks.ts +++ b/packages/config-svc-be/test/mocks/mocks.ts @@ -1,14 +1,19 @@ import { faker } from '@faker-js/faker'; import { DataTypeEnum } from '../../src/rule/schema/rule.schema'; +import { Typology } from '../../src/typology/entities/typology.entity'; +import { CreateRuleDto } from '../../src/rule/dto/create-rule.dto'; +import { CreateRuleConfigDto } from '../../src/rule-config/dto/create-rule-config.dto'; +import { CreateNetworkMapDto } from '../../src/network-map/dto/create-network-map.dto'; -export const rule = { +export const createRuleDto: CreateRuleDto = { name: faker.string.sample(), cfg: '1.0.0', dataType: DataTypeEnum['CURRENCY'], desc: faker.string.sample(), }; -export const ruleConfig = { +export const createRuleConfigDto: CreateRuleConfigDto = { + ruleId: faker.string.uuid(), cfg: '1.0.0', desc: faker.string.sample(), config: { @@ -41,3 +46,55 @@ export const ruleConfig = { bands: [], }, }; + +export const typology: Typology = { + _key: faker.string.uuid(), + name: faker.string.sample(), + desc: faker.string.sample(), + cfg: '1.0.0', + rules_rule_configs: [], + ownerId: faker.internet.email(), + state: '01_DRAFT', + typologyCategoryUUID: [faker.string.uuid()], +}; + +export const createNetworkMapDto: CreateNetworkMapDto = { + active: true, + cfg: '1.0.0', + events: [ + { + eventId: `event/${faker.string.uuid()}`, + typologies: [ + { + id: 'Typology Processor 1@1.0.0', + name: 'Typology Processor 1', + cfg: '1.0.0', + active: true, + rulesWithConfigs: [ + { + rule: { + _id: 'rule/rule-test-id-1', + _key: 'rule-test-id-1', + name: 'rule-name-1', + cfg: '1.0.0', + }, + ruleConfigs: [ + { + _id: 'rule-config/rule-config-test-id-1', + _key: 'rule-config-test-id-1', + cfg: '1.0.0', + }, + { + _id: 'rule-config/rule-config-test-id-2', + _key: 'rule-config-test-id-2', + cfg: '2.0.0', + }, + ], + }, + ], + }, + ], + }, + ], + source: 'user_created', +}; diff --git a/packages/config-svc-be/test/network-map.e2e-spec.ts b/packages/config-svc-be/test/network-map.e2e-spec.ts new file mode 100644 index 0000000..9df0c0d --- /dev/null +++ b/packages/config-svc-be/test/network-map.e2e-spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { faker } from '@faker-js/faker'; +import { user } from './mocks/userMocks'; +import { createNetworkMapDto } from './mocks/mocks'; +import { assignPrivileges } from './utils'; +import { ArangoDatabaseService } from '../src/arango-database/arango-database.service'; + +describe('NetworkMap (e2e)', () => { + let app: INestApplication; + let arangoService: ArangoDatabaseService; + let userToken: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + arangoService = app.get(ArangoDatabaseService); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + const loginUser = async () => { + return request(app.getHttpServer()) + .post('/auth/login') + .send(user) + .expect('Content-Type', /json/); + }; + + const createNetworkMap = async () => { + return await request(app.getHttpServer()) + .post('/network-map') + .set('Authorization', `Bearer ${userToken}`) + .send({ ...createNetworkMapDto }); + }; + + describe('/network-map e2e tests', () => { + it('/auth/login (POST) should login a user', async () => { + const response = await loginUser(); + expect(response.status).toBe(201); + expect(response.body.access_token).toBeDefined(); + expect(response.body.token_type).toEqual('Bearer'); + userToken = response.body.access_token; + await assignPrivileges(userToken, user.username); + }); + }); + + it('/ (POST) should create a new network map', async () => { + const response = await createNetworkMap(); + expect(response.status).toBe(201); + }); + + it('/ (POST) should return 401 if token is not valid', async () => { + const response = await request(app.getHttpServer()) + .post('/network-map') + .set('Authorization', 'fake-token') + .send({ ...createNetworkMapDto }); + expect(response.status).toBe(401); + }); + + it('/:id (GET) should retrieve a single network map by ID', async () => { + const networkMap = await createNetworkMap(); + const response = await request(app.getHttpServer()) + .get(`/network-map/${networkMap.body._key}`) + .set('Authorization', `Bearer ${userToken}`); + expect(response.status).toBe(200); + }); + + it('/:id (GET) should return 404 if network map not found', async () => { + const response = await request(app.getHttpServer()) + .get(`/network-map/${faker.string.uuid()}`) + .set('Authorization', `Bearer ${userToken}`); + expect(response.status).toBe(404); + }); + + it('/:id (GET) should return 401 if token is not valid', async () => { + const networkMap = await createNetworkMap(); + const response = await request(app.getHttpServer()) + .get(`/network-map/${networkMap.body._key}`) + .set('Authorization', 'fake-token'); + expect(response.status).toBe(401); + }); + + it('/:id (PATCH) should update a network map', async () => { + const networkMap = await createNetworkMap(); + const response = await request(app.getHttpServer()) + .patch(`/network-map/${networkMap.body._key}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ active: false }); + expect(response.status).toBe(200); + }); + + it('/:id (PATCH) should return 404 if network map not found', async () => { + const response = await request(app.getHttpServer()) + .patch(`/network-map/${faker.string.uuid()}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ active: false }); + expect(response.status).toBe(404); + }); + + it('/:id (PATCH) should return 401 if token is not valid', async () => { + const networkMap = await createNetworkMap(); + const response = await request(app.getHttpServer()) + .patch(`/network-map/${networkMap.body._key}`) + .set('Authorization', 'fake-token') + .send({ active: false }); + expect(response.status).toBe(401); + }); + + it('/:id (PATCH) should return 400 if invalid data is sent', async () => { + const networkMap = await createNetworkMap(); + const response = await request(app.getHttpServer()) + .patch(`/network-map/${networkMap.body._key}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ active: 'false' }); // invalid data + expect(response.status).toBe(400); + }); +}); diff --git a/packages/config-svc-be/test/rule-config.e2e-spec.ts b/packages/config-svc-be/test/rule-config.e2e-spec.ts index dd1db7c..f242810 100644 --- a/packages/config-svc-be/test/rule-config.e2e-spec.ts +++ b/packages/config-svc-be/test/rule-config.e2e-spec.ts @@ -4,11 +4,11 @@ import * as request from 'supertest'; import { AppModule } from '../src/app.module'; import { faker } from '@faker-js/faker'; import { user } from './mocks/userMocks'; -import { rule, ruleConfig } from './mocks/mocks'; +import { createRuleDto, createRuleConfigDto } from './mocks/mocks'; import { assignPrivileges } from './utils'; import { ArangoDatabaseService } from '../src/arango-database/arango-database.service'; -describe('AppController (e2e)', () => { +describe('RuleConfig (e2e)', () => { let app: INestApplication; let arangoService: ArangoDatabaseService; let userToken: string; @@ -23,8 +23,8 @@ describe('AppController (e2e)', () => { app.useGlobalPipes(new ValidationPipe()); await app.init(); }); + afterAll(async () => { - await arangoService.truncateCollections(); await app.close(); }); @@ -34,25 +34,28 @@ describe('AppController (e2e)', () => { .send(user) .expect('Content-Type', /json/); }; + const createRule = async () => { const response = await request(app.getHttpServer()) .post('/rule') .set('Authorization', `Bearer ${userToken}`) .send({ - ...rule, + ...createRuleDto, }); return response.body; }; const createRuleConfig = async () => { - const ruleId = (await createRule())._key; - return await request(app.getHttpServer()) + const rule = await createRule(); + + const response = await request(app.getHttpServer()) .post('/rule-config') .set('Authorization', `Bearer ${userToken}`) .send({ - ...ruleConfig, - ruleId, + ...createRuleConfigDto, + ruleId: rule._key, }); + return response.body; }; describe('/rule-config e2e tests', () => { @@ -66,13 +69,13 @@ describe('AppController (e2e)', () => { }); it('/ (POST) should create a new rule config', async () => { - const ruleId = (await createRule())._key; + const ruleId = (await createRule())._id; const response = await request(app.getHttpServer()) .post('/rule-config') .set('Authorization', `Bearer ${userToken}`) .send({ - ...ruleConfig, - ruleId: `rule/${ruleId}`, + ...createRuleConfigDto, + ruleId: ruleId, }); expect(response.status).toBe(201); @@ -84,33 +87,65 @@ describe('AppController (e2e)', () => { .post('/rule-config') .set('Authorization', `Bearer ${userToken}`) .send({ - ...ruleConfig, + ...createRuleConfigDto, ruleId: `rule/${incorrectRuleId}`, }); expect(response.status).toBe(404); }); + it('/ (POST) should return 401 for unauthorized access', async () => { + const ruleId = (await createRule())._id; + const response = await request(app.getHttpServer()) + .post('/rule-config') + .send({ + ...createRuleConfigDto, + ruleId, + }); + expect(response.status).toBe(401); + }); + + it('/ (POST) should fail with validation error for invalid data', async () => { + const ruleId = (await createRule())._key; + const response = await request(app.getHttpServer()) + .post('/rule-config') + .set('Authorization', `Bearer ${userToken}`) + .send({ + ...createRuleConfigDto, + ruleId: `rule/${ruleId}`, + cfg: 123, // invalid data type + }); + expect(response.status).toBe(400); + }); + it('/:id (GET) should get a single rule config', async () => { const ruleConfig = await createRuleConfig(); - const id = ruleConfig.body._key; + const key = ruleConfig._key; const result = await request(app.getHttpServer()) - .get(`/rule-config/${id}`) + .get(`/rule-config/${key}`) .set('Authorization', `Bearer ${userToken}`); expect(result.status).toBe(200); }); it('/:id (GET) should fail on non existing rule config', async () => { - const id = faker.string.uuid(); + const key = 'non-existing-id'; const result = await request(app.getHttpServer()) - .get(`/rule-config/${id}`) + .get(`/rule-config/${key}`) .set('Authorization', `Bearer ${userToken}`); expect(result.status).toBe(404); }); + it('/ (GET) should retrieve rule configs with pagination', async () => { + const response = await request(app.getHttpServer()) + .get('/rule-config?page=1&limit=2') + .set('Authorization', `Bearer ${userToken}`); + expect(response.status).toBe(200); + expect(response.body.data.length).toBeLessThanOrEqual(2); + }); + it('/:id (PATCH) should update an existing rule config', async () => { const ruleConfig = await createRuleConfig(); - const id = ruleConfig.body._key; + const id = ruleConfig._key; const result = await request(app.getHttpServer()) .patch(`/rule-config/${id}`) @@ -122,29 +157,69 @@ describe('AppController (e2e)', () => { desc: faker.string.sample(), }); - console.log('result.body', result.body); expect(result.status).toBe(200); expect(result.body.originatedID).toBe(id); }); - it('/:id/disable (POST) should disable an existing rule', async () => { + it('/:id (PATCH) should fail to update an existing rule config with concurrent update', async () => { const ruleConfig = await createRuleConfig(); - const id = ruleConfig.body._key; + const key = ruleConfig._key; + + await request(app.getHttpServer()) + .patch(`/rule-config/${key}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ + cfg: '2.0.0', + ruleId: `rule/${key}`, + updatedBy: user.username, + desc: faker.string.sample(), + }); + + const result = await request(app.getHttpServer()) + .patch(`/rule-config/${key}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ + cfg: '2.0.1', + ruleId: `rule/${key}`, + updatedBy: user.username, + desc: faker.string.sample(), + }); + + expect(result.status).toBe(403); + }); + + it('/:id/disable (PATCH) should disable an existing rule', async () => { + const ruleConfig = await createRuleConfig(); + const id = ruleConfig._key; const response = await request(app.getHttpServer()) - .post(`/rule-config/${id}/disable`) + .patch(`/rule-config/${id}/disable`) .set('Authorization', `Bearer ${userToken}`); - expect(response.status).toBe(201); + expect(response.status).toBe(200); }); - it('/:id (DELETE) should make a rule for deletion', async () => { + it('/:id (DELETE) should mark a rule for deletion', async () => { const ruleConfig = await createRuleConfig(); - const id = ruleConfig.body._key; + const id = ruleConfig._key; const response = await request(app.getHttpServer()) .delete(`/rule-config/${id}`) .set('Authorization', `Bearer ${userToken}`); expect(response.status).toBe(200); }); + + it('/:id (DELETE) should fail to delete a rule already marked for deletion', async () => { + const ruleConfig = await createRuleConfig(); + const id = ruleConfig._key; + + await request(app.getHttpServer()) + .delete(`/rule-config/${id}`) + .set('Authorization', `Bearer ${userToken}`); + + const response = await request(app.getHttpServer()) + .delete(`/rule-config/${id}`) + .set('Authorization', `Bearer ${userToken}`); + expect(response.status).toBe(400); + }); }); }); diff --git a/packages/config-svc-be/test/rule.e2e-spec.ts b/packages/config-svc-be/test/rule.e2e-spec.ts index 52e25c9..7c2acb5 100644 --- a/packages/config-svc-be/test/rule.e2e-spec.ts +++ b/packages/config-svc-be/test/rule.e2e-spec.ts @@ -5,11 +5,10 @@ import { AppModule } from '../src/app.module'; import { faker } from '@faker-js/faker'; import { StateEnum } from '../src/rule/schema/rule.schema'; import { user } from './mocks/userMocks'; -import { rule } from './mocks/mocks'; +import { createRuleDto } from './mocks/mocks'; import { assignPrivileges } from './utils'; import { ArangoDatabaseService } from '../src/arango-database/arango-database.service'; -jest.setTimeout(50000); describe('AppController (e2e)', () => { let app: INestApplication; let arangoService: ArangoDatabaseService; @@ -26,7 +25,6 @@ describe('AppController (e2e)', () => { await app.init(); }); afterAll(async () => { - await arangoService.truncateCollections(); await app.close(); }); @@ -48,7 +46,7 @@ describe('AppController (e2e)', () => { .post('/rule') .set('Authorization', `Bearer ${userToken}`) .send({ - ...rule, + ...createRuleDto, }); return response.body; }; @@ -70,7 +68,7 @@ describe('AppController (e2e)', () => { .post('/rule') .set('Authorization', `Bearer ${userToken}`) .send({ - ...rule, + ...createRuleDto, }); expect(response.status).toBe(201); }); @@ -80,7 +78,7 @@ describe('AppController (e2e)', () => { .post('/rule') .set('Authorization', `Bearer ${userToken}`) .send({ - ...rule, + ...createRuleDto, source: faker.string.sample(), }); expect(response.status).toBe(400); @@ -110,7 +108,8 @@ describe('AppController (e2e)', () => { }); it('/:id (PATCH) should update an existing rule', async () => { - const id = (await createRule())._key; + const rule = await createRule(); + const id = rule._key; const result = await request(app.getHttpServer()) .patch(`/rule/${id}`) .set('Authorization', `Bearer ${userToken}`) diff --git a/packages/config-svc-be/test/typology.e2e-spec.ts b/packages/config-svc-be/test/typology.e2e-spec.ts new file mode 100644 index 0000000..39235e2 --- /dev/null +++ b/packages/config-svc-be/test/typology.e2e-spec.ts @@ -0,0 +1,111 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { faker } from '@faker-js/faker'; +import { user } from './mocks/userMocks'; +import { typology } from './mocks/mocks'; +import { assignPrivileges } from './utils'; +import { ArangoDatabaseService } from '../src/arango-database/arango-database.service'; + +describe('Typology (e2e)', () => { + let app: INestApplication; + let arangoService: ArangoDatabaseService; + let userToken: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + arangoService = app.get(ArangoDatabaseService); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + const loginUser = async () => { + return request(app.getHttpServer()) + .post('/auth/login') + .send(user) + .expect('Content-Type', /json/); + }; + + const createTypology = async () => { + return await request(app.getHttpServer()) + .post('/typology') + .set('Authorization', `Bearer ${userToken}`) + .send({ ...typology }); + }; + + describe('/typology e2e tests', () => { + it('/auth/login (POST) should login a user', async () => { + const response = await loginUser(); + expect(response.status).toBe(201); + expect(response.body.access_token).toBeDefined(); + expect(response.body.token_type).toEqual('Bearer'); + userToken = response.body.access_token; + await assignPrivileges(userToken, user.username); + }); + + it('/ (POST) should create a new typology', async () => { + const response = await createTypology(); + expect(response.status).toBe(201); + }); + + it('/:id (GET) should retrieve a single typology by ID', async () => { + const typology = await createTypology(); + const id = typology.body._key; + + const result = await request(app.getHttpServer()) + .get(`/typology/${id}`) + .set('Authorization', `Bearer ${userToken}`); + expect(result.status).toBe(200); + }); + + it('/:id (GET) should return 404 for non-existent typology', async () => { + const id = faker.string.uuid(); + + const result = await request(app.getHttpServer()) + .get(`/typology/${id}`) + .set('Authorization', `Bearer ${userToken}`); + expect(result.status).toBe(404); + }); + + it('/ (GET) should retrieve all typologies with pagination', async () => { + const response = await request(app.getHttpServer()) + .get('/typology?page=1&limit=2') + .set('Authorization', `Bearer ${userToken}`); + expect(response.status).toBe(200); + expect(response.body.data.length).toBeLessThanOrEqual(2); + }); + + it('/:id (PATCH) should update an existing typology', async () => { + const typology = await createTypology(); + const id = typology.body._key; + + const result = await request(app.getHttpServer()) + .patch(`/typology/${id}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ name: 'Updated Name' }); + + expect(result.status).toBe(200); + expect(result.body._key).toBeDefined(); + }); + + it('/:id (PATCH) should return 404 when updating a non-existent typology', async () => { + const id = faker.string.uuid(); + + const result = await request(app.getHttpServer()) + .patch(`/typology/${id}`) + .set('Authorization', `Bearer ${userToken}`) + .send({ ...typology, name: 'Updated Name' }); + + expect(result.status).toBe(404); + }); + }); +}); diff --git a/packages/config-svc-fe/pages/network-map.tsx b/packages/config-svc-fe/pages/network-map.tsx deleted file mode 100644 index f2c7a8e..0000000 --- a/packages/config-svc-fe/pages/network-map.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import Head from "next/head"; -import React from "react"; - -const NetworkMap = () => ( - <> - - LexTego - Configuration Service - - Network map - -); - -export default NetworkMap; diff --git a/packages/config-svc-fe/pages/network-map/create.tsx b/packages/config-svc-fe/pages/network-map/create.tsx new file mode 100644 index 0000000..20452ce --- /dev/null +++ b/packages/config-svc-fe/pages/network-map/create.tsx @@ -0,0 +1 @@ +export { default } from "~/domain/Networkmap/CreateEdit"; \ No newline at end of file diff --git a/packages/config-svc-fe/pages/network-map/index.tsx b/packages/config-svc-fe/pages/network-map/index.tsx new file mode 100644 index 0000000..e8e14c0 --- /dev/null +++ b/packages/config-svc-fe/pages/network-map/index.tsx @@ -0,0 +1 @@ +export { default } from "~/domain/Networkmap/List/Index"; \ No newline at end of file diff --git a/packages/config-svc-fe/pages/rule-config/[id]/review.tsx b/packages/config-svc-fe/pages/rule-config/[id]/review.tsx new file mode 100644 index 0000000..42ce584 --- /dev/null +++ b/packages/config-svc-fe/pages/rule-config/[id]/review.tsx @@ -0,0 +1 @@ +export { default } from "~/domain/Rule/ReviewConfig"; \ No newline at end of file diff --git a/packages/config-svc-fe/pages/rule/[id]/review.tsx b/packages/config-svc-fe/pages/rule/[id]/review.tsx new file mode 100644 index 0000000..59b0380 --- /dev/null +++ b/packages/config-svc-fe/pages/rule/[id]/review.tsx @@ -0,0 +1 @@ +export { default } from "~/domain/Rule/ReviewRule"; diff --git a/packages/config-svc-fe/pages/rule.tsx b/packages/config-svc-fe/pages/rule/index.tsx similarity index 100% rename from packages/config-svc-fe/pages/rule.tsx rename to packages/config-svc-fe/pages/rule/index.tsx diff --git a/packages/config-svc-fe/setup.ts b/packages/config-svc-fe/setup.ts index 93cf957..d75d48b 100644 --- a/packages/config-svc-fe/setup.ts +++ b/packages/config-svc-fe/setup.ts @@ -23,4 +23,4 @@ jest.mock('axios', () => ({ }, }, })), -})); +})); \ No newline at end of file diff --git a/packages/config-svc-fe/src/client/api.ts b/packages/config-svc-fe/src/client/api.ts index a4d9d74..aa5100a 100644 --- a/packages/config-svc-fe/src/client/api.ts +++ b/packages/config-svc-fe/src/client/api.ts @@ -1,7 +1,8 @@ import axios from "axios"; const instance = axios.create({ - baseURL: `${process.env.NEXT_PUBLIC_CONFIG_SVC_BE_URL}/api` + baseURL: `${process.env.NEXT_PUBLIC_CONFIG_SVC_BE_URL}/api`, + timeout: 20000 }); instance.interceptors.request.use( diff --git a/packages/config-svc-fe/src/context/auth.tsx b/packages/config-svc-fe/src/context/auth.tsx index 358bb98..b2a556f 100644 --- a/packages/config-svc-fe/src/context/auth.tsx +++ b/packages/config-svc-fe/src/context/auth.tsx @@ -76,7 +76,10 @@ const AuthProvider = ({ children }: { children: ReactNode }) => { Promise.resolve(); localStorage.removeItem("token"); setIsAuthenticated(false); - router.push(`/login?next=${currentPath}`).catch(Promise.resolve); + router.push(`/login?next=${currentPath}`).catch(Promise.resolve) + .then(() => { + setIsLoading(false); + }); }); } else { if (unprotectedRoutes.includes(currentPath)) { diff --git a/packages/config-svc-fe/src/context/service.ts b/packages/config-svc-fe/src/context/service.ts index 09c42ff..dddf57c 100644 --- a/packages/config-svc-fe/src/context/service.ts +++ b/packages/config-svc-fe/src/context/service.ts @@ -1,5 +1,5 @@ import { Api } from "~/client" export const getUser = () => { - return Api.get('/auth/profile') + return Api.get('/auth/profile', {timeout: 10000}) } \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Create.tsx b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Create.tsx new file mode 100644 index 0000000..683eaaa --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Create.tsx @@ -0,0 +1,267 @@ +import { Row, Col, Spin, Button, Collapse, Space, Typography, Card, Checkbox } from 'antd'; +import React, { DragEventHandler, useMemo } from 'react'; +import { EventsProps } from './Events'; +import { IRule } from '~/domain/Rule/RuleDetailPage/service'; +import { RulesAttached, RulesConfigurationsAttached } from './Rules-Attached'; +import { Handle, NodeMouseHandler, NodeProps, Panel, Position } from 'reactflow'; +import { useCommonTranslations } from '~/hooks'; +import { Flow } from '../../Typology/Create/Flow'; +import { CloseOutlined, ExpandAltOutlined, MinusCircleFilled, NodeIndexOutlined, PlusCircleFilled } from '@ant-design/icons'; +import styles from './style.module.scss'; +import { UnAssignedTypologies, UnassignedTypologiesProps } from './Typology-List'; +import { RuleWithConfig } from '~/domain/Typology/Score/service'; +import Link from 'next/link'; + +interface Props { + loadingTypologies: boolean; + nodes: any[]; + edges: any[]; + onNodesChange: any; + onConnect: any; + onEdgesChange: any; + onDrop: DragEventHandler; + flowRef: any + ruleOptions: IRule[]; + setRuleOptions: (rules: IRule[]) => void; + selectedRule: null | string; + setSelectedRuleIndex: (index: string | null) => void; + attachedRules: RuleWithConfig[]; + ruleDragIndex: null | number; + setRuleDragIndex: (index: number | null) => void; + onNodeClick: NodeMouseHandler; + saveLoading: boolean; + showVersions: boolean; + setShowVersions: (val: boolean) => void; + events: { label: string, disabled: boolean, value: string, color?: string }[]; + expandAll: () => void; + loadingAttached: boolean; + handleSave: () => void; + +} + +const Version = React.lazy(() => import('./Versions')); + +const EventNode: React.FunctionComponent = ({ data, id }) => { + const handleExpand = () => { + if (data.handleExpand) { + data.handleExpand(id); + } + } + return
+ + {data.label} + {data.expanded ? + : } + +
+} + +const TypologyNode: React.FunctionComponent = ({ data, id }) => { + const {t} = useCommonTranslations(); + const ruleConfigurations = useMemo(() => { + const configurations: string[] = []; + data?.rules_rule_configs?.forEach((r: { ruleId: string; ruleConfigId: string[] }) => { + r?.ruleConfigId?.forEach((c: string) => { + configurations.push(c); + }) + }); + return configurations; + }, [data]); + + const handleDelete = () => { + if (data.handleDelete) { + data.handleDelete(id, data); + } + } + + const handleExpandVersions = () => { + if (data.handleExpandVersions) { + data.handleExpandVersions(id, data.expanded); + } + } + return + + {/* */} + } + styles={{ header: { background: '#EEEEEE' } }} + style={{ minWidth: 350 }}> + + +
+ {t('createEditNetworkMap.lastUpdated')} + {new Date(data.updatedAt).toDateString()} +
+
+ {t('createEditNetworkMap.rules')} + {data?.rules_rule_configs?.length} +
+ +
+ {t('createEditNetworkMap.configurations')} + {ruleConfigurations.length} +
+ + + {data.expanded ? + : } +
+ + + +
+} + +const VersionNode: React.FunctionComponent = ({ data, id }) => { + const handleCheck = () => { + if (data.handleCheck) { + data.handleCheck(data, !data.checked); + } + } + return
+ + {data.label} + + + +
+} + +const nodeTypes = { eventNode: EventNode, typologyNode: TypologyNode, versionNode: VersionNode }; + +export const Create: React.FunctionComponent = ({ loadingTypologies, ...props }) => { + const { t } = useCommonTranslations(); + + //Disabled donot delete + // const events: MenuProps['items'] = useMemo(() => { + // return props.events.map((e) => ({ + // key: e.value, icon: , + // label: {e.label}, + // disabled: e.disabled, + // })) + // }, [props.events]); + if (loadingTypologies) { + return + } + + return ( +
+
+
+ + + +
+
+ }> + + + + + {/* Disabled not part of this iteration */} + {/* */} + + + + +
+ + {/* Disabled not part of this iteration. Donot Delete */} + + {/* + + + + + */} + + + + + + + +
+ + +
+ + }, + { + key: '2', + label: t('createEditNetworkMap.rulesConfigurationsAttached'), + style: { padding: 0 }, + children: + + }]} + /> +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Events.tsx b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Events.tsx new file mode 100644 index 0000000..ef44bc4 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Events.tsx @@ -0,0 +1,92 @@ +import { FileDoneOutlined } from "@ant-design/icons" +import { Input, Typography } from "antd" +import { useEffect, useMemo } from "react" +import { useCommonTranslations } from "~/hooks"; +import { sortAlphabetically } from "~/utils"; + +export interface IEvent { + label: string; + disabled: boolean; + value: string; + color: string; +} +export interface EventsProps { + events: IEvent[]; + eventOptions: IEvent[]; + setEventOptions: (rules: IEvent[]) => void; + selectEvent: null | string; + setSelectedEvent: (index: string | null) => void; + attachedEvents: IEvent[]; + +} +export const Events: React.FunctionComponent = ({ + events, + eventOptions, + setEventOptions, + selectEvent, + setSelectedEvent, + attachedEvents, +}) => { + const attachedEventsIds = useMemo(() => { + return attachedEvents.map((r) => r.value); + + }, [attachedEvents]); + + useEffect(() => { + setEventOptions(events); + }, [events]); + + const handleSearch = (val: any) => { + if (val.trim().length) { + setEventOptions(events.filter((rule) => rule.value.toLowerCase().includes(val.toLowerCase()))) + } + if (!val.trim().length) { + setEventOptions(events); + } + setSelectedEvent(null); + } + + return
+
+ Events +
+
+ handleSearch(e?.target?.value || '')} + data-testid="search-rules-input" + /> + + { + (sortAlphabetically(eventOptions, 'label')).map((r: IEvent, index) => { + if (attachedEventsIds.includes(r.value)) { + return null; + } + return
{ + e.dataTransfer.setData('application/reactflow', 'node'); + e.dataTransfer.setData('type', 'rule'); + e.dataTransfer.setData('index', (selectEvent)?.toString() as string); + e.dataTransfer.setData('data', JSON.stringify(r)); + e.dataTransfer.effectAllowed = 'move'; + setSelectedEvent(r.value); + }} + onClick={() => setSelectedEvent(r.value)} + data-testid="rule-drag-item" + style={{ border: selectEvent === r.value ? '2px solid #4CAE47' : '' }} + className="flex cursor-move justify-between items-center border-gray-400 px-2 border mx-1 p-2 mb-2"> + + + {r.label} + +
+ }) + } +
+ +
+} \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Rules-Attached.tsx b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Rules-Attached.tsx new file mode 100644 index 0000000..c33b4e1 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Rules-Attached.tsx @@ -0,0 +1,108 @@ +import { Empty, Input, Spin, Typography } from "antd" +import { useEffect, useState } from "react"; +import { sortAlphabetically } from "~/utils"; +import { useCommonTranslations } from "~/hooks"; +import { RuleConfig, RuleWithConfig } from "~/domain/Typology/Score/service"; +import { FileDoneOutlined, NodeIndexOutlined } from "@ant-design/icons"; + +export interface RulesAttachedProps { + rulesAttached: RuleWithConfig[]; + loadingAttached: boolean; +} +export const RulesAttached: React.FunctionComponent = ({ rulesAttached, loadingAttached }) => { + const [options, setOptions] = useState([]); + const { t } = useCommonTranslations(); + + useEffect(() => { + const obj: {[k:string]: RuleWithConfig} = {} + rulesAttached.forEach((r) => { + if(!obj[r.rule._key]) { + obj[r.rule._key] = { + ...r, + } + } + }); + setOptions([...Object.values(obj)]); + + }, [rulesAttached]); + + const handleSearch = (text: string) => { + if (text.trim().length) { + const searchResults = rulesAttached.filter((rule) => rule?.rule.name?.toLocaleLowerCase().includes(text?.toLocaleLowerCase())); + setOptions([...searchResults]); + } else { + setOptions(rulesAttached); + } + } + + return +
+ handleSearch(e?.target?.value)} placeholder={t('createEditNetworkMap.searchRules')} className="mb-2 border-none shadow-none focus:ring-0" /> + { + !options.length ? {t('createEditNetworkMap.noRulesAttached')} : null + } + + { + (sortAlphabetically(options, 'name')).map((option, i) =>
+ +

{option.rule?.name}

+
) + } +
+
+} + +export interface IConfig extends RuleConfig { + ruleName: string; +} +export const RulesConfigurationsAttached: React.FunctionComponent = ({ rulesAttached, loadingAttached }) => { + const [configurations, setConfigurations] = useState([]); + const { t } = useCommonTranslations(); + + const handleSearch = (text: string) => { + if (text.trim().length) { + rulesAttached.forEach((rule) => { + const configs = rule.ruleConfigs.map((config) => ({ ...config, ruleName: rule.rule.name })); + const results = configs.filter((c) => c?.ruleName?.includes(text)); + setConfigurations([...results]); + }) + } else { + rulesAttached.forEach((rule) => { + const configs = rule.ruleConfigs.map((config) => ({ ...config, ruleName: rule.rule.name })); + setConfigurations([...configs]) + }) + } + } + useEffect(() => { + + if (rulesAttached.length) { + const configList: IConfig[] = []; + rulesAttached.forEach((r) => { + (r.ruleConfigs || []).forEach((config) => { + configList.push({ ...config, ruleName: r.rule.name }); + }); + }); + setConfigurations([...configList]); + } else { + setConfigurations([]); + } + + }, [rulesAttached]); + + + return +
+ + handleSearch(e?.target?.value)} data-testid="search-configuration-input" placeholder={t('createEditNetworkMap.searchConfigurations')} className="mb-2 border-none shadow-none focus:ring-0" /> + { + !configurations.length ? : null + } + {configurations.map((config, i) =>
+ +

+ {`${config.ruleName}-config-${i + 1}`} +

+
)} +
+
+} \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Typology-List.tsx b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Typology-List.tsx new file mode 100644 index 0000000..e1e3681 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Typology-List.tsx @@ -0,0 +1,127 @@ +import { Collapse, CollapseProps, Empty, Input, Typography } from "antd" +import { useEffect } from "react" +import { sortAlphabetically } from "~/utils"; +import { GroupedTypology } from "./service"; +import { ITypology } from "~/domain/Typology/List/service"; +import { useCommonTranslations } from "~/hooks"; + +function sortByCfgDesc(data: ITypology[]) { + function parseCfg(cfg: string) { + return cfg.split('.').map(Number); + } + data.sort((a, b) => { + const aParsed = parseCfg(a.cfg); + const bParsed = parseCfg(b.cfg); + + // Compare each part of the version number + for (let i = 0; i < aParsed.length; i++) { + if (aParsed[i] > bParsed[i]) { + return -1; // a comes before b + } else if (aParsed[i] < bParsed[i]) { + return 1; + } + } + + return 0; + }); + + return data; +} + + +export interface UnassignedTypologiesProps { + typologies: GroupedTypology[]; + typologyOptions: GroupedTypology[]; + setTypologyOptions: (rules: GroupedTypology[]) => void; + selectedTypology: null | string; + setSelectedTypology: (index: string | null) => void; + +} +export const UnAssignedTypologies: React.FunctionComponent = ({ + typologies, + typologyOptions, + setTypologyOptions, + selectedTypology, + setSelectedTypology, +}) => { + const {t: tn} = useCommonTranslations(); + + useEffect(() => { + setTypologyOptions(typologies); + }, [typologies]); + + const handleSearch = (val: any) => { + if (val.trim().length) { + setTypologyOptions(typologies.filter((rule) => rule.name.toLowerCase().includes(val.toLowerCase()))) + } + if (!val.trim().length) { + setTypologyOptions(typologies); + } + setSelectedTypology(null); + + } + + return
+
+
+ {tn('createEditNetworkMap.unassignedTypologies')} +
+ handleSearch(e?.target?.value)} + data-testid="removed-items-input" + /> + { + !typologyOptions.length ? : null + } +
+ { + (sortAlphabetically(typologyOptions, 'name')).map((t: GroupedTypology, index) => { + + const items: CollapseProps['items'] = [ + { + key: '1', + label: t.name.slice(0, 25), + children:
+ + { + sortByCfgDesc(t.versions).map((t) => + {tn('createEditNetworkMap.version')} {t.cfg} + ) + } +
+ }, + + ]; + return
{ + e.dataTransfer.setData('application/reactflow', 'node'); + e.dataTransfer.setData('type', 'typology'); + e.dataTransfer.setData('index', (selectedTypology)?.toString() as string); + e.dataTransfer.setData('data', JSON.stringify(t)); + e.dataTransfer.effectAllowed = 'move'; + setSelectedTypology(t._key as string); + }} + onClick={() => setSelectedTypology(t._key as string)} + data-testid={`typology-drag-item-${index}`} + className="flex cursor-move justify-between items-center px-1 mx-1 mb-2"> + +
+ }) + } +
+ + + +
+ +
+} \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Versions.tsx b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Versions.tsx new file mode 100644 index 0000000..d7a5de3 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/Versions.tsx @@ -0,0 +1,136 @@ +import { Checkbox, Col, Modal, Row } from "antd" +import { Position } from "reactflow"; +import { Flow } from "../../Typology/Create/Flow"; +import { ExpandAltOutlined } from "@ant-design/icons"; +import styles from './style.module.scss'; + +const nodeDefaults = { + sourcePosition: Position.Right, + targetPosition: Position.Left, + type: 'eventNode' +}; +const initialNodes = [ + { + id: '1', + position: { x: 0, y: 150 }, + data: { label: 'Event', showDelete: false }, + ...nodeDefaults, + + }, + { + id: '2', + position: { x: 300, y: 200 }, + data: { label: 'Typology 2', showDelete: false }, + ...nodeDefaults, + type: 'typologyNode' + + }, + + { + id: '3', + position: { x: 400, y: 200 }, + data: { label: 'Version 1.3.5', showDelete: false }, + ...nodeDefaults, + type: 'versionNode' + + }, +]; + +const initialEdges = [ + { + id: 'e1-2', + source: '1', + target: '2', + type: 'smoothstep' + }, + + { + id: 'e2-3', + source: '2', + target: '3', + type: 'smoothstep' + }, +]; +interface Props { + open: boolean; + setOpen: (val: boolean) => void; +} + +const VersionList: React.FunctionComponent<{ versions: any[] }> = ({ versions }) => { + return versions.map((v, i) => { + return
+ + +
+ + Version {i + 1} +
+ +
+ }) +} + + +const Version: React.FunctionComponent = ({ open, setOpen }) => { + const versionsList = [1, 2, 3, 5, 6] + return +
Typology 1 Design
+ setOpen(false)} className="cursor-pointer" /> + + } + + style={{ minWidth: '70vw', minHeight: '80vh'}} + open={open} onCancel={() => setOpen(false)} destroyOnClose> + + + + + + + + + + + + + + +
+} + +export default Version; + diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/__tests__/index.spec.tsx b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/__tests__/index.spec.tsx new file mode 100644 index 0000000..2d7ac0c --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/__tests__/index.spec.tsx @@ -0,0 +1,342 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import NetworkMapPage from '../index'; +import * as service from '../service'; +import '../../../../../setup'; +import { mockData } from '../mock'; + +class ResizeObserver { + constructor() { } + observe() { } + unobserve() { } + disconnect() { } +} +global.ResizeObserver = ResizeObserver; + +jest.mock('axios', () => ({ + create: jest.fn(() => ({ + get: jest.fn(), + post: jest.fn(), + interceptors: { + request: { + use: jest.fn(), + eject: jest.fn(), + }, + response: { + use: jest.fn(), + eject: jest.fn(), + }, + }, + })), +})); + +const getTypologies = jest.spyOn(service, "getTypologies"); +const getTypology = jest.spyOn(service, "getTypology"); +const createNetworkMap = jest.spyOn(service, "createNetworkMap"); +const usePrivileges = jest.spyOn(require('~/hooks/usePrivileges'), 'default'); + +describe.only('NetworkMapPage', () => { + beforeEach(() => { + (usePrivileges as jest.Mock).mockReturnValue({ + canCreateNetworkMap: true, + canViewTypologyList: true, + canReviewTypology: true, + }); + // (getTypologies as jest.Mock).mockResolvedValue({ data: { data: [] } }); + // (getTypology as jest.Mock).mockResolvedValue({ data: {} }); + // (createNetworkMap as jest.Mock).mockResolvedValue({}); + }); + + test('renders without crashing', () => { + getTypologies.mockResolvedValue({data: {data: mockData}} as any); + render(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + }); + + test('displays error message when user lacks canCreateNetworkMap', () => { + getTypologies.mockResolvedValue({data: {data: mockData}} as any); + + (usePrivileges).mockReturnValue({ + canCreateNetworkMap: false, + canViewTypologyList: true, + canReviewTypology: true, + }); + render(); + expect(screen.getByText(/You don't have permission to create a network map/)).toBeInTheDocument(); + }); + + test('displays error message when user lacks canViewTypologyList', () => { + getTypologies.mockResolvedValue({data: {data: mockData}} as any); + + (usePrivileges).mockReturnValue({ + canCreateNetworkMap: true, + canViewTypologyList: false, + canReviewTypology: true, + }); + render(); + expect(screen.getByText(/You don't have permission to view typology list/)).toBeInTheDocument(); + }); + + test('displays error message when user lacks canReviewTypology', () => { + getTypologies.mockResolvedValue({data: {data: mockData}} as any); + + (usePrivileges).mockReturnValue({ + canCreateNetworkMap: true, + canViewTypologyList: true, + canReviewTypology: false, + }); + render(); + expect(screen.getByText(/You don't have permission to get details for a single typology/)).toBeInTheDocument(); + }); + + test('handles save with no attached rules', async () => { + (usePrivileges).mockReturnValue({ + canCreateNetworkMap: true, + canViewTypologyList: true, + canReviewTypology: true, + }); + render(); + getTypologies.mockResolvedValue({data: {data: mockData}} as any) + await waitFor(() => expect(screen.getByTestId('typology-drag-item-0')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Save')); + expect(screen.getByText('Please add at least one typology')).toBeInTheDocument(); + }); + + test('handle drop typology node on page', async () => { + (usePrivileges).mockReturnValue({ + canCreateNetworkMap: true, + canViewTypologyList: true, + canReviewTypology: true, + }); + render(); + getTypologies.mockResolvedValue({data: {data: mockData}} as any) + await waitFor(() => expect(screen.getByTestId('typology-drag-item-0')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryAllByTestId(/typology-node/).length).toBe(0));; + const flow = document.querySelector('.react-flow') as Element; + const typologyNode = screen.getByTestId('typology-drag-item-0'); + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn().mockImplementation((arg) => { + if (arg === 'type') { + return 'typology' + } + return JSON.stringify({...mockData[0], versions: [mockData[0], mockData[1]]}) + }), + }; + const preventDefault = jest.fn(); + fireEvent.dragStart(typologyNode, { dataTransfer }); + fireEvent.drop(flow, { + dataTransfer, + preventDefault, + }); + expect(dataTransfer.setData).toHaveBeenCalledWith('application/reactflow', 'node'); + expect(dataTransfer.setData).toHaveBeenCalledWith('type', 'typology'); + await waitFor(() => expect(screen.getAllByTestId(/typology-node/).length).toBe(1));; + }); + + test('handle expand typology to see versions', async () => { + (usePrivileges).mockReturnValue({ + canCreateNetworkMap: true, + canViewTypologyList: true, + canReviewTypology: true, + }); + + getTypologies.mockResolvedValue({data: {data: mockData}} as any); + jest.spyOn(service, 'groupTypologies').mockReturnValueOnce([ + { + ...mockData[0], + versions: [mockData[0], mockData[1]] + }, + { + ...mockData[2], + versions: [], + } + ]); + + render(); + + await waitFor(() => expect(screen.getByTestId('typology-drag-item-0')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryAllByTestId(/typology-node/).length).toBe(0));; + const flow = document.querySelector('.react-flow') as Element; + const typologyNode = screen.getByTestId('typology-drag-item-0'); + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn().mockImplementation((arg) => { + if (arg === 'type') { + return 'typology' + } + return JSON.stringify({...mockData[0], versions: [mockData[0], mockData[1]]}) + }), + }; + const preventDefault = jest.fn(); + fireEvent.dragStart(typologyNode, { dataTransfer }); + fireEvent.drop(flow, { + dataTransfer, + preventDefault, + }); + expect(dataTransfer.setData).toHaveBeenCalledWith('application/reactflow', 'node'); + expect(dataTransfer.setData).toHaveBeenCalledWith('type', 'typology'); + await waitFor(() => expect(screen.getAllByTestId(/typology-node/).length).toBe(1)); + const expandIcon = screen.getAllByTestId('open-expand')[0]; + fireEvent.click(expandIcon); + await waitFor(() => expect(screen.getAllByTestId('version-node').length).toBe(2)); + }); + + test('handle expand all to see versions', async () => { + (usePrivileges).mockReturnValue({ + canCreateNetworkMap: true, + canViewTypologyList: true, + canReviewTypology: true, + }); + + getTypologies.mockResolvedValue({data: {data: mockData}} as any); + jest.spyOn(service, 'groupTypologies').mockReturnValueOnce([ + { + ...mockData[0], + versions: [mockData[0], mockData[1]] + }, + { + ...mockData[2], + versions: [], + } + ]); + + render(); + await waitFor(() => expect(screen.getByTestId('typology-drag-item-0')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryAllByTestId(/typology-node/).length).toBe(0));; + const flow = document.querySelector('.react-flow') as Element; + const typologyNode = screen.getByTestId('typology-drag-item-0'); + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn().mockImplementation((arg) => { + if (arg === 'type') { + return 'typology' + } + return JSON.stringify({...mockData[0], versions: [mockData[0], mockData[1]]}) + }), + }; + const preventDefault = jest.fn(); + fireEvent.dragStart(typologyNode, { dataTransfer }); + fireEvent.drop(flow, { + dataTransfer, + preventDefault, + }); + expect(dataTransfer.setData).toHaveBeenCalledWith('application/reactflow', 'node'); + expect(dataTransfer.setData).toHaveBeenCalledWith('type', 'typology'); + await waitFor(() => expect(screen.getAllByTestId(/typology-node/).length).toBe(1)); + const expandAllIcon = screen.getByTestId('expand-all'); + fireEvent.click(expandAllIcon); + await waitFor(() => expect(screen.getAllByTestId('version-node').length).toBe(2)); + }); + + test('handle check version node to set rules and configs', async () => { + (usePrivileges).mockReturnValue({ + canCreateNetworkMap: true, + canViewTypologyList: true, + canReviewTypology: true, + }); + + getTypologies.mockResolvedValue({data: {data: mockData}} as any); + jest.spyOn(service, 'groupTypologies').mockReturnValueOnce([ + { + ...mockData[0], + versions: [mockData[0], mockData[1]] + }, + { + ...mockData[2], + versions: [], + } + ]); + + render(); + await waitFor(() => expect(screen.getByTestId('typology-drag-item-0')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryAllByTestId(/typology-node/).length).toBe(0));; + const flow = document.querySelector('.react-flow') as Element; + const typologyNode = screen.getByTestId('typology-drag-item-0'); + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn().mockImplementation((arg) => { + if (arg === 'type') { + return 'typology' + } + return JSON.stringify({...mockData[0], versions: [mockData[0], mockData[1]]}) + }), + }; + const preventDefault = jest.fn(); + fireEvent.dragStart(typologyNode, { dataTransfer }); + fireEvent.drop(flow, { + dataTransfer, + preventDefault, + }); + getTypology.mockResolvedValue({data: mockData[0]} as any); + expect(dataTransfer.setData).toHaveBeenCalledWith('application/reactflow', 'node'); + expect(dataTransfer.setData).toHaveBeenCalledWith('type', 'typology'); + await waitFor(() => expect(screen.getAllByTestId(/typology-node/).length).toBe(1)); + const expandAllIcon = screen.getByTestId('expand-all'); + fireEvent.click(expandAllIcon); + await waitFor(() => expect(screen.getAllByTestId('version-node').length).toBe(2)); + const versionNodeCheckBox = screen.getAllByTestId('version-node-check')[0]; + fireEvent.click(versionNodeCheckBox); + await waitFor(() => expect(screen.getAllByTestId('attached-rule').length).toBe(2)); + await waitFor(() => expect(screen.getAllByTestId('attached-config').length).toBe(2)); + + }); + + test('handle uncheck version node to remove rules and configs', async () => { + (usePrivileges).mockReturnValue({ + canCreateNetworkMap: true, + canViewTypologyList: true, + canReviewTypology: true, + }); + + getTypologies.mockResolvedValue({data: {data: mockData}} as any); + jest.spyOn(service, 'groupTypologies').mockReturnValueOnce([ + { + ...mockData[0], + versions: [mockData[0], mockData[1]] + }, + { + ...mockData[2], + versions: [], + } + ]); + + render(); + await waitFor(() => expect(screen.getByTestId('typology-drag-item-0')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryAllByTestId(/typology-node/).length).toBe(0));; + const flow = document.querySelector('.react-flow') as Element; + const typologyNode = screen.getByTestId('typology-drag-item-0'); + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn().mockImplementation((arg) => { + if (arg === 'type') { + return 'typology' + } + return JSON.stringify({...mockData[0], versions: [mockData[0], mockData[1]]}) + }), + }; + const preventDefault = jest.fn(); + fireEvent.dragStart(typologyNode, { dataTransfer }); + fireEvent.drop(flow, { + dataTransfer, + preventDefault, + }); + getTypology.mockResolvedValue({data: mockData[0]} as any); + expect(dataTransfer.setData).toHaveBeenCalledWith('application/reactflow', 'node'); + expect(dataTransfer.setData).toHaveBeenCalledWith('type', 'typology'); + await waitFor(() => expect(screen.getAllByTestId(/typology-node/).length).toBe(1)); + const expandAllIcon = screen.getByTestId('expand-all'); + fireEvent.click(expandAllIcon); + await waitFor(() => expect(screen.getAllByTestId('version-node').length).toBe(2)); + const versionNodeCheckBox = screen.getAllByTestId('version-node-check')[0]; + fireEvent.click(versionNodeCheckBox); + await waitFor(() => expect(screen.getAllByTestId('attached-rule').length).toBe(2)); + await waitFor(() => expect(screen.getAllByTestId('attached-config').length).toBe(2)); + //Uncheck Boxes + fireEvent.click(versionNodeCheckBox); + await waitFor(() => expect(screen.queryAllByTestId('attached-rule').length).toBe(0)); + await waitFor(() => expect(screen.queryAllByTestId('attached-config').length).toBe(0)); + + + }); +}); diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/index.tsx b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/index.tsx new file mode 100644 index 0000000..70a6273 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/index.tsx @@ -0,0 +1,649 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Create } from "./Create"; +import { IRule } from "~/domain/Rule/RuleDetailPage/service"; +import usePrivileges from "~/hooks/usePrivileges"; +import { useNodesState, useEdgesState, Position, addEdge, NodeMouseHandler, Node, ConnectionLineType, Edge } from "reactflow"; +import { IRuleConfig } from "~/domain/Rule/RuleConfig/RuleConfigList/types"; +import dagre from 'dagre'; +import { GroupedTypology, createNetworkMap, getTypologies, getTypology, groupTypologies } from "./service"; +import { Button, Modal, Result } from "antd"; +import { ITypology, RuleWithConfig } from "../../Typology/Score/service"; +import { IEvent } from "./Events"; +import { getRandomNumber } from "~/utils/getRandomNumberHelper"; +import { t } from "i18next"; +import Link from "next/link"; +import { useCommonTranslations } from "~/hooks"; + +export interface AttachedRules extends IRule { + attachedConfigs: IRuleConfig[]; +} +const nodeDefaults = { + sourcePosition: Position.Right, + targetPosition: Position.Left, + type: 'customNode' +}; +const nodeWidth = 172; +const nodeHeight = 36; +const initialEdges = [ + { + id: 'e1-2', + source: '1', + target: '2', + type: 'smoothstep' + }, + + { + id: 'e2-3', + source: '2', + target: '3', + type: 'smoothstep' + }, +]; +const initialNodes: Node[] = []; + +const NetworkMapPage = () => { + const [rules, setRules] = useState([]); + const [ruleOptions, setRuleOptions] = useState([]); + const [modal, contextHolder] = Modal.useModal(); + const [loadingTypologies, setLoadingTypologies] = useState(true); + const { canCreateNetworkMap, canViewTypologyList, canReviewTypology } = usePrivileges(); + const reactFlowWrapper = useRef(null); + const [selectedRule, setSelectedRuleIndex] = useState(null); + const [attachedRules, setAttachedRules] = useState([]); + const [ruleDragIndex, setRuleDragIndex] = useState(null); + const [saveLoading, setSaveLoading] = useState(false); + const [typologies, setTypologies] = useState([]); + const [typologyOptions, setTypologyOptions] = useState([]); + const [selectedTypology, setSelectedTypology] = useState(null); + const [events] = useState<{ label: string, disabled: boolean, value: string; color: string }[]>([{ label: 'PAIN.001', value: 'pain_001', disabled: false, color: 'gold' }, { label: 'PAIN.013', value: 'pain_013', disabled: false, color: 'magenta' }]); + const [eventOptions, setEventOptions] = useState([]); + const [selectedEvent, setSelectedEvent] = useState(null); + const [attacheEvents, setAttachedEvents] = useState([]); + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const [allExpanded, setAllExpanded] = useState(false); + const [showVersions, setShowVersions] = useState(false); + const [loadingAttached, setLoadingAttached] = useState(false); + const{t} = useCommonTranslations(); + + const handleError = (e: any) => { + modal.error({ + title: t('createEditNetworkMap.errorTitle'), content: e?.response?.data?.message || e?.message || t('createEditNetworkMap.errorMessage'), + okButtonProps: { + style: { + backgroundColor: 'red', + } + } + }); + } + + const handleSave = async () => { + if (!attachedRules.length) { + modal.error({ + title: t('createEditNetworkMap.errorTitle'), content: t('createEditNetworkMap.pleaseAddTypology'), + okButtonProps: { + style: { + backgroundColor: 'red', + } + } + }); + + return; + } + type ITypologyData = Partial & { active: boolean, id: string }; + type ITypologyDataExtended = ITypology & { typologyId: string; typology: ITypology } + const obj: { [k: string]: ITypologyData } = {} + attachedRules.forEach((option, i) => { + const item = option as unknown as ITypologyDataExtended; + if (obj[item.typologyId]) { + const currentRules = obj[item.typologyId]?.ruleWithConfigs || []; + const newList: RuleWithConfig[] = []; + (item?.typology?.ruleWithConfigs || []).forEach((r) => { + const exists = (currentRules || []).find((option) => r.rule._key == option.rule._key); + if (!exists) { + newList.push(r); + } + }); + obj[item.typologyId] = { + ...obj[item.typologyId], + ruleWithConfigs: [...currentRules, ...newList], + } + + } else { + obj[item.typologyId] = { + name: item.typology.name, + cfg: item.typology.cfg, + id: item.typology._key, + active: true, + ruleWithConfigs: item.typology.ruleWithConfigs, + } + } + }); + + const data = { + active: false, + cfg: "1.0.0", + events: [ + { + eventId: 'PAN001', + typologies: Object.values(obj), + } + ] + } + try { + setSaveLoading(true); + await createNetworkMap(data); + modal.success({ + title: t('createEditNetworkMap.successTitle'), content: t('createEditNetworkMap.networkMapCreated'), + okButtonProps: { + style: { + backgroundColor: 'red', + } + } + }); + } catch (e: any) { + modal.error({ + title: 'Error', content: e?.response?.data?.message || e?.message || t('createEditNetworkMap.errorMessage'), + okButtonProps: { + style: { + backgroundColor: 'red', + } + } + }); + } finally { + setSaveLoading(false); + } + } + const handleExpandEvent = useCallback((id: string) => { + setNodes((prev) => { + let currentNodes = prev; + const nodeToUpdateIndex = prev.findIndex((n) => n.id === id); + if (nodeToUpdateIndex !== -1) { + let node = currentNodes[nodeToUpdateIndex]; + if (node.data.expanded) { + node.data = { + ...node.data, + expanded: false, + } + currentNodes[nodeToUpdateIndex] = node; + currentNodes = currentNodes.filter((n) => n.data.eventId !== node.id); + setNodes([...currentNodes]); + setEdges([...edges.filter((e) => e.source !== node.id)]); + updateLayout([...currentNodes], [...edges.filter((e) => e.source !== node.id)]); + return currentNodes; + + } else { + node.data = { + ...node.data, + expanded: true, + } + currentNodes[nodeToUpdateIndex] = node; + const typologyNode = { + + id: getRandomNumber(1000).toString(), + position: { x: 300, y: 200 }, + width: 300, + height: 400, + data: { + label: 'Typology 2', + showDelete: false, + handleExpand: handleExpandEvent, + handleExpandVersions, + expanded: false, + eventId: '1', + }, + ...nodeDefaults, + type: 'typologyNode', + } + const edge = { + id: getRandomNumber(1000).toString(), + source: node.id, + target: typologyNode.id, + type: 'smoothstep' + } + + currentNodes.push(typologyNode); + setEdges([...edges, edge]); + updateLayout([...currentNodes], [...edges, edge]); + return [...currentNodes]; + } + + } + return prev; + }); + }, []); + + const handleExpandTypology = (id: string) => { + setShowVersions(!showVersions); + } + + const setRulesAndConfigs = (id: string) => { + setLoadingAttached(true); + if (canReviewTypology) { + getTypology(id) + .then(({ data }) => { + setAttachedRules((prev) => { + return [ + ...prev, + ...((data.ruleWithConfigs || []).map((d: RuleWithConfig) => ({ ...d, typologyId: data._key, typology: data }))) + + ] + }); + }).catch((e) => { + modal.error({ + title: 'Error', content: "Couldn't get rules and configurations", + okButtonProps: { + style: { + backgroundColor: 'red', + } + } + }); + }).finally(() => { + setLoadingAttached(false); + }); + } + + } + + const removeRulesAndConfigs = (data: ITypology & { typologyId: string }) => { + setAttachedRules((prev) => { + return [...prev.filter((r) => r.typologyId !== data._key)]; + }); + } + + const expandAll = () => { + if (nodes.length === 1) { + return; + } + if (!allExpanded) { + setAllExpanded(true); + let currentNodes = nodes.map((n) => { + if (n.type === 'typologyNode') { + return { + ...n, + data: { + ...n.data, + expanded: true, + } + } + } + return n; + }); + const newNodes: Node[] = []; + const newEdges: Edge[] = []; + currentNodes.filter((n) => n.type === 'typologyNode').forEach((node) => { + (node?.data?.versions || []).forEach((t: ITypology, i: number) => { + const typology = t; + if (typology) { + const dataId = (getRandomNumber(100000000) + i).toString(); + const versionNode = { + id: dataId, + data: { + label: `Version ${typology.cfg}`, + checked: node?.data?.lastCheckedId === typology._key, + typologyNodeId: node.id, + id: dataId, + handleCheck, + ...typology, + }, + ...nodeDefaults, + type: 'versionNode', + position: { + x: node.position.x + 500, + y: node.position.y + (50 * (i + 1)) + }, + + } + const edge = { + id: getRandomNumber(1000).toString(), + source: node.id, + target: versionNode.id, + type: 'smoothstep', + data: { type: 'versionNode' } + } + newNodes.push(versionNode); + newEdges.push(edge); + } + }); + + }); + setEdges((prev) => { + return [...prev, ...newEdges]; + }); + updateLayout([...currentNodes, ...newNodes], [...edges, ...newEdges]); + setNodes([...currentNodes, ...newNodes]); + } else { + setAllExpanded(false); + let currentNodes = nodes.map((n) => { + if (n.type === 'typologyNode') { + return { + ...n, + data: { + ...n.data, + expanded: false, + } + } + } + return n; + }); + currentNodes = currentNodes.filter((n) => n.type !== 'versionNode'); + const currentEdges = edges.filter((e) => e?.data?.type !== 'versionNode'); + setNodes([...currentNodes]); + setEdges([...currentEdges]); + updateLayout([...currentNodes], [...currentEdges]); + }; + }; + + const handleExpandVersions = useCallback((id: string, expanded: boolean) => { + setNodes((prev) => { + let currentNodes = prev; + const nodeToUpdateIndex = prev.findIndex((n) => n.id == id); + if (nodeToUpdateIndex !== -1) { + let node = currentNodes[nodeToUpdateIndex]; + if (expanded) { + node.data = { + ...node.data, + expanded: false, + } + currentNodes[nodeToUpdateIndex] = node; + currentNodes = currentNodes.filter((n) => n.data.typologyNodeId !== node.id); + setNodes([...currentNodes]); + setEdges((prevEdges) => { + return [...prevEdges.filter((e) => e.source !== node.id)] + }); + updateLayout([...currentNodes], [...edges.filter((e) => e.source !== node.id)]); + return currentNodes; + + } else { + node.data = { + ...node.data, + expanded: true, + } + currentNodes[nodeToUpdateIndex] = node; + const newNodes: Node[] = []; + const newEdges: Edge[] = []; + (node.data?.versions || []).forEach((v: ITypology, i: number) => { //List of versions + const dataId = getRandomNumber(10000).toString(); + const versionNode = { + id: dataId, + data: { + label: `Version ${v.cfg}`, + checked: node?.data?.lastCheckedId === v._key, + typologyNodeId: node.id, + id: dataId, + handleCheck, + ...v + }, + ...nodeDefaults, + type: 'versionNode', + position: { + x: node.position.x + 500, + y: node.position.y + (50 * (i + 1)) + }, + + } + const edge = { + id: getRandomNumber(1000).toString(), + source: node.id, + target: versionNode.id, + type: 'smoothstep', + data: { type: 'versionNode' } + } + newNodes.push(versionNode); + newEdges.push(edge); + }); + setEdges((prev) => { + return [...prev, ...newEdges]; + }); + updateLayout([...currentNodes, ...newNodes], [...edges, ...newEdges]); + return [...currentNodes.concat(newNodes)]; + } + } + return prev; + }); + + + }, []); + + const handleCheck = (data: any, checked: boolean) => { + setNodes((prev) => { + let currentNodes = prev; + const parentNodeIndex = prev.findIndex((n) => n.id === data.typologyNodeId); + const index = prev.findIndex((n) => n.id === data.id); + currentNodes = currentNodes.map((n) => { + if (n.data?.typologyNodeId === data.typologyNodeId) { + return { + ...n, + data: { + ...n.data, + checked: false, + } + } + } + return n; + }); + if (index !== -1) { + currentNodes[index].data = { + ...currentNodes[index].data, + checked + } + if (checked) { + setRulesAndConfigs(data._key); + currentNodes[parentNodeIndex].data = { + ...currentNodes[parentNodeIndex].data, + lastCheckedId: data._key + } + } else { + removeRulesAndConfigs(data); + currentNodes[parentNodeIndex].data = { + ...currentNodes[parentNodeIndex].data, + lastCheckedId: null, + } + } + return [...currentNodes]; + } + return [...prev]; + }) + } + + const onConnect = useCallback( + (params: any) => + setEdges((eds) => + addEdge({ ...params, type: ConnectionLineType.SmoothStep, animated: true }, eds) + ), + [] + ); + + useEffect(() => { + const newNodes = [...initialNodes, { + id: '1', + position: { x: 0, y: 150 }, + data: { + label: 'Event', showDelete: false, expanded: false, handleExpand: + handleExpandEvent + }, + ...nodeDefaults, + type: 'eventNode' + + }, + ] + setNodes([...newNodes]); + }, [initialNodes]); + + useEffect(() => { + if (canViewTypologyList) { + setLoadingTypologies(true); + getTypologies(1) + .then(({ data }) => { + const grouped = groupTypologies(data.data || []); + setTypologies(grouped); + }).catch((e) => { + handleError(e.response?.data?.message || e.message); + }).finally(() => { + setLoadingTypologies(false) + }); + } + + }, [canViewTypologyList]); + + const onNodeClick: NodeMouseHandler = (_event, node) => { + if (node.type === 'customNode' && node.data.type === 'rule') { + setSelectedRuleIndex(node.id); + } + } + + const handleDelete = useCallback((id: string, data: ITypology & { lastCheckedId: string }) => { + setSelectedRuleIndex(null); + + setNodes((prev) => { + const newNodes = prev.filter((n) => id !== n.id) + .filter((n) => n.data.typologyNodeId !== id); + return newNodes; + }); + + setEdges((prev) => { + const newEdges = prev.filter((r) => r.target !== id) + .filter((r) => r.source !== id); + return newEdges; + }); + const currentNode = typologies.find((n) => n._key === id); + + setTypologyOptions((prev) => { + if (currentNode) { + return [ + ...prev, + currentNode, + ] + } + return prev; + + }); + + setAttachedRules((prev) => { + return prev.filter((t) => t.typologyId !== data.lastCheckedId); + }) + }, [nodes, attachedRules, edges, typologies]); + + + const updateLayout = (nodes: any[], edges: any[]) => { + const graph = new dagre.graphlib.Graph(); + graph.setGraph({ rankdir: 'LR' }); + graph.setDefaultEdgeLabel(() => ({})); + + nodes.forEach(node => { + graph.setNode(node.id, { width: node.width || nodeWidth, height: node.height || nodeHeight }); // Set width and height for each node + }); + + edges.forEach(edge => { + graph.setEdge(edge.source, edge.target); // Add edges to the graph + }); + + dagre.layout(graph); // Apply Dagre layout algorithm + + // Update positions of nodes based on Dagre layout + const layoutedNodes = nodes.map(node => ({ + ...node, + position: { + x: graph.node(node.id).x - (node.width || nodeWidth) / 2, + y: graph.node(node.id).y - (node.height || nodeHeight) / 2 + } + })); + setNodes(layoutedNodes); + } + + const onDrop = useCallback((event: any) => { + event.preventDefault(); + setRuleDragIndex(null); + const data = event.dataTransfer.getData('data'); + const typology = JSON.parse(data); + const [lastNode] = nodes.filter((n) => n.type === 'typologyNode'); + const typologyNode = { + ...nodeDefaults, + id: typology._key, + type: 'typologyNode', + data: { + ...typology, + label: typology.name.slice(0, 25), + handleExpandVersions, + handleExpand: handleExpandTypology, + handleDelete, + }, + position: { + x: 1000, + y: lastNode ? (lastNode?.position?.y || 0) + (-300 * nodes.length) : -800 + }, + width: 350, + height: 350, + }; + const typologyEdge = { + id: getRandomNumber(10000).toString(), + source: '1', + target: typologyNode.id, + type: 'smoothstep', + + }; + setNodes([...nodes, typologyNode]); + setEdges([...edges, typologyEdge]); + updateLayout([...nodes, typologyNode], [...edges, typologyEdge]); + setTypologyOptions((prev) => { + return [...prev.filter((t) => t._key !== typology._key)] + }); + }, [nodes, rules, attachedRules, edges, handleDelete, typologies]); + + if (!canCreateNetworkMap || !canReviewTypology || !canViewTypologyList) { + const message = !canCreateNetworkMap ? + t('createEditNetworkMap.accessDeniedMessage') : + !canViewTypologyList ? + t('createEditNetworkMap.typologyListPermissionError') : + t('createEditNetworkMap.typologyDetailsPermissionError') + return {t('createEditNetworkMap.backToHomePage')}} + /> + } + return <> + {contextHolder} + +} +export default NetworkMapPage; + + diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/mock.ts b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/mock.ts new file mode 100644 index 0000000..d9bce63 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/mock.ts @@ -0,0 +1,413 @@ +export const mockData = [ + { + "_id": "typology/5a3d7cbf-02e8-458b-b66c-a2ccb1f1fcb7", + "_key": "5a3d7cbf-02e8-458b-b66c-a2ccb1f1fcb7", + "_rev": "_h9G87jy---", + "cfg": "0.1.0", + "createdAt": "2024-06-07T10:27:10.009Z", + "desc": "aada", + "name": "Typology 1", + "ownerId": "test@gmail.com", + "rules_rule_configs": [ + { + "ruleId": "rule/32b24092-ede5-4892-9780-19c08b2badbe", + "ruleConfigId": [ + "rule_config/4c07cfe2-4a87-4bfc-a056-c69170c2668a" + ] + }, + { + "ruleId": "rule/5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "ruleConfigId": [ + "rule_config/78f59326-5fa2-44ab-a23a-aeb0945413bf" + ] + } + ], + "state": "01_DRAFT", + "typologyCategoryUUID": [], + "updatedAt": "2024-06-07T10:27:10.009Z", + "ruleWithConfigs": [ + { + "rule": { + "_id": "rule/32b24092-ede5-4892-9780-19c08b2badbe", + "_key": "32b24092-ede5-4892-9780-19c08b2badbe", + "name": "rule-001", + "cfg": "1.0.0" + }, + "ruleConfigs": [ + { + "_id": "rule_config/4c07cfe2-4a87-4bfc-a056-c69170c2668a", + "_key": "4c07cfe2-4a87-4bfc-a056-c69170c2668a", + "cfg": "2.1.1", + "ruleId": "32b24092-ede5-4892-9780-19c08b2badbe", + "config": { + "exitConditions": [ + { + "reason": "Unsuccessful transaction", + "subRuleRef": ".x00", + "outcome": true + }, + { + "reason": "Insufficient transaction history. At least 50 historical transactions are required", + "subRuleRef": ".x01", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions shows an increase", + "subRuleRef": ".x03", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "bands": [], + "cases": [], + "parameters": [ + { + "ParameterType": "number", + "ParameterName": "aa" + } + ] + } + } + ] + }, + { + "rule": { + "_id": "rule/5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "_key": "5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "name": "rule-002", + "cfg": "1.0.0" + }, + "ruleConfigs": [ + { + "_id": "rule_config/78f59326-5fa2-44ab-a23a-aeb0945413bf", + "_key": "78f59326-5fa2-44ab-a23a-aeb0945413bf", + "cfg": "2.1.1", + "ruleId": "5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "config": { + "exitConditions": [ + { + "reason": "Unsuccessful transaction", + "subRuleRef": ".x00", + "outcome": true + }, + { + "reason": "Insufficient transaction history. At least 50 historical transactions are required", + "subRuleRef": ".x01", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions shows an increase", + "subRuleRef": ".x03", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "bands": [ + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "cases": [ + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "parameters": [ + { + "ParameterType": "number", + "ParameterName": "1" + } + ] + } + } + ] + } + ] +}, +{ + "_id": "typology/5a3d7cbf-02e8-458b-b66c-a2ccb1f1f999", + "_key": "5a3d7cbf-02e8-458b-b66c-a2ccb1f1f999", + "_rev": "_h9G87jy---", + "cfg": "0.1.0", + "createdAt": "2024-06-07T10:27:10.009Z", + "desc": "aada", + "name": "Typology 1", + "ownerId": "test@gmail.com", + "rules_rule_configs": [ + { + "ruleId": "rule/32b24092-ede5-4892-9780-19c08b2111", + "ruleConfigId": [ + "rule_config/4c07cfe2-4a87-4bfc-a056-c69170c26611a" + ] + }, + { + "ruleId": "rule/5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "ruleConfigId": [ + "rule_config/78f59326-5fa2-44ab-a23a-aeb0945413bf" + ] + } + ], + "state": "01_DRAFT", + "typologyCategoryUUID": [], + "updatedAt": "2024-06-07T10:27:10.009Z", + "ruleWithConfigs": [ + { + "rule": { + "_id": "rule/32b24092-ede5-4892-9780-19c08b2b559", + "_key": "32b24092-ede5-4892-9780-19c08b2b559", + "name": "rule-001", + "cfg": "1.0.0" + }, + "ruleConfigs": [ + { + "_id": "rule_config/4c07cfe2-4a87-4bfc-a056-c69170c2668a", + "_key": "4c07cfe2-4a87-4bfc-a056-c69170c2668a", + "cfg": "2.1.1", + "ruleId": "32b24092-ede5-4892-9780-19c08b2b559", + "config": { + "exitConditions": [ + { + "reason": "Unsuccessful transaction", + "subRuleRef": ".x00", + "outcome": true + }, + { + "reason": "Insufficient transaction history. At least 50 historical transactions are required", + "subRuleRef": ".x01", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions shows an increase", + "subRuleRef": ".x03", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "bands": [], + "cases": [], + "parameters": [ + { + "ParameterType": "number", + "ParameterName": "aa" + } + ] + } + } + ] + }, + { + "rule": { + "_id": "rule/5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "_key": "5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "name": "rule-002", + "cfg": "1.0.0" + }, + "ruleConfigs": [ + { + "_id": "rule_config/78f59326-5fa2-44ab-a23a-aeb0945413bf", + "_key": "78f59326-5fa2-44ab-a23a-aeb0945413bf", + "cfg": "2.1.1", + "ruleId": "5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "config": { + "exitConditions": [ + { + "reason": "Unsuccessful transaction", + "subRuleRef": ".x00", + "outcome": true + }, + { + "reason": "Insufficient transaction history. At least 50 historical transactions are required", + "subRuleRef": ".x01", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions shows an increase", + "subRuleRef": ".x03", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "bands": [ + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "cases": [ + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "parameters": [ + { + "ParameterType": "number", + "ParameterName": "1" + } + ] + } + } + ] + } + ] +}, +{ + "_id": "typology/5a3d7cbf-02e8-458b-b66c-1032", + "_key": "5a3d7cbf-02e8-458b-b66c-1032", + "_rev": "_h9G87jy---", + "cfg": "1.1.3", + "createdAt": "2024-06-07T10:27:10.009Z", + "desc": "aada", + "name": "Typology 2", + "ownerId": "test@gmail.com", + "rules_rule_configs": [ + { + "ruleId": "rule/32b24092-ede5-4892-9780-19c08b2badbe", + "ruleConfigId": [ + "rule_config/4c07cfe2-4a87-4bfc-a056-c69170c2668a" + ] + }, + { + "ruleId": "rule/5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "ruleConfigId": [ + "rule_config/78f59326-5fa2-44ab-a23a-aeb0945413bf" + ] + } + ], + "state": "01_DRAFT", + "typologyCategoryUUID": [], + "updatedAt": "2024-06-07T10:27:10.009Z", + "ruleWithConfigs": [ + { + "rule": { + "_id": "rule/32b24092-ede5-4892-9780-19c08b2badbe", + "_key": "32b24092-ede5-4892-9780-19c08b2badbe", + "name": "rule-001", + "cfg": "1.0.0" + }, + "ruleConfigs": [ + { + "_id": "rule_config/4c07cfe2-4a87-4bfc-a056-c69170c2668a", + "_key": "4c07cfe2-4a87-4bfc-a056-c69170c2668a", + "cfg": "2.1.1", + "ruleId": "32b24092-ede5-4892-9780-19c08b2badbe", + "config": { + "exitConditions": [ + { + "reason": "Unsuccessful transaction", + "subRuleRef": ".x00", + "outcome": true + }, + { + "reason": "Insufficient transaction history. At least 50 historical transactions are required", + "subRuleRef": ".x01", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions shows an increase", + "subRuleRef": ".x03", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "bands": [], + "cases": [], + "parameters": [ + { + "ParameterType": "number", + "ParameterName": "aa" + } + ] + } + } + ] + }, + { + "rule": { + "_id": "rule/5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "_key": "5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "name": "rule-002", + "cfg": "1.0.0" + }, + "ruleConfigs": [ + { + "_id": "rule_config/78f59326-5fa2-44ab-a23a-aeb0945413bf", + "_key": "78f59326-5fa2-44ab-a23a-aeb0945413bf", + "cfg": "2.1.1", + "ruleId": "5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "config": { + "exitConditions": [ + { + "reason": "Unsuccessful transaction", + "subRuleRef": ".x00", + "outcome": true + }, + { + "reason": "Insufficient transaction history. At least 50 historical transactions are required", + "subRuleRef": ".x01", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions shows an increase", + "subRuleRef": ".x03", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "bands": [ + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "cases": [ + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "parameters": [ + { + "ParameterType": "number", + "ParameterName": "1" + } + ] + } + } + ] + } + ] +}, +] \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/service.ts b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/service.ts new file mode 100644 index 0000000..ab0d273 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/service.ts @@ -0,0 +1,39 @@ +import { Api } from "~/client" +import { ITypology } from "~/domain/Typology/List/service" + +export interface GroupedTypology extends ITypology { + versions: ITypology[] +} + +export const createNetworkMap = (data: any) => { + return Api.post('/network-map', {...data}) +} + +export const getTypologies = (page: number) => { + return Api.get(`/typology?page=${page}&limit=9999 `) +} + +export const getTypology = (id: string) => { + return Api.get(`/typology/${id}`); +} + + +export const groupTypologies = (typologies: ITypology[]) => { + const grouped: {[k: string]: GroupedTypology} = {}; + + typologies.forEach((typology) => { + if(grouped[typology.name]) { + grouped[typology.name] = { + ...grouped[typology.name], + versions: [...(grouped[typology.name]?.versions || []), typology] + } + } else { + grouped[typology.name] = { + ...typology, + versions: [typology] + } + } + }); + + return Object.values(grouped); +} \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/style.module.scss b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/style.module.scss new file mode 100644 index 0000000..66e08a9 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/CreateEdit/style.module.scss @@ -0,0 +1,45 @@ +.input-border { + border-color: #56b453; +} + +.custom-node { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + background: #fff; + border: 1px solid #ddd; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + min-width: 10rem; + width: 100; +} + +.custom-node-content { + display: flex; + align-items: center; +} + +.minus { + position: relative; + left: 1.1rem; + z-index: 100000; + color: #54B352; + font-size: 1.2rem; + cursor: pointer; +} + +.hidden-handle { + width: 0; + height: 0; + background: none; + border: none; +} + +.delete-icon { + margin-right: 10px; + color: #ff4d4f; + cursor: pointer; + font-size: 1.3rem; + +} \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/List/Index.tsx b/packages/config-svc-fe/src/domain/Networkmap/List/Index.tsx new file mode 100644 index 0000000..987de7a --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/List/Index.tsx @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useState } from 'react'; +import List from './List'; +import { getRules } from './service'; +import { useAuth } from '~/context/auth'; +import usePrivileges from '~/hooks/usePrivileges'; +import AccessDeniedPage from '~/components/common/AccessDenied'; + +const NetworkMapList = () => { + const [networkMaps, setNetworkMaps] = useState([]); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [totalItems, setTotalItems] = useState(0) + const {profile} = useAuth(); + const {canViewRules} = usePrivileges(); + + const onPageChange = useCallback((newPage: number) => { + setPage(newPage); + }, []); + + const fetchRules = useCallback(() => { + setError(''); + setLoading(true); + getRules({ page, limit: 10 }) + .then(({ data }) => { + setNetworkMaps(data?.rules || []); + setTotalItems(data.count || 0); + }).finally(() => { + setLoading(false) + }).catch((e) => { + setError(e.response?.data?.message || e?.message || 'Something went wrong getting rules'); + }) + }, [page]); + + useEffect(() => { + if(canViewRules) { + fetchRules(); + } + }, [fetchRules, canViewRules]); + + const retry = (pageNumber?: number) => { + if(pageNumber) { + setPage(pageNumber); + } + fetchRules(); + } + + + if(!canViewRules) { + return + } + + return +} + +export default NetworkMapList; \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/List/List.tsx b/packages/config-svc-fe/src/domain/Networkmap/List/List.tsx new file mode 100644 index 0000000..d895a5f --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/List/List.tsx @@ -0,0 +1,193 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Input, Space, Table } from 'antd'; +import type { TableColumnsType } from 'antd'; +import styles from './styles.module.scss'; +import { useCommonTranslations } from '~/hooks'; +import { uniqueArray } from '~/utils/uniqueItems'; +import { IUserProfile } from '~/context/auth'; +import Link from 'next/link'; + + +interface Props { + loading: boolean; + error: string; + retry(page?: number): void; + page: number, + data: any[], + total: number; + onPageChange(page: number, pageSize: number): void, + user: IUserProfile; +} + +const NetworkMapList: React.FunctionComponent = ({ + loading, + error, + retry, + data, + total, + onPageChange, + page, + user +}) => { + const { t: commonTranslations } = useCommonTranslations(); + const [searchText, setSearchText] = useState(''); + const [networkMaps, setNetworkMaps] = useState([]); + + const canCreate = useMemo(() => { + return user?.privileges?.includes('SECURITY_CREATE_RULE') + }, [user]); + + const canEdit = useMemo(() => { + return user?.privileges?.includes('SECURITY_UPDATE_RULE') + }, [user]); + + + useEffect(() => { + setNetworkMaps([...data]); + }, [data]) + + const handleSearch = useCallback((confirm: () => void) => { + if (searchText.trim().length) { + setNetworkMaps( + ...[data.filter((rule) => + rule.desc.toLowerCase().includes(searchText.toLowerCase()) + )] + ); + confirm(); + + } + }, [searchText]); + + + const columns: TableColumnsType = useMemo(() => { + return [ + { + title: commonTranslations('rulesListPage.table.name'), + dataIndex: 'name', + showSorterTooltip: { target: 'full-header' }, + sorter: (a: any, b: any) => a.state.localeCompare(b.name), + filters: uniqueArray(data, 'cfg').map((obj) => ({ text: obj.name, value: obj.name.toLowerCase() })), + onFilter: (value, record) => record.name.toLowerCase().includes(value as string), + + }, + { + title: commonTranslations('rulesListPage.table.version'), + dataIndex: 'cfg', + showSorterTooltip: { target: 'full-header' }, + sorter: (a: any, b: any) => a.state.localeCompare(b.cfg), + filters: uniqueArray(data, 'cfg').map((obj) => ({ text: obj.cfg, value: obj.cfg.toLowerCase() })), + onFilter: (value, record) => record.cfg.toLowerCase().includes(value as string), + + }, + { + title: commonTranslations('rulesListPage.table.description'), + dataIndex: 'desc', + defaultSortOrder: 'descend', + sorter: (a: any, b: any) => a.state.localeCompare(b.desc), + onFilter: (value, record) => record.desc.toLowerCase().includes(value as string), + filterDropdown: ({ confirm, clearFilters }: any) => ( +
+ setSearchText(e.target.value)} + onPressEnter={() => handleSearch(confirm)} + className={styles['input-description-search']} + + /> + + + + +
+ ), + }, + { + title: commonTranslations('rulesListPage.table.state'), + dataIndex: 'state', + defaultSortOrder: 'descend', + sorter: (a: any, b: any) => a.state.localeCompare(b.state), + onFilter: (value, record) => record.state.toLowerCase().includes(value as string), + filters: uniqueArray(data, 'state').map((obj) => ({ text: obj.state, value: obj.state.toLowerCase() })), + + }, + { + title: commonTranslations('rulesListPage.table.owner'), + dataIndex: 'ownerId', + defaultSortOrder: 'descend', + sorter: (a: any, b: any) => a.state.localeCompare(b.ownerId), + onFilter: (value, record) => record.ownerId.toLowerCase().includes(value as string), + filters: uniqueArray(data, 'ownerId').map((obj) => ({ text: obj.ownerId, value: obj.ownerId.toLowerCase() })), + + }, + { + title: commonTranslations('rulesListPage.table.updatedAt'), + dataIndex: 'updatedAt', + render: (text: string) => new Date(text).toDateString(), + sorter: (a: any, b: any) => a.createdAt.localeCompare(b.updatedAt) + }, + { + title: commonTranslations('rulesListPage.table.action'), + key: 'action', + render: (_, record) => ( + + {canEdit && {commonTranslations('rulesListPage.table.modify')} } + + ), + }, + ]; + }, [commonTranslations, data, searchText, canEdit]) + return ( + <> + {canCreate ?
+ + + +
:
} + + {error && retry()}> + {commonTranslations('rulesListPage.retry')} + + } + />} + + + + ) +} + +export default NetworkMapList; diff --git a/packages/config-svc-fe/src/domain/Networkmap/List/service.ts b/packages/config-svc-fe/src/domain/Networkmap/List/service.ts new file mode 100644 index 0000000..e004b8d --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/List/service.ts @@ -0,0 +1,16 @@ +import { Api } from "~/client" + +/** + * + * @param page + * @param limit + * @returns {data: any[], count: number} + */ +export const getRules = ({page = 1, limit = 10 }) => { + return Api.get('/rule', { + params: { + page, + limit + } + }); +} \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Networkmap/List/styles.module.scss b/packages/config-svc-fe/src/domain/Networkmap/List/styles.module.scss new file mode 100644 index 0000000..bd6f86e --- /dev/null +++ b/packages/config-svc-fe/src/domain/Networkmap/List/styles.module.scss @@ -0,0 +1,6 @@ +.create-button { + background-color: #2596be; + color: white; + margin-bottom: 1rem; + margin-left: .5rem; +} diff --git a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Bands.tsx b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Bands.tsx index 1b7e24f..c26f469 100644 --- a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Bands.tsx +++ b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Bands.tsx @@ -445,10 +445,10 @@ export const ValueField: React.FunctionComponent = ({ dataType = 'nu props.setTimeValues((prevState: any) => { const updatedTimeValues = [...prevState]; updatedTimeValues[props.index] = { - hours: timeObj.hours, - days: timeObj.days, - minutes: timeObj.minutes, - seconds: timeObj.seconds + hours: timeObj.hours <= 0 ? Math.abs(timeObj.hours) : timeObj.hours, + days: timeObj.days <= 0 ? Math.abs(timeObj.days) : timeObj.days, + minutes: timeObj.minutes <= 0 ? Math.abs(timeObj.minutes) : timeObj.minutes, + seconds: timeObj.seconds <= 0 ? Math.abs(timeObj.seconds) : timeObj.seconds }; return updatedTimeValues; }); diff --git a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Cases.tsx b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Cases.tsx index 60516f3..8913e40 100644 --- a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Cases.tsx +++ b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Cases.tsx @@ -167,15 +167,15 @@ export const ValueField = ({ dataType = 'numeric', ...props }) => { const convertEpochTime = useCallback((time: number) => { return convertMillisecondsToDHMS(time); - }, []) + }, []); const handleEpochTimeChange = (time: number | null) => { setEpochTime(time); const timeObj = convertEpochTime(time as number); - setHours(timeObj.hours); - setMinutes(timeObj.minutes); - setDays(timeObj.days); - setSeconds(timeObj.seconds); + setHours(timeObj.hours <= 0 ? Math.abs(timeObj.hours) : timeObj.hours); + setMinutes(timeObj.minutes <= 0 ? Math.abs(timeObj.minutes) : timeObj.minutes); + setDays(timeObj.days <= 0 ? Math.abs(timeObj.days) : timeObj.days); + setSeconds(timeObj.seconds <= 0 ? Math.abs(timeObj.seconds) : timeObj.seconds); props.onChange(time); } @@ -231,7 +231,7 @@ export const ValueField = ({ dataType = 'numeric', ...props }) => { }; - if (dataType === 'NUMERIC') { + if (dataType === 'NUMERIC' || dataType === 'CURRENCY') { return } diff --git a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Information.tsx b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Information.tsx index 6c4edac..eb4d655 100644 --- a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Information.tsx +++ b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Information.tsx @@ -150,7 +150,6 @@ export const Information: React.FunctionComponent = ({ formState, handle checked={field.value} onChange={() => { const value = !field.value - console.log({value}, 'case'); field.onChange(value); setValue('category', value ? 'isCase' : ''); if(value && isBand.value) { diff --git a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Parameters.tsx b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Parameters.tsx index b9ea09a..fd19360 100644 --- a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Parameters.tsx +++ b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/Parameters.tsx @@ -21,9 +21,9 @@ export const Parameters: FunctionComponent = ({ parameterFields, formSta const handlePrepend = React.useCallback((index: number) => { if (index === 0) { - parameterFields.append({ ParameterName: '', ParameterType: '', ParameterValue: '' }) + parameterFields.prepend({ ParameterName: '', ParameterType: '', ParameterValue: '' }) } else { - parameterFields.insert(index - 1, { ParameterName: '', ParameterType: '', ParameterValue: '' }); + parameterFields.insert(index, { ParameterName: '', ParameterType: '', ParameterValue: '' }); } }, [parameterFields.prepend, parameterFields.insert]) @@ -41,7 +41,7 @@ export const Parameters: FunctionComponent = ({ parameterFields, formSta return (
{parameterFields.fields.map((field, index) => ( - <> +
parameterFields.remove(index)} /> @@ -110,7 +110,7 @@ export const Parameters: FunctionComponent = ({ parameterFields, formSta style={{ fontSize: '1.2rem', cursor: 'pointer' }} />
- +
))} {formState.errors?.parameters?.message} diff --git a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Bands.spec.tsx b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Bands.spec.tsx index d35cee4..3bee60c 100644 --- a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Bands.spec.tsx +++ b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Bands.spec.tsx @@ -377,7 +377,7 @@ describe('ValueField component', () => { fireEvent.change(secondInput, { target: { value: '1' } }); // Check if onChange function is called with the correct value - expect(onChange).toHaveBeenCalledWith(1); + expect(onChange).toHaveBeenCalledWith(1000); }); @@ -388,7 +388,7 @@ describe('ValueField component', () => { const hourInput = getByTestId('hours-input'); fireEvent.change(hourInput, { target: { value: '1' } }); - expect(onChange).toHaveBeenCalledWith(3600); + expect(onChange).toHaveBeenCalledWith(3600000); }); @@ -400,7 +400,7 @@ describe('ValueField component', () => { const MinuteInput = getByTestId('minutes-input'); fireEvent.change(MinuteInput, { target: { value: '1' } }); - expect(onChange).toHaveBeenCalledWith(60); + expect(onChange).toHaveBeenCalledWith(60000); }); @@ -409,9 +409,9 @@ describe('ValueField component', () => { const setTimeValues = jest.fn(); const { getByTestId } = render(); const dayInput = getByTestId('days-input'); - fireEvent.change(dayInput, { target: { value: '1' } }); + fireEvent.change(dayInput, { target: { value: '30' } }); - expect(onChange).toHaveBeenCalledWith(86400); + expect(onChange).toHaveBeenCalledWith(2592000000); }); diff --git a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Cases.spec.tsx b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Cases.spec.tsx index ba17739..56628d1 100644 --- a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Cases.spec.tsx +++ b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Cases.spec.tsx @@ -264,19 +264,19 @@ describe('ValueField component', () => { // Change days fireEvent.change(screen.getByTestId('days-input'), { target: { value: 1 } }); - expect(onChange).toHaveBeenCalledWith(86400); // 1 day = 24 hours * 60 minutes * 60 seconds = 86400 seconds + expect(onChange).toHaveBeenCalledWith(86400000); // 1 day = 24 hours * 60 minutes * 60 seconds = 86400000 seconds // Change hours fireEvent.change(screen.getByTestId('hours-input'), { target: { value: 24 } }); - expect(onChange).toHaveBeenCalledWith(86400); // 2 days = 48 hours * 60 minutes * 60 seconds = 172800 seconds + expect(onChange).toHaveBeenCalledWith(86400000); // 2 days = 48 hours * 60 minutes * 60 seconds = 172800 seconds // // Change minutes fireEvent.change(screen.getByTestId('minutes-input'), { target: { value: 30 } }); - expect(onChange).toHaveBeenCalledWith(86400); // 2 days, 2 hours, 30 minutes = (48*60*60) + (2*60*60) + (30*60) = 180300 seconds + expect(onChange).toHaveBeenCalledWith(86400000); // 2 days, 2 hours, 30 minutes = (48*60*60) + (2*60*60) + (30*60) = 180300 seconds // Change seconds fireEvent.change(screen.getByTestId('seconds-input'), { target: { value: 45 } }); - expect(onChange).toHaveBeenLastCalledWith(88245); // 2 days, 2 hours, 30 minutes, 45 seconds = (48*60*60) + (2*60*60) + (30*60) + 45 = 180345 seconds + expect(onChange).toHaveBeenLastCalledWith(88245000); // 2 days, 2 hours, 30 minutes, 45 seconds = (48*60*60) + (2*60*60) + (30*60) + 45 = 180345 seconds }); it('should update days, hours, minutes, and seconds when epoch time is changed', () => { @@ -284,16 +284,16 @@ describe('ValueField component', () => { render(); // Change epoch time - fireEvent.change(screen.getByTestId('epoch-input'), { target: { value: 1000 } }); + fireEvent.change(screen.getByTestId('epoch-input'), { target: { value: 2592000000 } }); // Check if onChange function is called with the correct epoch time - expect(onChange).toHaveBeenCalledWith(1000); + expect(onChange).toHaveBeenCalledWith(2592000000); // Check if days, hours, minutes, and seconds are updated accordingly - expect((screen.getByTestId('days-input') as any).value).toBe('0'); + expect((screen.getByTestId('days-input') as any).value).toBe('30'); expect((screen.getByTestId('hours-input') as any).value).toBe('0'); expect((screen.getByTestId('minutes-input') as any).value).toBe('0'); - expect((screen.getByTestId('seconds-input') as any).value).toBe('1'); + expect((screen.getByTestId('seconds-input') as any).value).toBe('0'); }); it('should update data type and epoch time when data type is changed to positive', () => { diff --git a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Parameters.spec.tsx b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Parameters.spec.tsx index c8f9b49..6406ee0 100644 --- a/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Parameters.spec.tsx +++ b/packages/config-svc-fe/src/domain/Rule/CreateConfig/Forms/__tests__/Parameters.spec.tsx @@ -77,7 +77,7 @@ describe('Parameters component', () => { ); expect(screen.getAllByTestId('parameter-field').length).toBe(2); fireEvent.click(screen.getByTestId('prepend-button-0')); - expect(parameterFields.append).toHaveBeenCalled(); + expect(parameterFields.insert).toHaveBeenCalled(); }); diff --git a/packages/config-svc-fe/src/domain/Rule/CreateConfig/index.tsx b/packages/config-svc-fe/src/domain/Rule/CreateConfig/index.tsx index 5359402..0492955 100644 --- a/packages/config-svc-fe/src/domain/Rule/CreateConfig/index.tsx +++ b/packages/config-svc-fe/src/domain/Rule/CreateConfig/index.tsx @@ -52,7 +52,7 @@ const CreateRuleConfigPage = () => { desc: data.description, cfg: `${data.major}.${data.minor || 0}.${data.patch || 0}`, config: { - exitConditions: conditions?.map((con: any) => ({ reason: con.reason, subRuleRef: con.subRefRule })), + exitConditions: conditions ? conditions?.map((con: any) => ({ reason: con.reason, subRuleRef: con.subRefRule })) : [], bands: data.category === 'isBand' ? [...data.bands.map((band: any, index: number) => { if(index === 0) { return { diff --git a/packages/config-svc-fe/src/domain/Rule/EditRule/Edit.tsx b/packages/config-svc-fe/src/domain/Rule/EditRule/Edit.tsx new file mode 100644 index 0000000..640ed4a --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/EditRule/Edit.tsx @@ -0,0 +1,254 @@ +import React, { useEffect } from 'react'; +import { Drawer, Form, Input, Button, Select, Alert } from 'antd'; +import { useForm, Controller } from 'react-hook-form'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useCommonTranslations } from '~/hooks'; +import { IRule } from '../RuleDetailPage/service'; + + +const { Item: FormItem } = Form; + +export interface FormData { + name: string; + description: string; + major?: number; + minor?: number; + patch?: number; + changeType?: string; + state?: string; + +} + +export interface Props { + open: boolean; + setOpen(val: boolean): void; + setSelectedRule(rule: IRule | null): void; + onSubmit(data: FormData): void; + error: string; + success: string; + loading: boolean; + rule: IRule | null; +} + +const EditRule: React.FunctionComponent = ({ open, setOpen, ...props }) => { + const { t } = useCommonTranslations(); + + const validationSchema = React.useMemo(() => { + return yup.object().shape({ + name: yup.string().required(t('createRulePage.errors.nameRequired')).min(3, t('createRulePage.errors.nameLength')), + description: yup.string().required(t('createRulePage.errors.descriptionRequired')).min(5, t('createRulePage.errors.descriptionLength')), + major: yup.number().optional().integer(t('createRulePage.errors.majorInteger')).min(0, t('createRulePage.errors.patchMin')), + minor: yup.number().optional().integer(t('createRulePage.errors.minorInteger')).min(0, t('createRulePage.errors.majorMin')), + patch: yup.number().optional().integer(t('createRulePage.errors.patchInteger')).min(0, t('createRulePage.errors.minorMin')), + changeType: yup.string().when(['state'], (val) => { + const [state] = val; + if (state === '01_DRAFT') { + return yup.string().optional() + } + return yup.string().required(); + + }), + state: yup.string().required(), + }); + }, [t]) + + + const { handleSubmit, control, formState: { errors }, + clearErrors, reset, setValue } = useForm({ + resolver: yupResolver(validationSchema), + }); + + const onSubmit = (data: FormData) => { + props.onSubmit(data); + }; + + useEffect(() => { + if (props.success) { + reset(); + clearErrors(); + } + }, [props.success]) + + useEffect(() => { + if (props.rule?._key) { + setValue("name", props.rule.name); + setValue("description", props.rule.desc); + const [major, minor, patch] = props.rule.cfg.split('.'); + setValue("major", major ? Number(major) : 0); + setValue("minor", minor ? Number(minor) : 0); + setValue("patch", patch ? Number(patch) : 0); + setValue("state", props.rule.state); + } + }, [props.rule]); + + + return ( + <> + setOpen(false)} + open={open} + key={'create-form'} + width={'50%'} + > + + {/* Name field */} + + } + /> + + + {/* Description field */} + + } + /> + + + {props.rule?.state !== '01_DRAFT' ? + + + ( + + )} + /> + + : null} + + +
+ +
+ {/* Major Version Field */} +
+ + ( + + )} + /> + {errors.major && {errors.major.message}} +
+ + {/* Minor Version Field */} +
+ + ( + + )} + /> + {errors.minor && {errors.minor.message}} +
+ + {/* Patch Version Field */} +
+ + ( + + )} + /> + {errors.patch && {errors.patch.message}} +
+
+
+ + {props.success && } + {props.error && } + + {/* Submit button */} + + + + + + +
+ + ); +}; + +export default EditRule; \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/EditRule/__tests__/Edit.spec.tsx b/packages/config-svc-fe/src/domain/Rule/EditRule/__tests__/Edit.spec.tsx new file mode 100644 index 0000000..114d179 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/EditRule/__tests__/Edit.spec.tsx @@ -0,0 +1,141 @@ +import { RenderResult, render, waitFor, screen, fireEvent, act } from '@testing-library/react'; +import React from 'react'; +import '../../../../../setup'; +import EditRule, { Props } from '../Edit'; +import { IRule } from '../../RuleDetailPage/service'; + +const defaultProps: Props = { + setOpen: jest.fn(), + open: true, + onSubmit: jest.fn(), + error: '', + success: '', + loading: false, + setSelectedRule: jest.fn(), + rule: { + name: 'rule-001', + cfg: '1.9.0', + _key: '1235', + _id: 'rule/1235', + _rev: 'rev', + state: '01_DRAFT', + ownerId: 'test@gmail.com', + updatedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + dataType: 'NUMERIC', + desc: 'random', + ruleConfigs: [], + }, +} + + +describe('EditRule', () => { + let component: RenderResult + it('should render page', () => { + waitFor(() => { + component = render() + }) + expect(component).toBeDefined(); + }); + + it('should render error', async () => { + waitFor(() => { + component = render() + }) + + await waitFor(() => component.findAllByText('Error')); + expect((await component.findAllByText('Error')).length).toBe(1); + }); + + it('should render success', async () => { + waitFor(() => { + component = render() + }) + + await waitFor(() => component.findAllByText('Success')); + expect((await component.findAllByText('Success')).length).toBe(1); + }); +}) + +describe('EditRule Form', () => { + // Test case: Verify that the form renders correctly + it('renders form correctly', () => { + render(); + // Check if the form fields are rendered + expect(screen.getByPlaceholderText(/name/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/description/i)).toBeInTheDocument(); + expect(screen.getByText(/submit/i)).toBeInTheDocument(); + }); + + // Test case: Verify that form submission works correctly + it('submits form with valid data', async () => { + + render(); + + act(() => { + fireEvent.mouseDown(screen.getByRole('combobox')); + }); + + act(() => { + fireEvent.click(screen.getByText('MAJOR')); + }); + fireEvent.click(screen.getByText(/submit/i)); + + await waitFor(() => { + expect(defaultProps.onSubmit).toHaveBeenCalledTimes(1); + }); + + }); + + // Test case: Verify form validation for required fields + it('validates required fields', async () => { + render(); + + // Submit the form without filling any fields + fireEvent.click(screen.getByText(/submit/i)); + + // Check for validation errors + await waitFor(() => { + expect(screen.getByText(/required/i)).toBeInTheDocument(); + }); + }); + + // Test case: Verify error alert displays correctly + it('displays error alert', async () => { + const propsWithError = { + ...defaultProps, + error: 'Test error message', + }; + + render(); + + // Check if error alert is displayed + await waitFor(() => { + expect(screen.getByText(/test error message/i)).toBeInTheDocument(); + }); + }); + + // Test case: Verify success alert displays correctly + it('displays success alert', async () => { + const propsWithSuccess = { + ...defaultProps, + success: 'Test success message', + }; + + render(); + + // Check if success alert is displayed + await waitFor(() => { + expect(screen.getByText(/test success message/i)).toBeInTheDocument(); + }); + }); +}) \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/EditRule/index.tsx b/packages/config-svc-fe/src/domain/Rule/EditRule/index.tsx new file mode 100644 index 0000000..362f75b --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/EditRule/index.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState } from 'react'; +import EditRule, { FormData } from './Edit'; +import { updateRule } from './service'; +import { useCommonTranslations } from '~/hooks'; +import { IRule, getRules } from '../RuleDetailPage/service'; +import { createRule } from '../CreateRule/service'; +import { incrementVersion } from '~/utils'; + +interface Props { + open: boolean; + setOpen(val: boolean): void; + afterCreate(): void; + rule: IRule | null; + setSelectedRule(rule: IRule | null): void; +} +const EditRulePage: React.FunctionComponent = (props) => { + const { t } = useCommonTranslations(); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [loading, setLoading] = useState(false); + const [rules, setRules] = useState([]); + + const handleSubmit = async (data: FormData) => { + try { + setSuccess(''); + setError(''); + setLoading(true); + if (!(data.state === "01_DRAFT")) { + const version = incrementVersion(props.rule?.cfg as string, data.changeType as string, (rules || []).map((r) => r.cfg)); + await createRule( + { + cfg: version, + desc: data.description, + state: '01_DRAFT', + name: data.name, + dataType: 'NUMERIC', + } + ); + setSuccess('Rule has been created successfully'); + } else { + await updateRule({ + cfg: `${data.major}.${data.minor}.${data.patch}`, + desc: data.description, + state: '01_DRAFT', + name: data.name, + dataType: 'NUMERIC', + }, props.rule?._key as string); + setSuccess('Rule has been updated successfully'); + } + props.afterCreate && props.afterCreate(); + setTimeout(() => { + setSuccess(''); + }, 3000); + props.setOpen(false); + } catch (e: any) { + setError(e?.response?.data?.message || e?.message || t('generalError')); + } finally { + setLoading(false); + } + + } + + useEffect(() => { + getRules({ page: 1, limit: 99999 }) + .then(({ data }) => { + setRules(data.rules); + }).catch((e) => { + setError(e.response?.data?.message || e?.message || 'Something went wrong') + }); + }, []); + return +} + +export default EditRulePage; \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/EditRule/service.ts b/packages/config-svc-fe/src/domain/Rule/EditRule/service.ts new file mode 100644 index 0000000..3b43a9b --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/EditRule/service.ts @@ -0,0 +1,5 @@ +import { Api } from "~/client"; + +export const updateRule = (body: any, id: string) => { + return Api.patch(`/rule/${id}`, {...body}); +} \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/ReviewConfig/Review.tsx b/packages/config-svc-fe/src/domain/Rule/ReviewConfig/Review.tsx new file mode 100644 index 0000000..885fd0d --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/ReviewConfig/Review.tsx @@ -0,0 +1,91 @@ +import React, { useMemo } from 'react'; +import { Button, Descriptions, Result, Typography } from 'antd'; +import type { DescriptionsProps } from 'antd'; +import Link from 'next/link'; +import FullScreenLoader from '~/components/common/FullScreenLoader'; +import { IRuleConfig } from '../RuleConfig/RuleConfigList/types'; +import { IRule } from '../RuleDetailPage/service'; +import { useCommonTranslations } from '~/hooks'; +import { IUserProfile } from '~/context/auth'; + +interface Props { + loading: boolean; + configuration: IRuleConfig | null; + error: string; + fetchConfig: () => void; + rule: IRule | null; + user: IUserProfile +} + + +export const Review: React.FunctionComponent = ({ + loading, + error, + fetchConfig, + configuration, + rule, + user +}) => { + const { t } = useCommonTranslations(); + + const items = useMemo(() => { + const data: DescriptionsProps['items'] = []; + const { cfg, createdAt, updatedAt, desc, state } = configuration || {} as IRuleConfig; + data.push({ key: '1', label: t('ruleConfigReviewPage.version'), children: cfg, span: 12 }); + data.push({ key: '2', label: t('ruleConfigReviewPage.description'), children: desc, span: 12 }); + data.push({ key: '4', label: t('ruleConfigReviewPage.created'), children: new Date(createdAt).toDateString(), span: 12 },); + data.push({ key: '5', label: t('ruleConfigReviewPage.updated'), children: new Date(updatedAt).toDateString(), span: 12 }); + data.push({ key: '6', label: t('ruleConfigReviewPage.state'), children: state, span: 12 }); + if (rule?._key) { + data.push({ key: '3', label: t('ruleConfigReviewPage.rule'), children: rule.name, span: 12 }); + } + return data; + }, [configuration, rule, t]); + + const canApprove = useMemo(() => { + const isPendingReview = configuration?.state?.toLocaleLowerCase().includes('pending'); + const isUpdater = user?.username === configuration?.ownerId; + const hasApprovalPrivileges = user?.privileges?.includes('SECURITY_APPROVE_RULE_CONFIG'); + return isPendingReview && !isUpdater && hasApprovalPrivileges; + }, [user, configuration]); + + if (loading) { + return + } + + if (error) { + return + {t('ruleConfigReviewPage.retry')} + + ]} + + /> + } + return
+ + + {t('ruleConfigReviewPage.review')} + } bordered items={items} /> + {canApprove && } + + + +
+}; + diff --git a/packages/config-svc-fe/src/domain/Rule/ReviewConfig/__tests__/index.spec.tsx b/packages/config-svc-fe/src/domain/Rule/ReviewConfig/__tests__/index.spec.tsx new file mode 100644 index 0000000..bae6b65 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/ReviewConfig/__tests__/index.spec.tsx @@ -0,0 +1,248 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import ReviewConfig from '../index'; +import '../../../../../setup'; +import { AuthProvider } from '~/context/auth'; +import * as service from '../service'; +import { IRuleConfig } from '../../RuleConfig/RuleConfigList/types'; +import { IRule } from '../../RuleDetailPage/service'; +import * as Auth from '~/context/auth'; + +const useAuthDefault = { + isAuthenticated: false, + token: null, + login: jest.fn(), + logout: jest.fn(), + isLoading: false, + setIsLoading: jest.fn(), + error: '' +} +const mockConfig: IRuleConfig = { + _key: '123', + _id: 'rule-config/123', + _rev: '', + cfg: '1.0.0', + state: '01_DRAFT', + desc: 'Rule Config description', + ruleId: 'rule/123', + ownerId: 'username', + createdAt: new Date().toDateString(), + updatedAt: new Date().toString() +} + +const mockRule: IRule = { + _key: '1234', + _id: 'rule/1234', + _rev: '', + cfg: '1.2.0', + state: '01_DRAFT', + dataType: 'CURRENCY', + desc: 'Rule description', + ownerId: '', + createdAt: new Date().toDateString(), + updatedAt: new Date().toString(), + name: 'Rule Name', + ruleConfigs: [] +} +jest.mock('axios', () => ({ + create: jest.fn(() => ({ + get: jest.fn(), + post: jest.fn(), + interceptors: { + request: { + use: jest.fn(), + eject: jest.fn(), + }, + response: { + use: jest.fn(), + eject: jest.fn(), + }, + }, + })), +})); +jest.mock('next/navigation', () => ({ + useParams: jest.fn(() => ({ id: '123' })), + useSearchParams: jest.fn(() => ({ id: '123' })), +})); +jest.mock('~/components/common/AccessDenied', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); +const useAuth = jest.spyOn(Auth, "useAuth"); +const usePrivileges = jest.spyOn(require('~/hooks/usePrivileges'), 'default'); +const getConfig = jest.spyOn(service, "getRuleConfig"); +const getRule = jest.spyOn(service, "getRule"); + + +describe('Review Config', () => { + beforeEach(() => { + jest.clearAllMocks(); + }) + it('should render page', () => { + const result = render(); + expect(result).toBeDefined(); + }); + + it('should render access denied page', () => { + usePrivileges.mockReturnValueOnce({ + canReviewConfig: false + }) + render(); + expect(screen.getByTestId('access-denied')).toBeDefined(); + }); + + it('should render error', () => { + const mock = getConfig.mockRejectedValueOnce({ + data: { + message: 'something went wrong' + }, + status: 500, + statusText: 'error', + headers: {}, + config: { headers: {} as any } + }); + const userAuthMock = useAuth.mockReturnValueOnce({ + profile: { + clientId: null, + username: '', + platformRoleIds: [], + privileges: [] + }, + ...useAuthDefault + + }) + usePrivileges.mockReturnValue({ + canReviewConfig: true + }) + render(); + waitFor(() => expect(screen.getByTestId('error')).toBeDefined()); + mock.mockReset(); + userAuthMock.mockClear(); + }); + + + it('should render data', async() => { + const configMock = getConfig.mockResolvedValueOnce({ + data: { + ...mockConfig + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const ruleMock = getRule.mockResolvedValueOnce({ + data: { + ...mockRule + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const userAuthMock = useAuth.mockReturnValue({ + profile: { + clientId: null, + username: '', + platformRoleIds: [], + privileges: [] + }, + ...useAuthDefault }); + usePrivileges.mockReturnValue({ + canReviewConfig: true + }); + render(); + await waitFor(() => expect(screen.getByText(mockConfig.desc)).toBeDefined()); + await waitFor(() => expect(screen.getByText(mockRule.name)).toBeDefined()); + await waitFor(() => expect(screen.getByText(mockConfig.state)).toBeDefined()); + configMock.mockReset(); + ruleMock.mockReset(); + userAuthMock.mockClear() + }); + + it('should render approve button ', async() => { + const configMock = getConfig.mockResolvedValueOnce({ + data: { + ...mockConfig, + updatedBy: 'test1', + state: '10_PENDING_REVIEW' + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const ruleMock = getRule.mockResolvedValue({ + data: { + ...mockRule + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const userAuthMock = useAuth.mockReturnValue({ + profile: { + clientId: null, + username: 'test', + platformRoleIds: [], + privileges: ["SECURITY_APPROVE_RULE_CONFIG"] + }, + ...useAuthDefault + }); + usePrivileges.mockReturnValue({ + canReviewConfig: true + }); + render(); + await waitFor(() => expect(screen.getByText('Approve')).toBeDefined()); + configMock.mockReset(); + ruleMock.mockReset(); + userAuthMock.mockReset() + }); + + it('should hide approve button if approver is the same as updatedBy ', async() => { + const configMock = getConfig.mockResolvedValueOnce({ + data: { + // ...mockConfig, + updatedBy: 'test2', + state: '10_PENDING_REVIEW' + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const ruleMock = getRule.mockResolvedValue({ + data: { + ...mockRule, + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const userAuthMock = useAuth.mockReturnValue({ + profile: { + clientId: null, + username: 'test2', + platformRoleIds: [], + privileges: ["SECURITY_APPROVE_RULE_CONFIG"] + }, + ...useAuthDefault + }); + usePrivileges.mockReturnValue({ + canReviewConfig: true + }); + render(); + await waitFor(() => expect(screen.queryByText('Approve')).not.toBeInTheDocument()); + configMock.mockReset(); + ruleMock.mockReset(); + userAuthMock.mockReset() + }); +}) \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/ReviewConfig/index.tsx b/packages/config-svc-fe/src/domain/Rule/ReviewConfig/index.tsx new file mode 100644 index 0000000..9cd8514 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/ReviewConfig/index.tsx @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from "react"; +import { Review } from "./Review" +import usePrivileges from "~/hooks/usePrivileges"; +import AccessDeniedPage from "~/components/common/AccessDenied"; +import { getRule, getRuleConfig } from "./service"; +import { useParams } from "next/navigation"; +import { IRuleConfig } from "../RuleConfig/RuleConfigList/types"; +import { IRule } from "../RuleDetailPage/service"; +import { useAuth } from "~/context/auth"; + +const ReviewPage = () => { + const [loading, setLoading] = useState(false); + const { canReviewConfig } = usePrivileges(); + const [error, setError] = useState(''); + const [configuration, setConfiguration] = useState(null); + const [rule, setRule] = useState(null); + const { id } = useParams(); + const {profile} = useAuth(); + + const fetchConfig = useCallback(() => { + setLoading(true); + setError(''); + getRuleConfig(id as string) + .then(({ data }) => { + setConfiguration(data); + const [, ruleId] = data?.ruleId?.split('/'); + if (ruleId) { + getRule(ruleId) + .then((res) => { + setRule(res.data); + }).catch((e) => { + Promise.resolve(); + }); + } + }).catch((e) => { + setError(e?.response?.data?.message || e?.message || 'Something went wrong'); + }).finally(() => { + setLoading(false); + }); + + }, []); + + useEffect(() => { + if (canReviewConfig) { + fetchConfig(); + } + }, [canReviewConfig, id]); + + if (!canReviewConfig) { + return + } + return +} + +export default ReviewPage; \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/ReviewConfig/service.ts b/packages/config-svc-fe/src/domain/Rule/ReviewConfig/service.ts new file mode 100644 index 0000000..052244a --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/ReviewConfig/service.ts @@ -0,0 +1,9 @@ +import { Api } from "~/client" + +export const getRuleConfig = (id: string) => { + return Api.get(`/rule-config/${id}`); +} + +export const getRule = (id: string) => { + return Api.get(`/rule/${id}`); +} \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/ReviewRule/Review.tsx b/packages/config-svc-fe/src/domain/Rule/ReviewRule/Review.tsx new file mode 100644 index 0000000..b56aeb4 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/ReviewRule/Review.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } from 'react'; +import { Button, Descriptions, Result, Typography } from 'antd'; +import type { DescriptionsProps } from 'antd'; +import Link from 'next/link'; +import FullScreenLoader from '~/components/common/FullScreenLoader'; +import { IRule } from '../RuleDetailPage/service'; +import { useCommonTranslations } from '~/hooks'; +import { IUserProfile } from '~/context/auth'; + +interface Props { + loading: boolean; + error: string; + fetchRule: () => void; + rule: IRule | null; + user: IUserProfile +} + +export const Review: React.FunctionComponent = ({ + loading, + error, + fetchRule, + rule, + user +}) => { + const { t } = useCommonTranslations(); + + const items = useMemo(() => { + const data: DescriptionsProps['items'] = []; + const {name, cfg, createdAt, updatedAt, desc, state } = rule || {} as IRule; + data.push({ key: '1', label: t('ruleReviewPage.rule'), children: name, span: 12 }); + data.push({ key: '2', label: t('ruleReviewPage.version'), children: cfg, span: 12 }); + data.push({ key: '3', label: t('ruleReviewPage.description'), children: desc, span: 12 }); + data.push({ key: '5', label: t('ruleReviewPage.created'), children: new Date(createdAt).toDateString(), span: 12 },); + data.push({ key: '6', label: t('ruleReviewPage.updated'), children: new Date(updatedAt).toDateString(), span: 12 }); + data.push({ key: '7', label: t('ruleReviewPage.state'), children: state, span: 12 }); + + return data; + }, [rule, t]); + + const canApprove = useMemo(() => { + const isPendingReview = rule?.state?.toLocaleLowerCase().includes('pending'); + const isUpdater = user?.username === rule?.ownerId; + const hasApprovalPrivileges = user?.privileges?.includes('SECURITY_APPROVE_RULE'); + return isPendingReview && !isUpdater && hasApprovalPrivileges; + }, [user, rule]); + + if (loading) { + return + } + + if (error) { + return + {t('ruleReviewPage.retry')} + + ]} + + /> + } + return
+ + + {t('ruleReviewPage.review')} + } bordered items={items} /> + {canApprove && } + + + +
+}; + diff --git a/packages/config-svc-fe/src/domain/Rule/ReviewRule/__tests__/index.spec.tsx b/packages/config-svc-fe/src/domain/Rule/ReviewRule/__tests__/index.spec.tsx new file mode 100644 index 0000000..2e0ed8f --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/ReviewRule/__tests__/index.spec.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import ReviewRule from '../index'; +import '../../../../../setup'; +import { AuthProvider } from '~/context/auth'; +import * as service from '../service'; +import { IRule } from '../../RuleDetailPage/service'; +import * as Auth from '~/context/auth'; + +const useAuthDefault = { + isAuthenticated: false, + token: null, + login: jest.fn(), + logout: jest.fn(), + isLoading: false, + setIsLoading: jest.fn(), + error: '' +} + +const mockRule: IRule = { + _key: '1234', + _id: 'rule/1234', + _rev: '', + cfg: '1.2.0', + state: '01_DRAFT', + dataType: 'CURRENCY', + desc: 'Rule description', + ownerId: '', + createdAt: new Date().toDateString(), + updatedAt: new Date().toString(), + name: 'Rule Name', + ruleConfigs: [] +} +jest.mock('axios', () => ({ + create: jest.fn(() => ({ + get: jest.fn(), + post: jest.fn(), + interceptors: { + request: { + use: jest.fn(), + eject: jest.fn(), + }, + response: { + use: jest.fn(), + eject: jest.fn(), + }, + }, + })), +})); +jest.mock('next/navigation', () => ({ + useParams: jest.fn(() => ({ id: '123' })), + useSearchParams: jest.fn(() => ({ id: '123' })), +})); +jest.mock('~/components/common/AccessDenied', () => ({ + __esModule: true, + default: jest.fn(() =>
), +})); +const useAuth = jest.spyOn(Auth, "useAuth"); +const usePrivileges = jest.spyOn(require('~/hooks/usePrivileges'), 'default'); +const getRule = jest.spyOn(service, "getRule"); + +describe('Review Rule', () => { + beforeEach(() => { + jest.clearAllMocks(); + }) + it('should render page', () => { + const result = render(); + expect(result).toBeDefined(); + }); + + it('should render access denied page', () => { + usePrivileges.mockReturnValueOnce({ + canReviewRule: false + }) + render(); + expect(screen.getByTestId('access-denied')).toBeDefined(); + }); + + it('should render error', () => { + const mock = getRule.mockRejectedValueOnce({ + data: { + message: 'something went wrong' + }, + status: 500, + statusText: 'error', + headers: {}, + config: { headers: {} as any } + }); + const userAuthMock = useAuth.mockReturnValueOnce({ + profile: { + clientId: null, + username: '', + platformRoleIds: [], + privileges: [] + }, + ...useAuthDefault + + }) + usePrivileges.mockReturnValue({ + canReviewRule: true + }) + render(); + waitFor(() => expect(screen.getByTestId('error')).toBeDefined()); + mock.mockReset(); + userAuthMock.mockClear(); + }); + + + it('should render data', async() => { + const configMock = getRule.mockResolvedValueOnce({ + data: { + ...mockRule + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const ruleMock = getRule.mockResolvedValueOnce({ + data: { + ...mockRule + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const userAuthMock = useAuth.mockReturnValue({ + profile: { + clientId: null, + username: '', + platformRoleIds: [], + privileges: [] + }, + ...useAuthDefault }); + usePrivileges.mockReturnValue({ + canReviewRule: true + }); + render(); + await waitFor(() => expect(screen.getByText(mockRule.desc)).toBeDefined()); + await waitFor(() => expect(screen.getByText(mockRule.name)).toBeDefined()); + await waitFor(() => expect(screen.getByText(mockRule.state)).toBeDefined()); + configMock.mockReset(); + ruleMock.mockReset(); + userAuthMock.mockClear() + }); + + it('should render approve button ', async() => { + + const ruleMock = getRule.mockResolvedValue({ + data: { + ...mockRule, + updatedBy: 'test1', + state: '10_PENDING_REVIEW' + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const userAuthMock = useAuth.mockReturnValue({ + profile: { + clientId: null, + username: 'test', + platformRoleIds: [], + privileges: ["SECURITY_APPROVE_RULE"] + }, + ...useAuthDefault + }); + usePrivileges.mockReturnValue({ + canReviewRule: true + }); + render(); + await waitFor(() => expect(screen.getByText('Approve')).toBeDefined()); + ruleMock.mockReset(); + userAuthMock.mockReset() + }); + + it('should hide approve button if approver is the same as updatedBy ', async() => { + const ruleMock = getRule.mockResolvedValue({ + data: { + ...mockRule, + updatedBy: 'test2', + state: '10_PENDING_REVIEW' + }, + status: 200, + statusText: 'ok', + headers: {}, + config: { headers: {} as any } + }); + + const userAuthMock = useAuth.mockReturnValue({ + profile: { + clientId: null, + username: 'test2', + platformRoleIds: [], + privileges: ["SECURITY_APPROVE_RULE"] + }, + ...useAuthDefault + }); + usePrivileges.mockReturnValue({ + canReviewRule: true + }); + render(); + await waitFor(() => expect(screen.queryByText('Approve')).not.toBeInTheDocument()); + ruleMock.mockReset(); + userAuthMock.mockReset() + }); +}) \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/ReviewRule/index.tsx b/packages/config-svc-fe/src/domain/Rule/ReviewRule/index.tsx new file mode 100644 index 0000000..99f188d --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/ReviewRule/index.tsx @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from "react"; +import { Review } from "./Review" +import usePrivileges from "~/hooks/usePrivileges"; +import AccessDeniedPage from "~/components/common/AccessDenied"; +import { getRule } from "./service"; +import { useParams } from "next/navigation"; +import { IRule } from "../RuleDetailPage/service"; +import { useAuth } from "~/context/auth"; + +const ReviewPage = () => { + const [loading, setLoading] = useState(false); + const { canReviewRule } = usePrivileges(); + const [error, setError] = useState(''); + const [rule, setRule] = useState(null); + const { id } = useParams(); + const {profile} = useAuth(); + + const fetchRule = useCallback(() => { + setLoading(true); + setError(''); + getRule(id as string) + .then(({ data }) => { + setRule(data); + }).catch((e) => { + setError(e?.response?.data?.message || e?.message || 'Something went wrong'); + }).finally(() => { + setLoading(false); + }); + + }, []); + + useEffect(() => { + if (canReviewRule) { + fetchRule(); + } + }, [canReviewRule, id]); + + if (!canReviewRule) { + return + } + return +} + +export default ReviewPage; \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/ReviewRule/service.tsx b/packages/config-svc-fe/src/domain/Rule/ReviewRule/service.tsx new file mode 100644 index 0000000..e38c184 --- /dev/null +++ b/packages/config-svc-fe/src/domain/Rule/ReviewRule/service.tsx @@ -0,0 +1,6 @@ +import { Api } from "~/client" + + +export const getRule = (id: string) => { + return Api.get(`/rule/${id}`); +} \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/RuleConfig/RuleConfigList/ConfigTable.tsx b/packages/config-svc-fe/src/domain/Rule/RuleConfig/RuleConfigList/ConfigTable.tsx index f75baa7..a9b099e 100644 --- a/packages/config-svc-fe/src/domain/Rule/RuleConfig/RuleConfigList/ConfigTable.tsx +++ b/packages/config-svc-fe/src/domain/Rule/RuleConfig/RuleConfigList/ConfigTable.tsx @@ -4,6 +4,7 @@ import { IRuleConfig } from "./types"; import styles from './RuleConfigList.module.scss'; import { useMemo } from "react"; import { useCommonTranslations } from "~/hooks"; +import Link from "next/link"; export interface IProps { searchConfigText: string; @@ -94,7 +95,11 @@ export const ConfigTable: React.FunctionComponent = ({ render: (_, record) => ( {canEditConfig && } - {canReviewConfig && } + {canReviewConfig && } ), }, diff --git a/packages/config-svc-fe/src/domain/Rule/RuleConfig/RuleConfigList/types.ts b/packages/config-svc-fe/src/domain/Rule/RuleConfig/RuleConfigList/types.ts index ef9fca2..b266264 100644 --- a/packages/config-svc-fe/src/domain/Rule/RuleConfig/RuleConfigList/types.ts +++ b/packages/config-svc-fe/src/domain/Rule/RuleConfig/RuleConfigList/types.ts @@ -39,4 +39,5 @@ export interface IRuleConfig { ownerId: string; createdAt: string; updatedAt: string; + updatedBy?: string; } diff --git a/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/RuleDetailPage.tsx b/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/RuleDetailPage.tsx index e63a3d3..0f5a54e 100644 --- a/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/RuleDetailPage.tsx +++ b/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/RuleDetailPage.tsx @@ -1,15 +1,17 @@ import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; -import { Alert, Button, Input, Space, Table } from 'antd'; +import { Alert, Button, Input, Modal, Space, Table } from 'antd'; import type { TableColumnsType } from 'antd'; import styles from './RuleDetailPage.module.scss'; import { useCommonTranslations } from '~/hooks'; import { IRule } from './service'; import { uniqueArray } from '~/utils/uniqueItems'; import { IUserProfile } from '~/context/auth'; +import Link from 'next/link'; -const CreateRule = React.lazy(() => import('../CreateRule/index')) -interface Props { +const CreateRule = React.lazy(() => import('../CreateRule/index')); +const EditRule = React.lazy(() => import('../EditRule/index')); +export interface Props { loading: boolean; error: string; retry(page?: number): void; @@ -19,13 +21,19 @@ interface Props { onPageChange(page: number, pageSize: number): void, open: boolean; setOpen(val: boolean): void; + openEdit: boolean; + setOpenEdit(val: boolean): void; user: IUserProfile; + selectedRule: IRule | null; + setSelectedRule(rule: IRule | null): void } -const Rule: React.FunctionComponent = ({ loading, error, retry, data, total, onPageChange, page, open, setOpen, user }) => { +const Rule: React.FunctionComponent = ({ loading, error, retry, data, total, onPageChange, page, open, setOpen, user, openEdit, setOpenEdit, + selectedRule, setSelectedRule }) => { const { t: commonTranslations } = useCommonTranslations(); const [searchText, setSearchText] = useState(''); const [rules, setRules] = useState([]); + const [modal, contextHolder] = Modal.useModal(); const canCreate = useMemo(() => { return user?.privileges?.includes('SECURITY_CREATE_RULE') @@ -146,8 +154,28 @@ const Rule: React.FunctionComponent = ({ loading, error, retry, data, tot key: 'action', render: (_, record) => ( - {canEdit && } - {canReview && } + {canEdit && } + {canReview && } ), }, @@ -155,11 +183,11 @@ const Rule: React.FunctionComponent = ({ loading, error, retry, data, tot }, [commonTranslations, data, searchText, canEdit]) return ( <> - {canCreate ?
+ {canCreate ?
-
:
} +
:
} = ({ loading, error, retry, data, tot /> + + { + retry(1); + }} + /> + + {error && = ({ loading, error, retry, data, tot pagination={{ total: total, pageSize: 10, onChange: onPageChange, current: page }} rowKey="_key" /> + {contextHolder} ) } diff --git a/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/__tests__/RuleDetailPage.spec.tsx b/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/__tests__/RuleDetailPage.spec.tsx index f2140a7..c5667fb 100644 --- a/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/__tests__/RuleDetailPage.spec.tsx +++ b/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/__tests__/RuleDetailPage.spec.tsx @@ -6,8 +6,12 @@ import RuleDetailPage from '../index'; import { Api } from "~/client"; import { act } from "react-dom/test-utils"; import { IRule } from "../service"; -import Rule from '../RuleDetailPage'; +import Rule, {Props} from '../RuleDetailPage'; import { AuthProvider, IUserProfile } from "~/context/auth"; +import { Modal } from "antd"; +import * as service from '../service'; +import { AxiosResponse } from "axios"; + const userDefault: IUserProfile = { clientId: "", @@ -17,6 +21,22 @@ const userDefault: IUserProfile = { } jest.spyOn(require('~/hooks/usePrivileges'), 'default').mockReturnValue({ canViewRules: true }); +const defaultProps: Props = { + loading: false, + error: "", + retry: jest.fn(), + page: 0, + data: [], + total: 0, + onPageChange: jest.fn(), + open: false, + setOpen: jest.fn(), + openEdit: false, + setOpenEdit: jest.fn(), + user:{...userDefault}, + selectedRule: null, + setSelectedRule: jest.fn(), +} const rules: IRule[] = [{ "_key": "d8b3d1cb-bb9b-4dff-b7bd-985376d4424a", "_id": "rule/d8b3d1cb-bb9b-4dff-b7bd-985376d4424a", @@ -58,14 +78,13 @@ jest.mock('axios', () => ({ })), })); -const getMock = jest.spyOn(Api, 'get') + +const getMock = jest.spyOn(service, "getRules"); + describe('Rule Detail', () => { - afterEach(() => { - getMock.mockRestore(); - }); it('should call get rules', async () => { let component: RenderResult - getMock.mockResolvedValue({ data: { rules, count: 1 } }) + getMock.mockResolvedValue({ data: { rules, count: 1 } } as AxiosResponse) await waitFor(() => { component = render( @@ -81,7 +100,7 @@ describe('Rule Detail', () => { it('should render content', async () => { let component: RenderResult - getMock.mockResolvedValue({ data: { rules, count: 1 } }) + getMock.mockResolvedValue({ data: { rules, count: 1 } } as AxiosResponse) await waitFor(() => { component = render( @@ -94,22 +113,6 @@ describe('Rule Detail', () => { expect((await component.findAllByText(rules[0].cfg)).length).toBe(1); }); }); - - it('should render error', async () => { - let component: RenderResult - getMock.mockRejectedValueOnce({ response: { data: { message: 'An error occurred' } } }) - await waitFor(() => { - component = render( - - - - ); - }); - - await act(async () => { - expect((await component.findAllByText('An error occurred')).length).toBe(1); - }); - }); }); describe('Rule component', () => { @@ -117,6 +120,7 @@ describe('Rule component', () => { const user = { privileges: ['SECURITY_CREATE_RULE'] }; render( {}} @@ -136,6 +140,8 @@ describe('Rule component', () => { const user = { privileges: ['SECURITY_CREATE_RULE'] }; render( {}} @@ -156,6 +162,8 @@ describe('Rule component', () => { const user = { privileges: [] }; render( {}} @@ -175,6 +183,8 @@ describe('Rule component', () => { const user = { privileges: ['SECURITY_CREATE_RULE'] }; render( {}} @@ -196,6 +206,7 @@ describe('Rule component', () => { const user = { privileges: ['SECURITY_CREATE_RULE'] }; render( {}} @@ -212,4 +223,95 @@ describe('Rule component', () => { expect(setOpen).toHaveBeenCalledWith(true); }); + it('should render error', () => { + const setOpen = jest.fn(); + const user = { privileges: ['SECURITY_CREATE_RULE'] }; + render( + {}} + open={false} + setOpen={setOpen} + user={{...userDefault,...user}} + /> + ); + fireEvent.click(screen.getByText(/Retry/)); + expect(defaultProps.retry).toHaveBeenCalled(); + }); + + it('should call confirm when state of rule is not 01_DRAFT', () => { + const setOpen = jest.fn(); + const confirm = jest.fn(); + jest.spyOn(Modal, "useModal").mockImplementation(() => ([{ + confirm: () => { + confirm(); + }, + } as any,
])) + const user = { privileges: ['SECURITY_CREATE_RULE', 'SECURITY_UPDATE_RULE'] }; + render( + {}} + page={1} + data={rules} + total={0} + onPageChange={() => {}} + open={false} + setOpen={setOpen} + user={{...userDefault,...user}} + /> + ); + fireEvent.click(screen.getByTestId('modify-button')); + expect(confirm).toHaveBeenCalled(); + }); + + it('should call setEditOpen when state of rule is 01_DRAFT', () => { + const setOpen = jest.fn(); + const confirm = jest.fn(); + jest.spyOn(Modal, "useModal").mockImplementation(() => ([{ + confirm: () => { + confirm(); + }, + } as any,
])) + const user = { privileges: ['SECURITY_CREATE_RULE', 'SECURITY_UPDATE_RULE'] }; + render( + {}} + page={1} + data={[{ + state: '01_DRAFT', + _key: "", + _id: "", + _rev: "", + cfg: "", + dataType: "", + desc: "", + ownerId: "", + createdAt: "", + updatedAt: "", + name: "", + ruleConfigs: [] + }]} + total={0} + onPageChange={() => {}} + open={false} + setOpen={setOpen} + user={{...userDefault,...user}} + /> + ); + fireEvent.click(screen.getByTestId('modify-button')); + expect(defaultProps.setSelectedRule).toHaveBeenCalled(); + expect(defaultProps.setOpenEdit).toHaveBeenCalled(); + }); + }); \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/__tests__/index.spec.tsx b/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/__tests__/index.spec.tsx index 63194cd..387ce65 100644 --- a/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/__tests__/index.spec.tsx +++ b/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/__tests__/index.spec.tsx @@ -2,11 +2,17 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import Rule from '../index'; import '../../../../../setup'; - -jest.mock('~/context/auth', () => ({ - useAuth: jest.fn(() => ({ profile: {privileges: []} })), -})); - +import * as Auth from '~/context/auth'; + +const useAuthDefault = { + isAuthenticated: false, + token: null, + login: jest.fn(), + logout: jest.fn(), + isLoading: false, + setIsLoading: jest.fn(), + error: '' +} jest.mock('axios', () => ({ create: jest.fn(() => ({ get: jest.fn(), @@ -24,20 +30,34 @@ jest.mock('axios', () => ({ })), })); +jest.mock('../service', () => ({ + getRules: jest.fn(), +})); + const usePrivileges = jest.spyOn(require('~/hooks/usePrivileges'), 'default'); const getRules = jest.spyOn(require('../service'), 'getRules'); +const useAuth = jest.spyOn(Auth, "useAuth"); + jest.mock('~/components/common/AccessDenied', () => ({ __esModule: true, default: jest.fn(() =>
), })); - +useAuth.mockReturnValue({ + ...useAuthDefault, + profile: { + clientId: null, + username: '', + platformRoleIds: [], + privileges: ['SECURITY_UPDATE_RULE'] + }, +}); describe('Rule component', () => { - beforeEach(() => { - getRules.mockResolvedValue({ data: { rules: [], count: 0 } }); - }); + test('renders component with RuleView when user has permission', async () => { + getRules.mockResolvedValue({ data: { rules: [], count: 0 } }); + usePrivileges.mockReturnValue({ canViewRules: true }); render(); @@ -49,6 +69,8 @@ describe('Rule component', () => { }); test('renders AccessDeniedPage when user does not have permission', () => { + getRules.mockResolvedValue({ data: { rules: [], count: 0 } }); + usePrivileges.mockReturnValue({ canViewRules: false }); render(); @@ -57,6 +79,8 @@ describe('Rule component', () => { }); test('fetches rules on mount', async () => { + getRules.mockResolvedValue({ data: { rules: [], count: 0 } }); + render(); // Check if getRules function is called @@ -65,23 +89,4 @@ describe('Rule component', () => { }); }); - test('retry fetches rules with specified page number', async () => { - getRules.mockRejectedValueOnce({ data: { message: 'Error'} }); - usePrivileges.mockReturnValue({ canViewRules: true }); - - - render(); - - // Simulate retry action - waitFor(() => { - fireEvent.click(screen.getByTestId('retry-button')); - }); - - // Check if getRules function is called with correct page number - await waitFor(() => { - expect(getRules).toHaveBeenCalledWith({ page: 1, limit: 10 }); - }); - }); - - // Add more tests as needed for other interactions and edge cases }); diff --git a/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/index.tsx b/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/index.tsx index df915d6..4348446 100644 --- a/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/index.tsx +++ b/packages/config-svc-fe/src/domain/Rule/RuleDetailPage/index.tsx @@ -5,7 +5,6 @@ import { useAuth } from '~/context/auth'; import usePrivileges from '~/hooks/usePrivileges'; import AccessDeniedPage from '~/components/common/AccessDenied'; - const Rule = () => { const [rules, setRules] = useState([]); const [page, setPage] = useState(1); @@ -13,6 +12,9 @@ const Rule = () => { const [error, setError] = useState(''); const [totalItems, setTotalItems] = useState(0) const [open, setOpen] = useState(false); + const [openEdit, setOpenEdit] = useState(false); + const[selectedRule, setSelectedRule] = useState(null); + const {profile} = useAuth(); const {canViewRules} = usePrivileges(); @@ -63,6 +65,10 @@ const Rule = () => { open={open} setOpen={setOpen} user={profile} + openEdit={openEdit} + setOpenEdit={setOpenEdit} + selectedRule={selectedRule} + setSelectedRule={setSelectedRule} /> } diff --git a/packages/config-svc-fe/src/domain/Typology/Create/Create.tsx b/packages/config-svc-fe/src/domain/Typology/Create/Create.tsx index b077498..81f6834 100644 --- a/packages/config-svc-fe/src/domain/Typology/Create/Create.tsx +++ b/packages/config-svc-fe/src/domain/Typology/Create/Create.tsx @@ -1,4 +1,4 @@ -import { Row, Col, Spin, Button, Typography } from 'antd'; +import { Row, Col, Spin, Button } from 'antd'; import React, { DragEventHandler } from 'react'; import { Rules } from './Rules'; import { IRule } from '~/domain/Rule/RuleDetailPage/service'; @@ -9,8 +9,9 @@ import { Structure } from './Structure'; import TypologyDetails from './Typology-Details'; import { IRuleConfig } from '~/domain/Rule/RuleConfig/RuleConfigList/types'; import { AttachedRules } from '.'; -import { Connection, EdgeChange, Node, NodeChange, NodeMouseHandler } from 'reactflow'; +import { Connection, Controls, EdgeChange, MiniMap, Node, NodeChange, NodeMouseHandler } from 'reactflow'; import { FormState, Control, UseFormHandleSubmit, UseFormWatch } from 'react-hook-form'; +import { useCommonTranslations } from '~/hooks'; interface Props { rules: IRule[]; @@ -42,15 +43,16 @@ interface Props { } export const Create: React.FunctionComponent = ({ rules, loadingRules, ...props }) => { + const{t} = useCommonTranslations(); if (loadingRules) { return } return (
-
-
- -
+
+ + +
@@ -78,7 +80,11 @@ export const Create: React.FunctionComponent = ({ rules, loadingRules, .. flowRef={props.flowRef} handleDelete={props.handleDelete} onNodeClick={props.onNodeClick} - /> + > + + + + diff --git a/packages/config-svc-fe/src/domain/Typology/Create/Flow.tsx b/packages/config-svc-fe/src/domain/Typology/Create/Flow.tsx index ac860fb..4e18d7d 100644 --- a/packages/config-svc-fe/src/domain/Typology/Create/Flow.tsx +++ b/packages/config-svc-fe/src/domain/Typology/Create/Flow.tsx @@ -1,13 +1,14 @@ import { CloseOutlined } from "@ant-design/icons"; import { DragEventHandler, useCallback, useMemo } from "react"; import ReactFlow, { - Controls, Edge, MiniMap, Node, Handle, OnConnect, OnEdgesChange, OnNodesChange, Position, NodeMouseHandler, ConnectionLineType, ReactFlowProps - + Edge, Node, Handle, OnConnect, OnEdgesChange, + OnNodesChange, Position, NodeMouseHandler, + ConnectionLineType, + ReactFlowProps } from "reactflow"; import styles from './style.module.scss'; import React from "react"; -import { Form, InputNumber, Popover } from "antd"; -import { Conditions } from "../Score/Conditions"; +import { Form, InputNumber } from "antd"; interface Props { nodes: Node[]; @@ -17,7 +18,7 @@ interface Props { onEdgesChange: OnEdgesChange; onDrop: DragEventHandler, flowRef: any; - handleDelete: (id: string, type: string) => void; + handleDelete?: (id: string, type: string) => void; onNodeClick: NodeMouseHandler; } @@ -57,13 +58,13 @@ const ScoreNode: React.FunctionComponent = ({ data, id }) => {
-
-
- +
+ + = ({ connectionLineType={ConnectionLineType.SmoothStep} {...props} > - - {children} ); diff --git a/packages/config-svc-fe/src/domain/Typology/Create/Typology-Details.tsx b/packages/config-svc-fe/src/domain/Typology/Create/Typology-Details.tsx index 36d80d0..e66a231 100644 --- a/packages/config-svc-fe/src/domain/Typology/Create/Typology-Details.tsx +++ b/packages/config-svc-fe/src/domain/Typology/Create/Typology-Details.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { IConfig } from "./Rules-Attached"; import { AttachedRules } from "."; import {UseFormWatch } from "react-hook-form"; +import { useCommonTranslations } from "~/hooks"; export interface Props { attachedRules: AttachedRules[]; @@ -11,6 +12,7 @@ watch: UseFormWatch; const TypologyDetails: React.FunctionComponent = ({attachedRules, watch}) => { const [configurations, setConfigurations] = useState([]); const [values, setValues] = useState({minor: null, major: null, patch: null}); + const{t} = useCommonTranslations(); watch(['minor', 'major', 'patch']); @@ -43,21 +45,21 @@ const TypologyDetails: React.FunctionComponent = ({attachedRules, watch}) return
- Typology Details + {t('typologyCreatePage.typologyDetails')}
- Version + {t('typologyCreatePage.version')} {`${values.major !== null ? values.major : ''}.${values.minor !== null ? values.minor : ''}.${values.patch !== null ? values.patch : ''}`}
- Rules + {t('typologyCreatePage.title')} {attachedRules.length || 0}
- Rule Configs + {t('typologyCreatePage.ruleConfigs')} {configurations.length || 0}
diff --git a/packages/config-svc-fe/src/domain/Typology/Create/__tests__/index.spec.tsx b/packages/config-svc-fe/src/domain/Typology/Create/__tests__/index.spec.tsx index b3f2306..4d9a82c 100644 --- a/packages/config-svc-fe/src/domain/Typology/Create/__tests__/index.spec.tsx +++ b/packages/config-svc-fe/src/domain/Typology/Create/__tests__/index.spec.tsx @@ -4,6 +4,153 @@ import CreateEditTopologyPage from '../index'; import * as configService from '~/domain/Rule/RuleConfig/RuleConfigList/service'; import '../../../../../setup'; import * as service from '~/domain/Typology/Score/service'; +import * as createService from '../service'; +import { useParams } from 'next/navigation'; +import { Router } from 'next/router'; + + +let routeChangeComplete: () => void; +Router.events.on = jest.fn((event, callback) => { + routeChangeComplete = callback; +}); + +const mockData = { + "_id": "typology/5a3d7cbf-02e8-458b-b66c-a2ccb1f1fcb7", + "_key": "5a3d7cbf-02e8-458b-b66c-a2ccb1f1fcb7", + "_rev": "_h9G87jy---", + "cfg": "0.1.0", + "createdAt": "2024-06-07T10:27:10.009Z", + "desc": "aada", + "name": "Typology 1", + "ownerId": "test@gmail.com", + "rules_rule_configs": [ + { + "ruleId": "rule/32b24092-ede5-4892-9780-19c08b2badbe", + "ruleConfigId": [ + "rule_config/4c07cfe2-4a87-4bfc-a056-c69170c2668a" + ] + }, + { + "ruleId": "rule/5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "ruleConfigId": [ + "rule_config/78f59326-5fa2-44ab-a23a-aeb0945413bf" + ] + } + ], + "state": "01_DRAFT", + "typologyCategoryUUID": [], + "updatedAt": "2024-06-07T10:27:10.009Z", + "ruleWithConfigs": [ + { + "rule": { + "_id": "rule/32b24092-ede5-4892-9780-19c08b2badbe", + "_key": "32b24092-ede5-4892-9780-19c08b2badbe", + "name": "rule-001", + "cfg": "1.0.0" + }, + "ruleConfigs": [ + { + "_id": "rule_config/4c07cfe2-4a87-4bfc-a056-c69170c2668a", + "_key": "4c07cfe2-4a87-4bfc-a056-c69170c2668a", + "cfg": "2.1.1", + "ruleId": "32b24092-ede5-4892-9780-19c08b2badbe", + "config": { + "exitConditions": [ + { + "reason": "Unsuccessful transaction", + "subRuleRef": ".x00", + "outcome": true + }, + { + "reason": "Insufficient transaction history. At least 50 historical transactions are required", + "subRuleRef": ".x01", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions shows an increase", + "subRuleRef": ".x03", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "bands": [], + "cases": [], + "parameters": [ + { + "ParameterType": "number", + "ParameterName": "aa" + } + ] + } + } + ] + }, + { + "rule": { + "_id": "rule/5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "_key": "5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "name": "rule-002", + "cfg": "1.0.0" + }, + "ruleConfigs": [ + { + "_id": "rule_config/78f59326-5fa2-44ab-a23a-aeb0945413bf", + "_key": "78f59326-5fa2-44ab-a23a-aeb0945413bf", + "cfg": "2.1.1", + "ruleId": "5e88f0bd-65d1-48f4-b697-de0501c84d4a", + "config": { + "exitConditions": [ + { + "reason": "Unsuccessful transaction", + "subRuleRef": ".x00", + "outcome": true + }, + { + "reason": "Insufficient transaction history. At least 50 historical transactions are required", + "subRuleRef": ".x01", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions shows an increase", + "subRuleRef": ".x03", + "outcome": true + }, + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "bands": [ + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "cases": [ + { + "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", + "subRuleRef": ".x04", + "outcome": true + } + ], + "parameters": [ + { + "ParameterType": "number", + "ParameterName": "1" + } + ] + } + } + ] + } + ] +} // Mock the hooks and services jest.mock('~/hooks/usePrivileges'); jest.mock('~/domain/Rule/RuleConfig/RuleConfigList/service'); @@ -29,8 +176,31 @@ class ResizeObserver { unobserve() { } disconnect() { } } -jest.mock('next/navigation', () => ({ - useParams: jest.fn(() => ({ id: undefined })), +jest.mock('next/navigation'); +jest.mock('next/router', () => ({ + useRouter() { + return ({ + route: '/', + pathname: '', + query: '', + asPath: '', + push: jest.fn(), + events: { + on: jest.fn(), + off: jest.fn() + }, + beforePopState: jest.fn(() => null), + prefetch: jest.fn(() => null) + }); + }, + Router: { + events: { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + } + } + })); // Assign the mock to the global window object @@ -45,7 +215,33 @@ describe('CreateEditTopologyPage', () => { jest.clearAllMocks(); }); - it('renders access denied page if user lacks privileges', async() => { + it('renders access denied page if user lacks privileges', async () => { + (useParams as jest.Mock).mockReturnValue({ id: undefined }); + usePrivileges.mockReturnValue({ + canCreateTypology: false, + canViewRuleWithConfigs: false, + canCreateRuleConfig: false, + canEditRule: false, + canReviewRule: false, + canEditConfig: false, + canReviewConfig: false, + privileges: [], + canViewRules: false, + canViewRuleConfigs: false, + canViewTypologyList: false, + canEditTypology: false, + canReviewTypology: false + }); + const spy = jest.spyOn(service, 'getTypology').mockResolvedValueOnce({ data: null } as any); + const result = render(); + expect(screen.queryByText('Rules')).not.toBeInTheDocument(); + expect(result.queryByText('Recently removed')).not.toBeInTheDocument(); + spy.mockClear(); + + }); + + it('renders access denied page if user lacks privileges and is edit mode', async () => { + (useParams as jest.Mock).mockReturnValue({ id: '1234' }); usePrivileges.mockReturnValue({ canCreateTypology: false, canViewRuleWithConfigs: false, @@ -61,14 +257,177 @@ describe('CreateEditTopologyPage', () => { canEditTypology: false, canReviewTypology: false }); - jest.spyOn(service,'getTypology').mockResolvedValueOnce({data: null} as any); + const spy = jest.spyOn(service, 'getTypology').mockResolvedValueOnce({ + data: {} + } as any); const result = render(); + expect(useParams).toHaveReturnedWith({ id: '1234' }); expect(screen.queryByText('Rules')).not.toBeInTheDocument(); expect(result.queryByText('Recently removed')).not.toBeInTheDocument(); + (useParams as jest.Mock).mockClear(); + (useParams as jest.Mock).mockReset(); + spy.mockClear(); + spy.mockReset(); + }); + + it('triggers set edit data when in edit mode', async () => { + (useParams as jest.Mock).mockReturnValue({ id: '1234' }); + usePrivileges.mockReturnValue({ + canCreateTypology: true, + canViewRuleWithConfigs: true, + canCreateRuleConfig: false, + canEditRule: false, + canReviewRule: false, + canEditConfig: false, + canReviewConfig: false, + privileges: [], + canViewRules: false, + canViewRuleConfigs: false, + canViewTypologyList: false, + canEditTypology: true, + canReviewTypology: false, + }); + const spy = jest.spyOn(service, 'getTypology').mockResolvedValueOnce({ data: mockData } as any); + mockGetRulesWithConfigs.mockResolvedValue({ + data: { + rules: [{ _key: '1', name: 'Rule 1' }], + }, + status: 200, + statusText: 'ok', + headers: {}, + config: {} as any + }); + render(); + expect(useParams).toHaveReturnedWith({ id: '1234' }); + await waitFor(() => expect(screen.getAllByText('Rules').length).toBeDefined()); + //expect rules to be added to react flow + expect(screen.getAllByTestId('node').length).toBe(5); //5 rule and all bands, cases and exit conditions + spy.mockClear(); + + }); + + + it('triggers calls router push on update success', async () => { + const push = jest.fn(); + (useParams as jest.Mock).mockReturnValue({ id: 'update-id' }); + const useRouter = jest.spyOn(require("next/router"), "useRouter"); + useRouter.mockImplementation(() => ({ + route: '/', + pathname: '', + query: '', + asPath: '', + push, + events: { + on: jest.fn(), + off: jest.fn() + }, + beforePopState: jest.fn(() => null), + prefetch: jest.fn(() => null) + })); + + usePrivileges.mockReturnValue({ + canCreateTypology: true, + canViewRuleWithConfigs: true, + canCreateRuleConfig: false, + canEditRule: false, + canReviewRule: false, + canEditConfig: false, + canReviewConfig: false, + privileges: [], + canViewRules: true, + canViewRuleConfigs: false, + canViewTypologyList: true, + canEditTypology: true, + canReviewTypology: true, + }); + const spy = jest.spyOn(service, 'getTypology').mockResolvedValueOnce({ data: mockData } as any); + const update = jest.spyOn(createService, 'updateTypology').mockResolvedValueOnce({ data: mockData } as any); + + mockGetRulesWithConfigs.mockResolvedValue({ + data: { + rules: [{...mockData, _key: '12234'}], + }, + status: 200, + statusText: 'ok', + headers: {}, + config: {} as any + }); + render(); + expect(useParams).toHaveReturnedWith({ id: 'update-id' }); + await waitFor(() => expect(screen.getAllByText('Rules').length).toBeDefined()); + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + await waitFor(() => expect(push).toHaveBeenCalled()); + update.mockClear(); + spy.mockClear(); + }); + + it('handles navigate away before save and call confirm ', async () => { + (useParams as jest.Mock).mockReturnValue({ id: undefined }); + usePrivileges.mockReturnValue({ + canCreateTypology: true, + canViewRuleWithConfigs: true, + canCreateRuleConfig: false, + canEditRule: false, + canReviewRule: false, + canEditConfig: false, + canReviewConfig: false, + privileges: [], + canViewRules: false, + canViewRuleConfigs: false, + canViewTypologyList: false, + canEditTypology: false, + canReviewTypology: false + }); + + mockGetRulesWithConfigs.mockResolvedValue({ + data: { + rules: [{ _key: '1', name: 'Rule 1' }, { _key: 2, name: 'Rule 2' }], + }, + status: 200, + statusText: 'ok', + headers: {}, + config: {} as any + }); + + render(); + + await waitFor(() => expect(mockGetRulesWithConfigs).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getAllByTestId('rule-drag-item').length).toBe(2)); + const ruleNode = screen.getAllByTestId('rule-drag-item')[0]; + expect(screen.getAllByTestId('rule-drag-item').length).toBe(2); + const flow = document.querySelector('.react-flow') as Element; + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn().mockImplementation((arg) => { + if (arg === 'type') { + return 'rule' + } + return JSON.stringify({ _key: '1', name: 'Rule 1' }) + }), + }; + + const preventDefault = jest.fn(); + fireEvent.dragStart(ruleNode, { dataTransfer }); + fireEvent.drop(flow, { + dataTransfer, + preventDefault, + }); + expect(dataTransfer.setData).toHaveBeenCalledWith('application/reactflow', 'node'); + expect(dataTransfer.setData).toHaveBeenCalledWith('type', 'rule'); + expect(dataTransfer.setData).toHaveBeenCalledWith('data', JSON.stringify({ _key: '1', name: 'Rule 1' })); + //rule removed from the list + await waitFor(() => expect(screen.getAllByTestId('rule-drag-item').length).toBe(1)); + //simulate navigate away + const confirmSpy = jest.spyOn(window, "confirm").mockReturnValue(true); + routeChangeComplete(); + expect(confirmSpy).toHaveBeenCalled(); }); it('fetches rules with configurations on mount if user has privileges', async () => { + (useParams as jest.Mock).mockReturnValue({ id: undefined }); + usePrivileges.mockReturnValue({ canCreateTypology: true, canViewRuleWithConfigs: true, @@ -104,6 +463,7 @@ describe('CreateEditTopologyPage', () => { }); it('handles onDrop for rule correctly ', async () => { + (useParams as jest.Mock).mockReturnValue({ id: undefined }); usePrivileges.mockReturnValue({ canCreateTypology: true, canViewRuleWithConfigs: true, @@ -162,6 +522,7 @@ describe('CreateEditTopologyPage', () => { }); it('handles delete rule correctly', async () => { + (useParams as jest.Mock).mockReturnValue({ id: undefined }); usePrivileges.mockReturnValue({ canCreateTypology: true, canViewRuleWithConfigs: true, @@ -222,6 +583,8 @@ describe('CreateEditTopologyPage', () => { }); it('handles onDrop for config correctly ', async () => { + (useParams as jest.Mock).mockReturnValue({ id: undefined }); + usePrivileges.mockReturnValue({ canCreateTypology: true, canViewRuleWithConfigs: true, @@ -292,6 +655,8 @@ describe('CreateEditTopologyPage', () => { }); it('handles delete config node correctly ', async () => { + (useParams as jest.Mock).mockReturnValue({ id: undefined }); + usePrivileges.mockReturnValue({ canCreateTypology: true, canViewRuleWithConfigs: true, @@ -363,7 +728,7 @@ describe('CreateEditTopologyPage', () => { // using index 1 because we added a rule with its configuration and configuration will be index 1 fireEvent.click(screen.getAllByTestId('show-recently-removed')[0]); //show removed items list - + // config added to deleted list await waitFor(() => expect(screen.getAllByTestId('rule-config-deleted').length).toBe(1)); @@ -371,6 +736,8 @@ describe('CreateEditTopologyPage', () => { }); it('handles delete rule node and removes all configurations associated ', async () => { + (useParams as jest.Mock).mockReturnValue({ id: undefined }); + usePrivileges.mockReturnValue({ canCreateTypology: true, canViewRuleWithConfigs: true, @@ -442,7 +809,7 @@ describe('CreateEditTopologyPage', () => { // using index 0 because we added a rule with its configuration. SO rule its at index 0 fireEvent.click(screen.getAllByTestId('show-recently-removed')[0]); //show removed items list - + // config and rule added to deleted list await waitFor(() => expect(screen.getAllByTestId('rule-config-deleted').length).toBe(2)); diff --git a/packages/config-svc-fe/src/domain/Typology/Create/index.tsx b/packages/config-svc-fe/src/domain/Typology/Create/index.tsx index 0cebcaa..6576dc0 100644 --- a/packages/config-svc-fe/src/domain/Typology/Create/index.tsx +++ b/packages/config-svc-fe/src/domain/Typology/Create/index.tsx @@ -6,14 +6,13 @@ import usePrivileges from "~/hooks/usePrivileges"; import AccessDeniedPage from "~/components/common/AccessDenied"; import { useNodesState, useEdgesState, Position, addEdge, NodeMouseHandler, Node, ConnectionLineType } from "reactflow"; import { IRuleConfig } from "~/domain/Rule/RuleConfig/RuleConfigList/types"; -import dagre from 'dagre'; import { useForm } from "react-hook-form"; import * as yup from 'yup'; import { yupResolver } from "@hookform/resolvers/yup"; -import { createTypology } from "./service"; +import { createNodesAndEdges, createTypology, updateLayout, updateTypology } from "./service"; import { Modal } from "antd"; import { useCommonTranslations } from "~/hooks"; -import { useRouter } from "next/router"; +import { Router, useRouter } from "next/router"; import { useParams } from "next/navigation"; import { getTypology } from "../Score/service"; @@ -25,8 +24,7 @@ const nodeDefaults = { targetPosition: Position.Left, type: 'customNode' }; -const nodeWidth = 172; -const nodeHeight = 36; + const initialNodes = [ { @@ -47,14 +45,14 @@ const initialEdges = [ }, ]; const CreateEditTopologyPage = () => { - + const [rules, setRules] = useState([]); const [ruleOptions, setRuleOptions] = useState([]); const [modal, contextHolder] = Modal.useModal(); const [page, setPage] = useState(1); const [loadingRules, setLoadingRules] = useState(true); const [error, setError] = useState(''); - const { canCreateTypology, canViewRuleWithConfigs } = usePrivileges(); + const { canCreateTypology, canViewRuleWithConfigs, canEditTypology } = usePrivileges(); const reactFlowWrapper = useRef(null); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -63,27 +61,31 @@ const CreateEditTopologyPage = () => { const [ruleDragIndex, setRuleDragIndex] = useState(null); const [removedRules, setRemoveRules] = useState([]); const [saveLoading, setSaveLoading] = useState(false); - const {t} = useCommonTranslations(); + const [saved, setSaved] = useState(false); + const { t } = useCommonTranslations(); const router = useRouter(); - const {id} = useParams(); + const { id } = useParams(); + const isEditMode = useMemo(() => { + return !!id; + }, [id]) const schema = useMemo(() => { return yup.object().shape({ - name: yup.string().required(), - description: yup.string().required(), - minor: yup.number().required(), - major: yup.number().required(), - patch: yup.number().required(), + name: yup.string().required(), + description: yup.string().required(), + minor: yup.number().required(), + major: yup.number().required(), + patch: yup.number().required(), }); - }, []); + }, []); - const {control, handleSubmit, formState, watch, reset, trigger, getValues, setValue} = useForm({ + const { control, handleSubmit, formState, watch, reset, trigger, getValues, setValue } = useForm({ resolver: yupResolver(schema), mode: 'onChange' - }); + }); - const save = async(data: any, openScoreMode = false) => { - const instanceLoading = modal.info({ + const save = async (data: any, openScoreMode = false) => { + const instanceLoading = modal.info({ title: t('typologyCreatePage.saving'), content: t('typologyCreatePage.pleaseWait'), okButtonProps: { @@ -97,32 +99,66 @@ const CreateEditTopologyPage = () => { const obj = { state: '01-Draft', desc: data.description, - cfg: `${data.major || 0 }.${data.minor || 0}.${data.patch || 0}`, + cfg: `${data.major || 0}.${data.minor || 0}.${data.patch || 0}`, name: data.name, typologyCategoryUUID: [], - rules_rule_configs: attachedRules.map((rule) => ({ruleId: rule._id, ruleConfigId: rule.attachedConfigs.map((c) => (c._id))})), + rules_rule_configs: attachedRules.map((rule) => ({ ruleId: rule._id, ruleConfigId: rule.attachedConfigs.map((c) => (c._id)) })), } - const res = await createTypology(obj); - reset(); - instanceLoading.destroy(); - const instanceSuccess = modal.success({ - title: 'Success', - content: t('typologyCreatePage.typologyCreated'), - okButtonProps: { - style: { - backgroundColor: 'red', + if (isEditMode) { + //save changes and do nothing; + const res = await updateTypology({ + ...obj, + }, id as string); + const instanceSuccess = modal.success({ + title: 'Success', + content: t('typologyCreatePage.typologyUpdated'), + okButtonProps: { + style: { + backgroundColor: 'red', + } } + }); + setSaved(true); + + instanceLoading.destroy(); + setTimeout(() => { + instanceSuccess.destroy(); + }, 3000); + if (openScoreMode) { + router.push(`/typology/${res.data._key}/score`); + } else { + if (res.data?._key) { + router.push(`/typology/edit/${res.data._key}`); + } + } + } else { + const res = await createTypology(obj); + reset(); + instanceLoading.destroy(); + const instanceSuccess = modal.success({ + title: 'Success', + content: t('typologyCreatePage.typologyCreated'), + okButtonProps: { + style: { + backgroundColor: 'red', + } + } + }); + setNodes([...initialNodes]); + setEdges([...initialEdges]); + setAttachedRules([]); + setTimeout(() => { + instanceSuccess.destroy(); + }, 10000); + setSaved(true); + if (openScoreMode) { + router.push(`/typology/${res?.data?._key}/score`); + } else { + router.push(`/typology/edit/${res?.data?._key}`); + } - }); - setNodes([...initialNodes]); - setEdges([...initialEdges]); - setAttachedRules([]); - setTimeout(() => { - instanceSuccess.destroy(); - }, 10000); - if(openScoreMode) { - router.push(`/typology/${res?.data?._key}/score`); - } + } + } catch (e: any) { instanceLoading.destroy(); setError(e?.response?.data?.message || e?.message || 'Something went wrong'); @@ -134,49 +170,45 @@ const CreateEditTopologyPage = () => { backgroundColor: 'red', } } - }); + }); } finally { setSaveLoading(false); + instanceLoading.destroy(); } - - } - - const onSubmit = async (data: any) => { + + } + + const onSubmit = async (data: any) => { await save(data); - } - - ; + }; const onConnect = useCallback( (params: any) => - setEdges((eds) => - addEdge({ ...params, type: ConnectionLineType.SmoothStep, animated: true }, eds) - ), + setEdges((eds) => + addEdge({ ...params, type: ConnectionLineType.SmoothStep, animated: true }, eds) + ), [] ); const onOpenScoreMode = () => { - if(formState.isDirty || attachedRules.length) { + if (formState.isDirty || attachedRules.length) { modal.confirm({ title: 'Save in Drafts', content: 'Would you like to save your changes as draft before switching to score view', cancelText: 'Dont Save', okText: 'Save Changes', - okButtonProps:{ + okButtonProps: { className: 'bg-green-500 text-white' }, - onOk: async() => { - trigger(['name', 'description', 'major', 'minor', 'patch']); - if(formState.isValid) { + onOk: async () => { await save(getValues(), true); - } } }) } else { modal.info({ title: 'No changes', content: 'No changes have been yet. Please add new changes before switching to scoring view', - okButtonProps:{ + okButtonProps: { className: 'bg-green-500 text-white' } }) @@ -188,13 +220,17 @@ const CreateEditTopologyPage = () => { setLoadingRules(true); getRulesWithConfigs({ page, limit: 100 }) .then(({ data }) => { - setRules(data?.rules || []); + if (!isEditMode) { + setRules(data?.rules || []); + } else { + handleSetEditData(data?.rules || []) + } }).finally(() => { setLoadingRules(false) }).catch((e) => { setError(e.response?.data?.message || e?.message || 'Something went wrong getting configurations'); }) - }, [page]); + }, [page, isEditMode]); const onNodeClick: NodeMouseHandler = (_event, node) => { if (node.type === 'customNode' && node.data.type === 'rule') { @@ -211,11 +247,11 @@ const CreateEditTopologyPage = () => { setEdges([...newEdges]); if (type === 'rule') { const newEdges = edges.filter((r) => r.source !== id) - .filter((r) => r.target !== id); + .filter((r) => r.target !== id); setEdges([...newEdges]); const rule = rules.find((r) => r._key === id); setNodes([...newNodes.filter((n: any) => { - if(n.type === 'rule') { + if (n.type === 'rule') { return true; } return (n?.data?.ruleId) !== id; @@ -224,7 +260,7 @@ const CreateEditTopologyPage = () => { const newAttached = prev.filter((r) => r._key !== id); return [...newAttached]; }); - + if (rule) { setRemoveRules((prev) => { const nonExistRemovedConfigs = []; @@ -235,8 +271,8 @@ const CreateEditTopologyPage = () => { } removedConfigs.forEach((config) => { const exist = prev.find((r) => r._key === config.id); - if(!exist) { - nonExistRemovedConfigs.push({...config.data, ruleName: rule.name}); + if (!exist) { + nonExistRemovedConfigs.push({ ...config.data, ruleName: rule.name }); } }) @@ -246,6 +282,12 @@ const CreateEditTopologyPage = () => { ] as IRule[] }); setRuleOptions((prev) => { + const ruleExists = prev.find((r) => r._key === rule._key); + if (ruleExists) { + return [ + ...prev, + ] + } return [ ...prev, rule, @@ -283,7 +325,7 @@ const CreateEditTopologyPage = () => { const attachedRulesIds = attachedRules.map((r) => r._key); const newOptions = rules.filter((r) => r._key !== rule._key); setRuleOptions([...newOptions.filter((r) => !attachedRulesIds.includes(r._key))]); - const newAttachedRules = [...attachedRules, { ...rule, attachedConfigs: [] }]; + const newAttachedRules = [...attachedRules, { ...rule, attachedConfigs: [] }]; const currentAttachedRules = newAttachedRules; const updateRuleIndex = newAttachedRules.findIndex((r) => r._key === config.ruleId); @@ -297,32 +339,6 @@ const CreateEditTopologyPage = () => { }, [attachedRules, rules,]); - const updateLayout = (nodes: any[], edges: any[]) => { - const graph = new dagre.graphlib.Graph(); - graph.setGraph({rankdir: 'LR'}); - graph.setDefaultEdgeLabel(() => ({})); - - nodes.forEach(node => { - graph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); // Set width and height for each node - }); - - edges.forEach(edge => { - graph.setEdge(edge.source, edge.target); // Add edges to the graph - }); - - dagre.layout(graph); // Apply Dagre layout algorithm - - // Update positions of nodes based on Dagre layout - const layoutedNodes = nodes.map(node => ({ - ...node, - position: { - x: graph.node(node.id).x - nodeWidth / 2 , - y: graph.node(node.id).y - nodeHeight / 2 - } - })); - setNodes(layoutedNodes); - } - const onDrop = useCallback((event: any) => { event.preventDefault(); setRuleDragIndex(null); @@ -343,22 +359,24 @@ const CreateEditTopologyPage = () => { onDelete: handleDelete, type: 'rule', showDelete: true }, - position: { x: 250, y: 100 }, + position: { x: 250, y: nodes[0]?.position?.y || 100 }, type: 'customNode' } const edge = { id: rule._key, source: '1', target: node.id, + type: 'smoothstep' } setNodes([...nodes, node]); - setEdges([...edges, edge]) + setEdges([...edges, edge]); + updateLayout([...nodes, node], [...edges, edge]); } else { const node = { ...nodeDefaults, id: rule._key, data: { label: rule.name, ...rule, onDelete: handleDelete, type: 'rule', showDelete: true }, - position: { x: 250, y: nodes[nodes.length - 1].position.y + 100 }, + position: { x: 250, y: nodes[nodes.length - 1].position.y + 50 }, type: 'customNode' } const edge = { @@ -367,9 +385,9 @@ const CreateEditTopologyPage = () => { target: node.id, type: 'smoothstep' } - setNodes([...nodes, node]); + const layedOutNodes = updateLayout([...nodes, node], [...edges, edge]); + setNodes([...layedOutNodes]); setEdges([...edges, edge]); - updateLayout([...nodes, node], [...edges, edge]); } const attachedRulesIds = attachedRules.map((r) => r._key); const newOptions = rules.filter((r) => r._key !== rule._key); @@ -380,7 +398,7 @@ const CreateEditTopologyPage = () => { const config: IRuleConfig = JSON.parse(data); const parentNode: any = nodes.find((n: any) => n.id === config.ruleId && (n?.data?.type) === 'rule'); //handle if rule for rule configuration has been added already; - if (parentNode) { + if (parentNode) { const nodeConfig = { id: config._key, position: { x: 500, y: 100 }, @@ -400,9 +418,9 @@ const CreateEditTopologyPage = () => { target: nodeConfig.id, type: 'smoothstep' } - setNodes([...nodes, nodeConfig]); setEdges([...edges, edge]); - updateLayout([...nodes, nodeConfig], [...edges, edge]); + const layedOutNodes = updateLayout([...nodes, nodeConfig], [...edges, edge]); + setNodes(layedOutNodes); const currentAttachedRules = attachedRules; const updateRuleIndex = attachedRules.findIndex((r) => r._key === config.ruleId); if (updateRuleIndex !== -1) { @@ -435,7 +453,7 @@ const CreateEditTopologyPage = () => { source: '1', target: parentNode.id, type: 'smoothstep' - } + } const configNode = { id: config._key, position: { x: 500, y: 100 }, @@ -455,9 +473,9 @@ const CreateEditTopologyPage = () => { target: configNode.id, type: 'smoothstep' } - setNodes([...nodes, parentNode, configNode]); setEdges([...edges, parentEdge, configEdge]); - updateLayout([...nodes, parentNode, configNode], [...edges, parentEdge, configEdge]); + const layedOutNodes = updateLayout([...nodes, parentNode, configNode], [...edges, parentEdge, configEdge]); + setNodes(layedOutNodes); handleRuleNodeAdded(rule, config); } @@ -466,41 +484,75 @@ const CreateEditTopologyPage = () => { } }, [nodes, rules, attachedRules, edges, handleDelete]); - useEffect(() => { - if (canViewRuleWithConfigs && !id) { - fetchRuleWithConfigurations(); - } else { - //fetch typology and show rules - getTypology(id as string) - .then(({data}) => { - setValue('name', data?.name || ''); - setValue('description', data?.desc || ''); - const [major, minor, patch] = (data?.cfg || '').split('.') - setValue('major', Number(major) || 0, {shouldDirty: true, shouldTouch: true}); - setValue('minor', Number(minor) || 0, {shouldDirty: true, shouldTouch: true}); - setValue('patch', Number(patch) || 0, {shouldDirty: true, shouldTouch: true}); + const handleSetEditData = useCallback((rules: IRule[]) => { + getTypology(id as string) + .then(({ data }) => { + setValue('name', data?.name || '', { shouldDirty: true, shouldTouch: true }); + setValue('description', data?.desc || '', { shouldDirty: true, shouldTouch: true }); + const [major, minor, patch] = (data?.cfg || '').split('.') + setValue('major', !isNaN(Number(major)) ? Number(major) : 0, { shouldDirty: true, shouldTouch: true }); + setValue('minor', !isNaN(Number(minor)) ? Number(minor) : 0, { shouldDirty: true, shouldTouch: true }); + setValue('patch', !isNaN(Number(patch)) ? Number(patch) : 0, { shouldDirty: true, shouldTouch: true }); let rulesArray: IRule[] = []; data?.ruleWithConfigs.forEach((d) => { const rule = { ...d.rule, - ruleConfigs: (d.ruleConfigs || []).map((c) => ({...c, ruleId: d.rule._key})) as any, + ruleConfigs: (d.ruleConfigs || []).map((c) => ({ ...c, ruleId: d.rule._key })) as any, } as Partial rulesArray.push(rule as IRule); }); - - setRules(rulesArray); + const { nodes: newNodes, edges: newEdges } = createNodesAndEdges(rulesArray, handleDelete); + const layedOutNodes = updateLayout([...initialNodes, ...newNodes], [...initialEdges, ...newEdges]); + setNodes([...layedOutNodes]); + setEdges([...initialEdges, ...newEdges]); + const options: IRule[] = []; + rules.forEach((r) => { + const exists = rulesArray.find((rule) => rule._key === r._key); + if (!exists) { + options.push(r); + } + }); + setRules(rules); + setRuleOptions([...options]); + setAttachedRules([...rulesArray.map((r) => ({ ...r, attachedConfigs: r.ruleConfigs }))]) }).catch((e) => { setError(e.response?.data?.message || e?.message || 'Something went wrong getting configurations'); }).finally(() => { setLoadingRules(false); }) + }, [id]); + + useEffect(() => { + if (canViewRuleWithConfigs) { + fetchRuleWithConfigurations(); } - }, [fetchRuleWithConfigurations, canViewRuleWithConfigs, id]); + }, [fetchRuleWithConfigurations, canViewRuleWithConfigs]); + + useEffect(() => { + const handler = () => { + if ((formState.isDirty || attachedRules.length)) { + if(!saved && !isEditMode) { + const confirm = window.confirm('You have unsaved changes. Are you sure you want to navigate away'); + if (!confirm) { + throw 'Please save changes to continue'; + } + } + } + setSaved(false); + } + Router.events.on('beforeHistoryChange', handler); + return () => { + Router.events.off("beforeHistoryChange", handler); + }; + }, [formState.isDirty, attachedRules, saved, isEditMode]); if (!canCreateTypology) { return } + if (isEditMode && !canEditTypology) { + return + } return <> { onOpenScoreMode={onOpenScoreMode} /> - {contextHolder} + {contextHolder} } export default CreateEditTopologyPage; diff --git a/packages/config-svc-fe/src/domain/Typology/Create/service.ts b/packages/config-svc-fe/src/domain/Typology/Create/service.ts index 50cf296..6042fc0 100644 --- a/packages/config-svc-fe/src/domain/Typology/Create/service.ts +++ b/packages/config-svc-fe/src/domain/Typology/Create/service.ts @@ -1,5 +1,104 @@ +import { Edge, Node, Position } from "reactflow"; +import dagre from 'dagre'; import { Api } from "~/client" +import { IRule } from "~/domain/Rule/RuleDetailPage/service" + +const defaultNodeWidth = 172; +const defaultNodeHeight = 36; + +export const nodeDefaults = { + sourcePosition: Position.Right, + targetPosition: Position.Left, + type: 'customNode', + width: defaultNodeWidth, + height: defaultNodeHeight +}; + export const createTypology = (data: any) => { return Api.post('/typology', {...data}) +} + +export const updateTypology = (data: any, id: string) => { + return Api.patch(`/typology/${id}`, {...data}); +} + +export const createNodesAndEdges = (rules: IRule[], handleDelete: (id: string, type: string) => void) => { + const nodes: Node[] = []; + const edges: Edge[] = []; + + rules.forEach((rule, i) => { + const parentNode: Node = { + id: rule._key, + ...nodeDefaults, + data: { + ...rule, + label: rule.name, + onDelete: handleDelete, + type: 'rule', + showDelete: true, + }, + position: { + x: 250, + y: 100 + (i * 20), + } + } + const parentEdge: Edge = { + id: parentNode.id, + source: '1', + target: parentNode.id, + } + nodes.push(parentNode); + edges.push(parentEdge); + rule?.ruleConfigs.forEach((config, index) => { + const configNode: Node = { + id: config._key, + ...nodeDefaults, + data: { + ...config, + label: `${rule.name}-config-${config.cfg}`, + showDelete: true, + onDelete: handleDelete, + }, + position: { + x: 500, y: 100 + (index * 20) + }, + + } + const configEdge: Edge = { + id: config._key, + source: rule._key, + target: config._key + } + nodes.push(configNode); + edges.push(configEdge); + }); + }) + return {nodes, edges} +} + +export const updateLayout = (nodes: any[], edges: any[]) => { + const graph = new dagre.graphlib.Graph(); + graph.setGraph({rankdir: 'LR'}); + graph.setDefaultEdgeLabel(() => ({})); + + nodes.forEach(node => { + graph.setNode(node.id, { width: defaultNodeWidth, height: defaultNodeHeight }); // Set width and height for each node + }); + + edges.forEach(edge => { + graph.setEdge(edge.source, edge.target); // Add edges to the graph + }); + + dagre.layout(graph); // Apply Dagre layout algorithm + + // Update positions of nodes based on Dagre layout + const layoutedNodes = nodes.map(node => ({ + ...node, + position: { + x: graph.node(node.id).x - defaultNodeWidth / 2 , + y: graph.node(node.id).y - defaultNodeHeight / 2 + } + })); + return layoutedNodes; } \ No newline at end of file diff --git a/packages/config-svc-fe/src/domain/Typology/Create/style.module.scss b/packages/config-svc-fe/src/domain/Typology/Create/style.module.scss index b2337d3..ac5e421 100644 --- a/packages/config-svc-fe/src/domain/Typology/Create/style.module.scss +++ b/packages/config-svc-fe/src/domain/Typology/Create/style.module.scss @@ -4,15 +4,15 @@ .custom-node { display: flex; - align-items: flex-end; + align-items: center; justify-content: space-around; - padding: 10px; + padding: 8px; background: #fff; border: 1px solid #ddd; border-radius: 5px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); min-width: 10rem; - width: 100; + height: 3rem; } .custom-node-content { diff --git a/packages/config-svc-fe/src/domain/Typology/List/List.tsx b/packages/config-svc-fe/src/domain/Typology/List/List.tsx index 20ee940..75007c4 100644 --- a/packages/config-svc-fe/src/domain/Typology/List/List.tsx +++ b/packages/config-svc-fe/src/domain/Typology/List/List.tsx @@ -133,7 +133,7 @@ const List: React.FunctionComponent = ({ loading, error, retry, data, tot key: 'action', render: (_, record) => ( - {canEditTypology && } + {canEditTypology && {commonTranslations('typologyListPage.table.modify')} } {canReviewTypology && Score } ), diff --git a/packages/config-svc-fe/src/domain/Typology/Score/Rules.tsx b/packages/config-svc-fe/src/domain/Typology/Score/Rules.tsx index 7347eb1..8a1a512 100644 --- a/packages/config-svc-fe/src/domain/Typology/Score/Rules.tsx +++ b/packages/config-svc-fe/src/domain/Typology/Score/Rules.tsx @@ -1,13 +1,12 @@ import { FileDoneOutlined, PlusOutlined } from "@ant-design/icons" import { Empty, Input, Typography } from "antd" import { useEffect } from "react" -import { IRule } from "~/domain/Rule/RuleDetailPage/service" import { useCommonTranslations } from "~/hooks"; import { sortAlphabetically } from "~/utils"; import { RuleWithConfig } from "./service"; export interface RuleProps { - rules: RuleWithConfig[]; + rules: RuleWithConfig[] | any[]; ruleOptions: RuleWithConfig[]; setRuleOptions: (rules: RuleWithConfig[]) => void; selectedRule: null | string; diff --git a/packages/config-svc-fe/src/domain/Typology/Score/Score.tsx b/packages/config-svc-fe/src/domain/Typology/Score/Score.tsx index 3c4d757..9dea4ae 100644 --- a/packages/config-svc-fe/src/domain/Typology/Score/Score.tsx +++ b/packages/config-svc-fe/src/domain/Typology/Score/Score.tsx @@ -4,7 +4,7 @@ import { IRule } from '~/domain/Rule/RuleDetailPage/service'; import { Flow } from '../Create/Flow'; import { IRuleConfig } from '~/domain/Rule/RuleConfig/RuleConfigList/types'; import { AttachedRules } from '../Create/index'; -import { NodeMouseHandler, ReactFlowProps } from 'reactflow'; +import { Controls, MiniMap, NodeMouseHandler, ReactFlowProps } from 'reactflow'; import { Conditions } from './Conditions'; import { Rules } from './Rules'; import styles from './style.module.scss'; @@ -17,7 +17,7 @@ import { useCommonTranslations } from '~/hooks'; import { RuleWithConfig } from './service'; interface Props { - rules: RuleWithConfig[]; + rules: RuleWithConfig[] | any[]; loadingRules: boolean; nodes: any[]; edges: any[]; @@ -26,15 +26,15 @@ interface Props { onEdgesChange: any; onDrop: DragEventHandler; flowRef: any - ruleOptions: IRule[]; - setRuleOptions: (rules: IRule[]) => void; + ruleOptions: RuleWithConfig[]; + setRuleOptions: (rules: RuleWithConfig[]) => void; selectedRule: null | string; setSelectedRuleIndex: (index: string | null) => void; attachedRules: AttachedRules[]; ruleDragIndex: null | number; setRuleDragIndex: (index: number | null) => void; handleDelete: (id: string, type: string) => void; - recentlyRemovedRules: IRule[] | IRuleConfig[]; + recentlyRemovedRules: RuleWithConfig[] | IRuleConfig[]; onNodeClick: NodeMouseHandler; saveLoading: boolean; handleSelectRule: (id: string) => void; @@ -59,7 +59,10 @@ export const Score: React.FunctionComponent, + children: , }, { key: '3', @@ -96,7 +99,9 @@ export const Score: React.FunctionComponent
- + + +
@@ -117,7 +122,10 @@ export const Score: React.FunctionComponent + > + + + diff --git a/packages/config-svc-fe/src/domain/Typology/Score/__tests__/index.spec.tsx b/packages/config-svc-fe/src/domain/Typology/Score/__tests__/index.spec.tsx index 803b649..7b47a7d 100644 --- a/packages/config-svc-fe/src/domain/Typology/Score/__tests__/index.spec.tsx +++ b/packages/config-svc-fe/src/domain/Typology/Score/__tests__/index.spec.tsx @@ -94,38 +94,33 @@ const data: ITypology = { "reason": "Band Reason 2", "subRuleRef": "0.1", "value": 1, - outcome: false + }, { "reason": "Reason 2", "subRuleRef": "0.2", "value": 2, - outcome: false + } ], "exitConditions": [ { - "outcome": true, "reason": "Insufficient transaction history. At least 50 historical transactions are required", "subRuleRef": ".x01", }, { - "outcome": true, "reason": "Insufficient transaction history. At least 50 historical transactions are required", "subRuleRef": ".x02", }, { - "outcome": true, "reason": "Insufficient transaction history. At least 50 historical transactions are required", "subRuleRef": ".x03", }, { - "outcome": true, "reason": "No variance in transaction history and the volume of recent incoming transactions shows an increase", "subRuleRef": ".x04", }, { - "outcome": true, "reason": "No variance in transaction history and the volume of recent incoming transactions is less than or equal to the historical average", "subRuleRef": ".x05", } diff --git a/packages/config-svc-fe/src/domain/Typology/Score/helpers.ts b/packages/config-svc-fe/src/domain/Typology/Score/helpers.ts index 367f2b7..4585142 100644 --- a/packages/config-svc-fe/src/domain/Typology/Score/helpers.ts +++ b/packages/config-svc-fe/src/domain/Typology/Score/helpers.ts @@ -1,5 +1,5 @@ import { Position } from "reactflow"; -import { RuleConfig, RuleWithConfig } from "./service"; +import { RuleConfig } from "./service"; const defaultNodeWidth = 172; const defaultNodeHeight = 36; @@ -11,25 +11,8 @@ export const nodeDefaults = { width: defaultNodeWidth, height: defaultNodeHeight }; -export const groupItemsByGroupId = (data: { rules: any[] }) => { - const items: any[] = []; - - data.rules.forEach((rule, index) => { - items.push({ - id: rule.id, - data: { - label: rule.name, - ...rule - }, - index - }); - }); - - return items; -}; export const createNodesAndEdges = (rules: any[]) => { - // const items = groupItemsByGroupId(data); const newNodes: any[] = []; const newEdges: any[] = []; @@ -60,12 +43,12 @@ export const extractOutcomes = (ruleConfigs: RuleConfig[], ruleId: string) => { let combinedArray: any[] = []; // Iterate over each ruleWithConfigs -// ruleWithConfigs.forEach(ruleWithConfig => { - // Iterate over each ruleConfig within the ruleWithConfig +// ruleWithConfigs.forEach(ruleWithConfig => { + // Iterate over each ruleConfig within the ruleWithConfig ruleConfigs.forEach(ruleConfig => { const config = ruleConfig.config; // Check if exitConditions exist and add them to the combined array with type 'exit-conditions' - if (config.exitConditions.length) { + if (config?.exitConditions?.length) { config.exitConditions.forEach(item => { combinedArray.push({ ...item, @@ -76,7 +59,7 @@ export const extractOutcomes = (ruleConfigs: RuleConfig[], ruleId: string) => { } // Check if bands exist and add them to the combined array with type 'bands' - if (config.bands.length) { + if (config?.bands?.length) { config.bands.forEach(item => { combinedArray.push({ ...item, @@ -88,7 +71,7 @@ export const extractOutcomes = (ruleConfigs: RuleConfig[], ruleId: string) => { } // Check if cases exist and add them to the combined array with type 'cases' - if (config.cases.length) { + if (config?.cases?.length) { config.cases.forEach(item => { combinedArray.push({ ...item, @@ -98,7 +81,6 @@ export const extractOutcomes = (ruleConfigs: RuleConfig[], ruleId: string) => { }); } }); -// }); // Return the combined array return combinedArray; diff --git a/packages/config-svc-fe/src/domain/Typology/Score/index.tsx b/packages/config-svc-fe/src/domain/Typology/Score/index.tsx index b175a25..dd009a6 100644 --- a/packages/config-svc-fe/src/domain/Typology/Score/index.tsx +++ b/packages/config-svc-fe/src/domain/Typology/Score/index.tsx @@ -3,12 +3,11 @@ import { Score } from "./Score" import { Modal } from "antd"; import { useState, useRef, useEffect, DragEventHandler, useCallback } from "react"; import { IRuleConfig } from "~/domain/Rule/RuleConfig/RuleConfigList/types"; -import { IRule } from "~/domain/Rule/RuleDetailPage/service"; import usePrivileges from "~/hooks/usePrivileges"; import { AttachedRules } from "../Create"; import React from "react"; import AccessDeniedPage from "~/components/common/AccessDenied"; -import { ITypology, config, getTypology, typologyData } from "./service"; +import { ITypology, RuleWithConfig, getTypology } from "./service"; import dagre from 'dagre'; import { nodeDefaults, createNodesAndEdges, extractOutcomes } from "./helpers"; import { getRandomNumber } from "~/utils/getRandomNumberHelper"; @@ -40,9 +39,8 @@ const initialEdges = [ const ScorePage = () => { const { id } = useParams(); const [rules, setRules] = useState([]); - const [ruleOptions, setRuleOptions] = useState([]); + const [ruleOptions, setRuleOptions] = useState([]); const [modal, contextHolder] = Modal.useModal(); - const [page, setPage] = useState(1); const [loadingRules, setLoadingRules] = useState(false); const [error, setError] = useState(''); const { canReviewTypology } = usePrivileges(); @@ -52,7 +50,7 @@ const ScorePage = () => { const [selectedRule, setSelectedRuleIndex] = useState(null); const [attachedRules, setAttachedRules] = useState([]); const [ruleDragIndex, setRuleDragIndex] = useState(null); - const [removedRules, setRemoveRules] = useState([]); + const [removedRules, setRemoveRules] = useState([]); const [saveLoading, setSaveLoading] = useState(false); const [typology, setTypology] = useState({} as ITypology); const [outcomes, setOutComes] = useState([]); @@ -223,7 +221,7 @@ const ScorePage = () => { ...nodeDefaults, id: getRandomNumber(10000).toString(), data: { ...data, label: `${data.type}: ${data.subRuleRef}`, type: 'outcome', score: 0, showDelete: true }, - position: { x: 250, y: nodes[nodes.length - 1].position.y + 100 }, + position: { x: 250, y: nodes[nodes.length - 1].position.y }, type: 'customNode' }; newNodes.push(outcomeNode); @@ -246,7 +244,7 @@ const ScorePage = () => { outcomeId: outcomeNode.id, onScoreChange: handleScoreChange }, - position: { x: 250, y: outcomeNode.position.y + 100 }, + position: { x: 250, y: outcomeNode.position.y }, type: 'scoreNode' }; newNodes.push(scoreNode); diff --git a/packages/config-svc-fe/src/domain/Typology/Score/service.ts b/packages/config-svc-fe/src/domain/Typology/Score/service.ts index a865334..afe4699 100644 --- a/packages/config-svc-fe/src/domain/Typology/Score/service.ts +++ b/packages/config-svc-fe/src/domain/Typology/Score/service.ts @@ -96,6 +96,7 @@ export interface ITypology { export interface RuleWithConfig { rule: Rule ruleConfigs: RuleConfig[] + typologyId?: string; } export interface Rule { diff --git a/packages/config-svc-fe/src/hooks/usePrivileges.ts b/packages/config-svc-fe/src/hooks/usePrivileges.ts index aee7f84..f45156e 100644 --- a/packages/config-svc-fe/src/hooks/usePrivileges.ts +++ b/packages/config-svc-fe/src/hooks/usePrivileges.ts @@ -54,6 +54,9 @@ const usePrivileges = () => { return user?.privileges?.includes('SECURITY_GET_TYPOLOGY'); }, [user]); + const canCreateNetworkMap = useMemo(() => { + return user?.privileges?.includes('SECURITY_CREATE_NETWORK_MAP'); + }, [user]); return { canCreateRuleConfig, canEditRule, @@ -68,6 +71,7 @@ const usePrivileges = () => { canEditTypology, canReviewTypology, canCreateTypology, + canCreateNetworkMap, }; }; diff --git a/packages/config-svc-fe/src/i18n/common/de.ts b/packages/config-svc-fe/src/i18n/common/de.ts index 78cfb9f..bc6b118 100644 --- a/packages/config-svc-fe/src/i18n/common/de.ts +++ b/packages/config-svc-fe/src/i18n/common/de.ts @@ -387,22 +387,27 @@ export const deCommon = { }, typologyCreatePage: { title: 'Regeln', - searchRules: 'Regeln Suchen', - recentlyRemoved: 'Kürzlich entfernt', - ruleConfiguration: 'Regelkonfiguration', - searchConfigurations: 'Konfigurationen Suchen', - noRuleConfigurations: 'Keine Regelkonfigurationen', - setRule: 'Bitte setzen Sie eine Regel, um Konfigurationen anzuzeigen', - rulesAttached: "Angehängte Regeln", - noRulesAttached: "Keine Regeln angehängt", - configurations: "Konfiguration", - noConfigurations: "Keine Konfigurationen", - rulesConfigurationsAttached: "Angehängte Regelkonfigurationen", - structureTitle: "Struktur", - searchPlaceholder: "Suche", - saving: "Speichern.....", - pleaseWait: "Bitte warten....", - typologyCreated: "Typologie wurde erstellt" + searchRules: 'Regeln Suchen', + recentlyRemoved: 'Kürzlich entfernt', + ruleConfiguration: 'Regelkonfiguration', + searchConfigurations: 'Konfigurationen Suchen', + noRuleConfigurations: 'Keine Regelkonfigurationen', + setRule: 'Bitte setzen Sie eine Regel, um Konfigurationen anzuzeigen', + rulesAttached: "Angehängte Regeln", + noRulesAttached: "Keine Regeln angehängt", + configurations: "Konfiguration", + noConfigurations: "Keine Konfigurationen", + rulesConfigurationsAttached: "Angehängte Regelkonfigurationen", + structureTitle: "Struktur", + searchPlaceholder: "Suche", + saving: "Speichern.....", + pleaseWait: "Bitte warten....", + typologyCreated: "Typologie wurde erstellt", + "typologyDetails": "Typologie Details", + "version": "Version", + "ruleConfigs": "Regelkonfigurationen", + typologyUpdated: 'Die Typologie wurde erfolgreich aktualisiert', + }, typologyScorePage: { conditions: "Bedingungen", @@ -416,7 +421,70 @@ export const deCommon = { rulesCount: "Regeln", outcomesCount: "Ergebnisse", maxScore: "Maximale Punktzahl", - search: 'Suchen' + search: 'Suchen', + "openScoringView": "In Bewertungsansicht öffnen", + "keepInDrafts": "In Entwürfen behalten" + }, + ruleReviewPage: { + version: 'Version', + description: 'Beschreibung', + created: 'Erstellt', + updated: 'Aktualisiert', + state: 'Status', + rule: 'Regel', + review: 'Überprüfung', + backToList: 'Zurück zur Liste', + approve: 'Genehmigen', + cancel: 'Abbrechen', + errorTitle: 'Fehler', + errorMessage: 'Ein Fehler ist aufgetreten', + retry: 'Wiederholen' + }, + ruleConfigReviewPage: { + version: 'Version', + description: 'Beschreibung', + created: 'Erstellt', + updated: 'Aktualisiert', + state: 'Status', + rule: 'Regel', + review: 'Überprüfung', + backToList: 'Zurück zur Liste', + approve: 'Genehmigen', + cancel: 'Abbrechen', + errorTitle: 'Fehler', + errorMessage: 'Ein Fehler ist aufgetreten', + retry: 'Wiederholen' +}, + createEditNetworkMap: { + searchRules: "Regeln suchen", + noRulesAttached: "Keine Regeln angehängt", + searchConfigurations: "Konfigurationen suchen", + noRuleConfigurations: "Keine Regelkonfigurationen", + unassignedTypologies: "Nicht zugewiesene Typologien", + searchTypologies: "Typologien suchen", + networkMapCreated: "Netzwerkkarte wurde erstellt", + successTitle: "Erfolg", + errorTitle: "Fehler", + errorMessage: "Etwas ist schief gelaufen", + pleaseAddTypology: "Bitte fügen Sie mindestens eine Typologie hinzu", + typologyListPermissionError: "Sie haben keine Berechtigung, die Typologie-Liste anzuzeigen", + typologyDetailsPermissionError: "Sie haben keine Berechtigung, Details für eine einzelne Typologie abzurufen", + accessDeniedTitle: "403", + accessDeniedMessage: "Sie haben keine Berechtigung, eine Netzwerkkarte zu erstellen", + backToHomePage: "Zurück zur Startseite", + save: 'Speichern', + cancel: 'Abbrechen', + lastUpdated: 'Zuletzt aktualisiert', + rules: 'Regeln', + configurations: 'Konfigurationen', + allRules: 'Alle Regeln', + rulesConfigurationsAttached: 'Angefügte Regelkonfigurationen', + expandAll: 'Alles Erweitern', + removeNode: 'Knoten Entfernen', + openExpand: 'Erweiterung Öffnen', + closeExpand: 'Erweiterung Schließen', + typology: 'Typologie', + version: 'Version', } }; diff --git a/packages/config-svc-fe/src/i18n/common/en.ts b/packages/config-svc-fe/src/i18n/common/en.ts index 68b9163..ee52655 100644 --- a/packages/config-svc-fe/src/i18n/common/en.ts +++ b/packages/config-svc-fe/src/i18n/common/en.ts @@ -401,7 +401,11 @@ export const enCommon = { searchPlaceholder: "Search", saving: "Saving.....", pleaseWait: "Please wait....", - typologyCreated: "Typology has been created" + typologyCreated: "Typology has been created", + typologyDetails: "Typology Details", + version: "Version", + ruleConfigs: "Rule Configs", + typologyUpdated: 'Typology has been updated successfully', }, typologyScorePage: { @@ -417,8 +421,71 @@ export const enCommon = { outcomesCount: "Outcomes", maxScore: "Max Score", search: 'Search', + "openScoringView": "Open in Scoring View", + "keepInDrafts": "Keep in Drafts" + }, + ruleReviewPage: { + version: 'Version', + description: 'Description', + created: 'Created', + updated: 'Updated', + state: 'State', + rule: 'Rule', + review: 'Review', + backToList: 'Back to List', + approve: 'Approve', + cancel: 'Cancel', + errorTitle: 'Error', + errorMessage: 'An error occurred', + retry: 'Retry' + }, + ruleConfigReviewPage: { + version: 'Version', + description: 'Description', + created: 'Created', + updated: 'Updated', + state: 'State', + rule: 'Rule', + review: 'Review', + backToList: 'Back to List', + approve: 'Approve', + cancel: 'Cancel', + errorTitle: 'Error', + errorMessage: 'An error occurred', + retry: 'Retry' +}, +createEditNetworkMap: { + searchRules: "Search rules", + noRulesAttached: "No rules attached", + searchConfigurations: "Search configurations", + noRuleConfigurations: "No rule configurations", + unassignedTypologies: "Unassigned Typologies", + searchTypologies: "Search Typologies", - } + networkMapCreated: "Network Map has been created", + successTitle: "Success", + errorTitle: "Error", + errorMessage: "Something went wrong", + pleaseAddTypology: "Please add at least one typology", + typologyListPermissionError: "You don't have permission to view typology list", + typologyDetailsPermissionError: "You don't have permission to get details for a single typology", + accessDeniedTitle: "403", + accessDeniedMessage: "You don't have permission to create a network map", + backToHomePage: "Back to Home", + save: 'Save', + cancel: 'Cancel', + lastUpdated: 'Last Updated', + rules: 'Rules', + configurations: 'Configurations', + allRules: 'All Rules', + rulesConfigurationsAttached: 'Rules Configurations Attached', + expandAll: 'Expand All', + removeNode: 'Remove Node', + openExpand: 'Open Expand', + closeExpand: 'Close Expand', + typology: 'Typology', + version: 'Version', +} }; diff --git a/packages/config-svc-fe/src/i18n/common/es.ts b/packages/config-svc-fe/src/i18n/common/es.ts index ccf1ada..5e8d289 100644 --- a/packages/config-svc-fe/src/i18n/common/es.ts +++ b/packages/config-svc-fe/src/i18n/common/es.ts @@ -400,7 +400,11 @@ export const esCommon = { searchPlaceholder: "Buscar", saving: "Guardando.....", pleaseWait: "Por favor espera....", - typologyCreated: "Tipología ha sido creada" + typologyCreated: "Tipología ha sido creada", + "typologyDetails": "Detalles Tipología", + "version": "Versión", + "ruleConfigs": "Configuraciones de Reglas", + typologyUpdated: 'La tipología se ha actualizado correctamente', }, @@ -417,8 +421,72 @@ export const esCommon = { rulesCount: "Reglas", outcomesCount: "Resultados", maxScore: "Puntuación Máxima", - search: 'Buscar' + search: 'Buscar', + "openScoringView": "Abrir en Vista de Puntuación", + "keepInDrafts": "Guardar en Borradores" + }, + ruleReviewPage: { + version: 'Versión', + description: 'Descripción', + created: 'Creado', + updated: 'Actualizado', + state: 'Estado', + rule: 'Regla', + review: 'Revisión', + backToList: 'Volver a la lista', + approve: 'Aprobar', + cancel: 'Cancelar', + errorTitle: 'Error', + errorMessage: 'Ocurrió un error', + retry: 'Reintentar' + }, + ruleConfigReviewPage: { + version: 'Versión', + description: 'Descripción', + created: 'Creado', + updated: 'Actualizado', + state: 'Estado', + rule: 'Regla', + review: 'Revisión', + backToList: 'Volver a la lista', + approve: 'Aprobar', + cancel: 'Cancelar', + errorTitle: 'Error', + errorMessage: 'Ocurrió un error', + retry: 'Reintentar' + }, + createEditNetworkMap: { + searchRules: "Buscar reglas", + noRulesAttached: "No hay reglas adjuntas", + searchConfigurations: "Buscar configuraciones", + noRuleConfigurations: "No hay configuraciones de reglas", + unassignedTypologies: "Tipologías no asignadas", + searchTypologies: "Buscar tipologías", + + networkMapCreated: "Mapa de red ha sido creado", + successTitle: "Éxito", + errorTitle: "Error", + errorMessage: "Algo salió mal", + pleaseAddTypology: "Por favor, agregue al menos una tipología", + typologyListPermissionError: "No tienes permiso para ver la lista de tipologías", + typologyDetailsPermissionError: "No tienes permiso para obtener detalles de una tipología", + accessDeniedTitle: "403", + accessDeniedMessage: "No tienes permiso para crear un mapa de red", + backToHomePage: "Volver a la página de inicio", + save: 'Guardar', + cancel: 'Cancelar', + lastUpdated: 'Última Actualización', + rules: 'Reglas', + configurations: 'Configuraciones', + allRules: 'Todas las Reglas', + rulesConfigurationsAttached: 'Configuraciones de Reglas Adheridas', + expandAll: 'Expandir Todo', + removeNode: 'Eliminar Nodo', + openExpand: 'Abrir Expansión', + closeExpand: 'Cerrar Expansión', + typology: 'Tipología', + version: 'Versión', } } \ No newline at end of file diff --git a/packages/config-svc-fe/src/i18n/common/fr.ts b/packages/config-svc-fe/src/i18n/common/fr.ts index 5484230..0cc4ac5 100644 --- a/packages/config-svc-fe/src/i18n/common/fr.ts +++ b/packages/config-svc-fe/src/i18n/common/fr.ts @@ -27,7 +27,7 @@ export const frCommon = { state: "State", actions: "Actions", modify: "Modify", - submit: "Submit", + submit: 'Soumettre', review: "Review", minimum: "Minimum", maximum: "Maximum", @@ -394,7 +394,12 @@ export const frCommon = { searchPlaceholder: "Rechercher", saving: "Enregistrement.....", pleaseWait: "Veuillez patienter....", - typologyCreated: "La typologie a été créée" + typologyCreated: "La typologie a été créée", + "typologyDetails": "Détails de la Typologie", + "version": "Version", + "ruleConfigs": "Configurations de Règles", + typologyUpdated: 'La typologie a été mise à jour avec succès', + }, typologyScorePage: { @@ -410,6 +415,72 @@ export const frCommon = { outcomesCount: "Résultats", maxScore: "Score Maximum", search: 'Rechercher', + "openScoringView": "Ouvrir en vue de notation", + "keepInDrafts": "Garder en brouillons" + }, + ruleConfigReviewPage: { + version: 'Version', + description: 'Description', + created: 'Créé', + updated: 'Mis à jour', + state: 'État', + rule: 'Règle', + review: 'Revoir', + backToList: 'Retour à la liste', + approve: 'Approuver', + cancel: 'Annuler', + errorTitle: 'Erreur', + errorMessage: 'Une erreur est survenue', + retry: 'Réessayer', + "openScoringView": "Ouvrir en vue de notation", + "keepInDrafts": "Garder en brouillons" + }, + ruleReviewPage: { + version: 'Version', + description: 'Description', + created: 'Créé', + updated: 'Mis à jour', + state: 'État', + rule: 'Règle', + review: 'Revoir', + backToList: 'Retour à la liste', + approve: 'Approuver', + cancel: 'Annuler', + errorTitle: 'Erreur', + errorMessage: 'Une erreur est survenue', + retry: 'Réessayer' + }, + createEditNetworkMap: { + searchRules: "Rechercher des règles", + noRulesAttached: "Aucune règle attachée", + searchConfigurations: "Rechercher des configurations", + noRuleConfigurations: "Aucune configuration de règle", + unassignedTypologies: "Typologies non assignées", + searchTypologies: "Rechercher des typologies", + + networkMapCreated: "Le plan de réseau a été créé", + successTitle: "Succès", + errorTitle: "Erreur", + errorMessage: "Quelque chose s'est mal passé", + pleaseAddTypology: "Veuillez ajouter au moins une typologie", + typologyListPermissionError: "Vous n'avez pas la permission de voir la liste des typologies", + typologyDetailsPermissionError: "Vous n'avez pas la permission d'obtenir les détails d'une seule typologie", + accessDeniedTitle: "403", + accessDeniedMessage: "Vous n'avez pas la permission de créer un plan de réseau", + backToHomePage: "Retour à la page d'accueil", + save: 'Enregistrer', + cancel: 'Annuler', + lastUpdated: 'Dernière mise à jour', + rules: 'Règles', + configurations: 'Configurations', + allRules: 'Toutes les Règles', + rulesConfigurationsAttached: 'Configurations de Règles Attachées', + expandAll: 'Tout Développer', + removeNode: 'Supprimer le Nœud', + openExpand: 'Ouvrir l\'Extension', + closeExpand: 'Fermer l\'Extension', + typology: 'Typologie', + version: 'Version', -} + } }; diff --git a/packages/config-svc-fe/src/utils/__tests__/index.spec.js b/packages/config-svc-fe/src/utils/__tests__/index.spec.js index 95e18a4..25b77bc 100644 --- a/packages/config-svc-fe/src/utils/__tests__/index.spec.js +++ b/packages/config-svc-fe/src/utils/__tests__/index.spec.js @@ -1,24 +1,64 @@ -import {changeToEpoch, convertMillisecondsToDHMS} from '../'; +import { changeToEpoch, convertMillisecondsToDHMS, incrementVersion } from '../'; describe('convertMillisecondsToDHMS', () => { - it('should correctly convert milliseconds to DHMS format', () => { - const milliseconds = 1000000; - const expectedResult = { - days: 0, - hours: 0, - minutes: 16, - seconds: 40 - }; - expect(convertMillisecondsToDHMS(milliseconds)).toEqual(expectedResult); - }); + it('should correctly convert milliseconds to DHMS format', () => { + const milliseconds = 2592000000; + const expectedResult = { + days: 30, hours: 0, minutes: 0, seconds: 0 + }; + const result = convertMillisecondsToDHMS(milliseconds); + expect(result).toEqual(expectedResult); }); +}); + +describe('changeToEpoch', () => { + it('should correctly convert DHMS format to epoch time in seconds', () => { + const days = 30; + const hours = 0; + const minutes = 0; + const seconds = 0; + const expectedResult = 2592000000; + expect(changeToEpoch(days, hours, minutes, seconds)).toEqual(expectedResult); + }); +}); + + +describe('incrementVersion', () => { - describe('changeToEpoch', () => { - it('should correctly convert DHMS format to epoch time in seconds', () => { - const days = 1; - const hours = 12; - const minutes = 30; - const seconds = 45; - const expectedResult = 131445; - expect(changeToEpoch(days, hours, minutes, seconds)).toEqual(expectedResult); - }); - }); \ No newline at end of file + it('should handle increment major', () => { + expect(incrementVersion("1.0.0","major",['1.0.1', '2.0.1', '1.4.2'])).toBe("3.0.0"); + expect(incrementVersion("3.0.0","major",['1.0.1', '4.0.1', '2.4.2'])).toBe("5.0.0"); + expect(incrementVersion("1.0.0","major",['1.0.1', '2.0.1', '1.4.2'])).toBe("3.0.0"); + expect(incrementVersion("1.0.0","major",[ + "1.0.0", + "1.0.0", + "1.0.0", + "1.0.0", + "1.0.0", + "2.1.0", + "2.0.0", + "1.0.0", + "1.0.0" + ])).toBe("3.0.0"); + + }); + + it('should handle increment minor', () => { + expect(incrementVersion("1.0.0","minor",['1.0.1', '2.0.1', '1.4.2'])).toBe("1.5.0"); + expect(incrementVersion("1.8.0","minor",['1.0.1', '1.9.0', '1.4.2'])).toBe("1.10.0"); + + + expect(incrementVersion("1.0.0","minor",[ '1.0.0', + '1.1.0', + '2.0.0', + '2.1.0', + '2.1.1', + '3.3.2'])).toBe("1.2.0"); + }); + + it('should handle increment patch', () => { + expect(incrementVersion("1.0.0","patch",['1.0.1', '2.0.1', '1.4.2'])).toBe("1.0.2"); + expect(incrementVersion("1.8.0","patch",['1.0.1', '1.9.0', '1.4.2'])).toBe("1.8.1"); + }); + + +}) \ No newline at end of file diff --git a/packages/config-svc-fe/src/utils/index.ts b/packages/config-svc-fe/src/utils/index.ts index 53ddd80..74419c2 100644 --- a/packages/config-svc-fe/src/utils/index.ts +++ b/packages/config-svc-fe/src/utils/index.ts @@ -1,39 +1,110 @@ export const convertMillisecondsToDHMS = (milliseconds: number) => { - // Calculate days, hours, minutes, and seconds - const seconds = milliseconds / 1000; - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - // Calculate remaining hours, minutes, and seconds after subtracting days - const remainingHours = hours % 24; - const remainingMinutes = minutes % 60; - const remainingSeconds = seconds % 60; - - return { - days, - hours: remainingHours, - minutes: remainingMinutes, - seconds: remainingSeconds - }; + // Calculate total seconds + const totalSeconds = Math.floor(milliseconds / 1000); + + // Calculate days, hours, minutes, and seconds + const days = Math.floor(totalSeconds / (24 * 60 * 60)); + const hours = Math.floor((totalSeconds % (24 * 60 * 60)) / (60 * 60)); + const minutes = Math.floor((totalSeconds % (60 * 60)) / 60); + const seconds = totalSeconds % 60; + + return { + days, + hours, + minutes, + seconds + }; }; export function changeToEpoch(days: number, hours: number, minutes: number, seconds: number) { - // Calculate the total number of seconds - var totalSeconds = (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds; - - // Return the epoch time in seconds - return totalSeconds; + // Calculate the total number of seconds + var totalSeconds = ((days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000; + + // Return the epoch time in seconds + return totalSeconds; } export const sortAlphabetically = (array: any[], key: string) => { - return array.sort((a, b) => { - if (a[key] < b[key]) { - return -1; - } - if (a[key] > b[key]) { - return 1; - } - return 0; - }); - }; \ No newline at end of file + return array.sort((a, b) => { + if (a[key] < b[key]) { + return -1; + } + if (a[key] > b[key]) { + return 1; + } + return 0; + }); +}; + +interface Version { + major: number; + minor: number; + patch: number; +} + +/** + * Increment the version based on the change type. + * @param version - The current version string. + * @param changeType - The type of change ('major', 'minor', 'patch'). + * @param existingVersions - List of existing versions to avoid collisions. + * @returns The new version string. + */ +export const incrementVersion = (selectedVersion: string, changeType: string, versions: string[]): string => { + // Parse version string into an object with major, minor, and patch numbers + const parseVersion = (version: string): Version => { + const [major, minor, patch] = version.split('.').map(Number); + return { major, minor, patch }; + }; + + // Format the version object back into a version string + const formatVersion = ({ major, minor, patch }: Version): string => { + return `${major}.${minor}.${patch}`; + }; + + const parsedVersions = versions.map(parseVersion); + const selectedParsedVersion = parseVersion(selectedVersion); + + // Get the highest version for a specific major or minor version + const getHighestForType = (major: number, minor?: number): Version => { + return parsedVersions.reduce((highest, current) => { + if (changeType === 'major' && current.major > highest.major) { + return current; + } + if (changeType === 'minor' && current.major === major && current.minor > highest.minor) { + return current; + } + if (changeType === 'patch' && current.major === major && current.minor === minor && current.patch > highest.patch) { + return current; + } + return highest; + }, parseVersion('0.0.0')); + }; + + // Increment the version based on the change type and ensure it does not exist + let newVersion: Version = { major: 0, minor: 0, patch: 0 } + + switch (changeType) { + case 'major': + let highestMajorVersion = getHighestForType(0).major; + do { + newVersion = { major: ++highestMajorVersion, minor: 0, patch: 0 }; + } while (versions.includes(formatVersion(newVersion))); + break; + case 'minor': + let highestMinorVersion = getHighestForType(selectedParsedVersion.major).minor; + do { + newVersion = { major: selectedParsedVersion.major, minor: ++highestMinorVersion, patch: 0 }; + } while (versions.includes(formatVersion(newVersion))); + break; + case 'patch': + let highestPatchVersion = getHighestForType(selectedParsedVersion.major, selectedParsedVersion.minor).patch; + do { + newVersion = { major: selectedParsedVersion.major, minor: selectedParsedVersion.minor, patch: ++highestPatchVersion }; + } while (versions.includes(formatVersion(newVersion))); + break; + default: + break; + } + + return formatVersion(newVersion); +} \ No newline at end of file