Skip to content

Commit

Permalink
chore(spa): create core ui library (#1151)
Browse files Browse the repository at this point in the history
  • Loading branch information
timonmasberg authored Jan 1, 2025
1 parent 2607d76 commit fc13451
Show file tree
Hide file tree
Showing 45 changed files with 522 additions and 430 deletions.
39 changes: 39 additions & 0 deletions libs/spa/core/ui/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"extends": [
"../../../../.eslintrc.json",
"../../../../.eslintrc.angular.json"
],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts"],
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "krd",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "krd",
"style": "kebab-case"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@nx/angular-template"],
"rules": {}
}
]
}
4 changes: 4 additions & 0 deletions libs/spa/core/ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Common UI

This library contains common UI components and some helper services that are
used across multiple domains.
21 changes: 21 additions & 0 deletions libs/spa/core/ui/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export default {
displayName: 'spa-ui',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/spa/core/ui',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
20 changes: 20 additions & 0 deletions libs/spa/core/ui/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "spa-ui",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "spa/core/ui/src",
"prefix": "krd",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/spa/core/ui/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
3 changes: 3 additions & 0 deletions libs/spa/core/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './lib/entity-search/index';
export * from './lib/status-badge.component';
export * from './lib/status-explanations';
7 changes: 7 additions & 0 deletions libs/spa/core/ui/src/lib/entity-search/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Entity Search

This library contains components and services to search entities such as units
and alert-groups. Mainly, there are two types of components, selection
components where a selection service manages a state of entities and each entity
can only be selected once and autocomplete components, which is a simple search
component.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';

import { Unit } from '@kordis/shared/model';

import { StatusBadgeComponent } from '../../../../deployment/unit/status-badge.component';
import { StatusBadgeComponent } from '../../../status-badge.component';

@Component({
selector: 'krd-unit-selection-option',
Expand Down
9 changes: 9 additions & 0 deletions libs/spa/core/ui/src/lib/entity-search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export * from './service/match-strategies';
export * from './service/entity-search.service';
export * from './service/unit-selection.service';
export * from './service/alert-group-selection.service';
export * from './component/unit/unit-selection-option.component';
export * from './component/unit/units-select.component';
export * from './component/alert-group/alert-group-selection.component';
export * from './component/alert-group/alert-group-autocomplete.component';
export * from './component/alert-group/alert-group-selections.component';
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Injectable } from '@angular/core';
import { TypedDocumentNode } from 'apollo-angular';

import { AlertGroup, Query } from '@kordis/shared/model';
import { gql } from '@kordis/spa/core/graphql';

import { EntitySearchEngine } from './entity-search.service';
import { EntitySelectionSearchService } from './entity-selection-search.service';
import { alertGroupMatchesByName } from './match-strategies';

/*
This service handles the selection of alert groups in a context where a unit can only be selected once.
*/
@Injectable()
export class PossibleAlertGroupSelectionsService extends EntitySelectionSearchService<
AlertGroup,
{ alertGroups: Query['alertGroups'] }
> {
protected override query: TypedDocumentNode<{
alertGroups: Query['alertGroups'];
}> = gql`
query {
alertGroups {
id
name
currentUnits {
id
name
callSign
status {
status
}
assignment {
__typename
id
name
}
}
assignment {
__typename
id
name
}
}
}
`;
protected queryName = 'alertGroups' as const;
protected searchService = new EntitySearchEngine(alertGroupMatchesByName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { EntitySearchEngine } from './entity-search.service';

describe('EntitySearchEngine', () => {
let searchEngine: EntitySearchEngine<{ id: string; name: string }>;
const mockMatchStrategy = jest.fn((entity, searchTerm) =>
entity.name.includes(searchTerm),
);

beforeEach(() => {
searchEngine = new EntitySearchEngine(mockMatchStrategy);
});

it('should set searchable entities', () => {
const entities = [
{ id: '1', name: 'Entity One' },
{ id: '2', name: 'Entity Two' },
];
searchEngine.setSearchableEntities(entities);
expect(searchEngine['searchableEntities']).toEqual(entities);
});

it('should search entities based on the match strategy', () => {
const entities = [
{ id: '1', name: 'Entity One' },
{ id: '2', name: 'Entity Two' },
{ id: '3', name: 'Another' },
];
searchEngine.setSearchableEntities(entities);

const result = searchEngine.search('Entity');
expect(result).toEqual([
{ id: '1', name: 'Entity One' },
{ id: '2', name: 'Entity Two' },
]);
});

it('should return an empty array if no entities match the search term', () => {
const entities = [
{ id: '1', name: 'Entity One' },
{ id: '2', name: 'Entity Two' },
];
searchEngine.setSearchableEntities(entities);

const result = searchEngine.search('Nonexistent');
expect(result).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface IEntitySearchEngine<Entity> {
search(searchTerm: string): Entity[];
setSearchableEntities(entities: Entity[]): void;
}

/*
* A generic search engine for entities which is able to search through a list of entities that can change over time.
*/
export class EntitySearchEngine<Entity = unknown>
implements IEntitySearchEngine<Entity>
{
private searchableEntities: Entity[] = [];

constructor(
private readonly entityMatchStrategy: (
entity: Entity,
searchTerm: string,
) => boolean,
) {}

setSearchableEntities(entities: Entity[]): void {
this.searchableEntities = entities;
}

search(searchTerm: string): Entity[] {
return this.searchableEntities.filter((entity) =>
this.entityMatchStrategy(entity, searchTerm),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { of } from 'rxjs';

import { GraphqlService } from '@kordis/spa/core/graphql';

import { EntitySearchService } from '../../../../services/entity-search.service';
import { IEntitySearchEngine } from './entity-search.service';
import { EntitySelectionSearchService } from './entity-selection-search.service';

class TestEntitySelectionService extends EntitySelectionSearchService<
Expand All @@ -16,7 +16,7 @@ class TestEntitySelectionService extends EntitySelectionSearchService<
protected query = {} as any; // Mock GraphQL query
protected queryName = 'testEntities' as const;
protected searchService =
createMock<EntitySearchService<{ id: string; name: string }>>(); // Mock search service
createMock<IEntitySearchEngine<{ id: string; name: string }>>(); // Mock search service
}

describe('EntitySelectionSearchService', () => {
Expand Down Expand Up @@ -69,7 +69,7 @@ describe('EntitySelectionSearchService', () => {

it('should filter search', async () => {
service.markAsSelected({ id: '1', name: 'Entity 1' });
(service as any).searchService.searchByTerm.mockResolvedValue([
(service as any).searchService.search.mockReturnValue([
{ id: '1', name: 'Entity 1' },
{ id: '2', name: 'Entity 2' },
]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { inject } from '@angular/core';
import { TypedDocumentNode } from 'apollo-angular';
import { Subject, map, shareReplay, startWith, switchMap } from 'rxjs';
import { Subject, map, shareReplay, startWith, switchMap, tap } from 'rxjs';

import { GraphqlService } from '@kordis/spa/core/graphql';

import { EntitySearchService } from '../../../../services/entity-search.service';
import { IEntitySearchEngine } from './entity-search.service';

/*
* This service handles the selection of entities in a context where an entity can only be selected once.
Expand All @@ -16,11 +16,12 @@ export abstract class EntitySelectionSearchService<
> {
protected abstract query: TypedDocumentNode<TQuery>;
protected abstract queryName: keyof TQuery;
protected abstract searchService: EntitySearchService<TEntity>;
protected abstract searchService: IEntitySearchEngine<TEntity>;

private readonly entityIdsSelected = new Set<string>();
private readonly gqlService = inject(GraphqlService);
private readonly selectionChangedSubject$ = new Subject<void>();

readonly allPossibleEntitiesToSelect$ = this.selectionChangedSubject$.pipe(
startWith(null), // trigger initial query
switchMap(() =>
Expand All @@ -34,6 +35,7 @@ export abstract class EntitySelectionSearchService<
),
),
),
tap((entities) => this.searchService.setSearchableEntities(entities)),
shareReplay({ bufferSize: 1, refCount: true }),
);

Expand All @@ -54,7 +56,7 @@ export abstract class EntitySelectionSearchService<
}

async searchAllPossibilities(query: string): Promise<TEntity[]> {
const entities = await this.searchService.searchByTerm(query);
const entities = this.searchService.search(query);
return entities.filter(({ id }) => !this.entityIdsSelected.has(id));
}
}
Loading

0 comments on commit fc13451

Please sign in to comment.