diff --git a/backend/alembic/versions/62f72c3ab3a5_favorite_and_recent_cards.py b/backend/alembic/versions/62f72c3ab3a5_favorite_and_recent_cards.py new file mode 100644 index 0000000..2d8264f --- /dev/null +++ b/backend/alembic/versions/62f72c3ab3a5_favorite_and_recent_cards.py @@ -0,0 +1,39 @@ +"""favorite and recent cards + +Revision ID: 62f72c3ab3a5 +Revises: 21459fe9053f +Create Date: 2025-07-04 00:20:18.908767 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "62f72c3ab3a5" +down_revision: Union[str, None] = "21459fe9053f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + with op.batch_alter_table("cards", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "is_favorite", + sa.Boolean(), + server_default=sa.text("(FALSE)"), + nullable=False, + ), + ) + batch_op.add_column(sa.Column("used_at", sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("cards", schema=None) as batch_op: + batch_op.drop_column("is_favorite") + batch_op.drop_column("used_at") diff --git a/backend/app/api/card.py b/backend/app/api/card.py index 5fb6fdf..479bbe7 100644 --- a/backend/app/api/card.py +++ b/backend/app/api/card.py @@ -50,7 +50,7 @@ async def create_card( @router.put("/cards/{card_id}", response_model=schemas.Card) async def update_card( card_id: int, - updated: schemas.CardCreate, + updated: schemas.CardUpdate, session: AsyncSession = Depends(db.get_async_session), user: models.User = Depends(auth.is_user), ): @@ -64,7 +64,7 @@ async def update_card( if not card: raise HTTPException(status_code=404, detail="Card not found") - for key, value in updated.model_dump().items(): + for key, value in updated.model_dump(exclude_unset=True).items(): if getattr(card, key) != value: setattr(card, key, value) await session.commit() @@ -90,3 +90,27 @@ async def delete_card( await session.delete(card) await session.commit() return {"detail": "Card deleted"} + + +@router.patch("/cards/{card_id}") +async def patch_card( + card_id: int, + body: schemas.CardPatch, + session: AsyncSession = Depends(db.get_async_session), + user: models.User = Depends(auth.is_user), +): + stmt = ( + select(models.Card) + .where(models.Card.id == card_id, models.Card.user_id == user.id) + .limit(1) + ) + result = await session.execute(stmt) + card = result.scalar_one_or_none() + if not card: + raise HTTPException(status_code=404, detail="Card not found") + for key, value in body.model_dump(exclude_unset=True).items(): + if getattr(card, key) != value: + setattr(card, key, value) + await session.commit() + await session.refresh(card) + return card diff --git a/backend/app/db/models/card.py b/backend/app/db/models/card.py index 068ee9c..648776c 100644 --- a/backend/app/db/models/card.py +++ b/backend/app/db/models/card.py @@ -1,6 +1,6 @@ from app.helpers import now from .base import Base -from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, text +from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, text, Boolean from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.sql import func from datetime import datetime @@ -20,6 +20,10 @@ class Card(Base): name: Mapped[str] = mapped_column(String, nullable=False) description: Mapped[Optional[Text]] = mapped_column(Text, nullable=True) color: Mapped[Optional[str]] = mapped_column(String, nullable=True) + is_favorite: Mapped[bool] = mapped_column( + Boolean, server_default=text("FALSE"), default=False, nullable=False + ) + used_at: Mapped[bool] = mapped_column(DateTime, nullable=True) user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id"), nullable=False ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 33d8c49..bedb670 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,6 +1,6 @@ from .user import User, UserCreate, UserUpdate from .auth import TokenResponse, RefreshRequest, RevokeRequest -from .card import CardBase, CardCreate, CardUpdate, Card +from .card import CardBase, CardCreate, CardUpdate, Card, CardPatch from .password_recovery import PasswordRecoveryCodeBody, PasswordRecoverySubmitBody from .version import Version from .settings import ( diff --git a/backend/app/schemas/card.py b/backend/app/schemas/card.py index c5629a5..b0442b8 100644 --- a/backend/app/schemas/card.py +++ b/backend/app/schemas/card.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, field_validator, ConfigDict from typing import Optional +from datetime import datetime, timezone import re @@ -9,6 +10,7 @@ class CardBase(BaseModel): name: str description: Optional[str] = None color: Optional[str] = None + is_favorite: Optional[bool] = None @field_validator("color") @classmethod @@ -28,7 +30,37 @@ class CardUpdate(CardBase): pass +class CardPatch(BaseModel): + code: Optional[str] = None + code_type: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + is_favorite: Optional[bool] = None + used_at: Optional[datetime] = None + + @field_validator("used_at") + @classmethod + def to_utc(cls, v: datetime) -> datetime: + if not v: + return v + if v.tzinfo: + return v.astimezone(timezone.utc) + return v.replace(tzinfo=None) + + @field_validator("color") + @classmethod + def validate_color(cls, v: str) -> str | None: + if not v: + return None + if not re.search(r"^#(?:[0-9a-fA-F]{3}){1,2}$", v): + raise ValueError("The card color must match hex color string e.g. #ff00ff") + return v + + class Card(CardBase): id: int - + used_at: Optional[datetime] + created_at: datetime + updated_at: datetime model_config = ConfigDict(from_attributes=True) diff --git a/frontend/public/i18n/en.json b/frontend/public/i18n/en.json index acce782..d12d928 100644 --- a/frontend/public/i18n/en.json +++ b/frontend/public/i18n/en.json @@ -64,6 +64,10 @@ "NAME": "Name", "DESCRIPTION": "Description", "COLOR": "Color", + "IS_FAVORITE": "Favorite", + "USED_AT": "Used at", + "CREATED_AT": "Created at", + "UPDATED_AT": "Updated at", "CODE_FORMAT_WARN": "The app now uses different names for code types. Save the card with the new code type.", "SCAN": { "PERMISSION_ERROR": "Access to video device is denied or feature is not supported.", @@ -156,5 +160,33 @@ "IMAGE_VERSION": "Docker image version", "EXAMPLES": "Supported codes examples", "CHANGELOG": "Changelog" + }, + "SORT_FILTER": { + "TITLE": "Sorting and Filtering", + "SORT_BY": "Sort by", + "SORT_DIR": "Direction", + "SORT_DIRS": { + "ASC": "Ascending", + "DESC": "Descending" + }, + "FILTER_BY": "Filter by", + "CRITERIA": "Criteria", + "VALUE": "Value", + "ADD_FILTER": "Add filter", + "BOOL_LABEL": { + "TRUE": "Yes", + "FALSE": "No" + }, + "CRITERIAS": { + "LIKE": "Contains", + "EQUALS": "Equals", + "NOT_EQUALS": "Not equals", + "NULL": "Is empty", + "NOT_NULL": "Is not empty", + "GREATER": "Greater than", + "LESS": "Less than", + "GREATER_OR_EQUALS": "Greater or equal", + "LESS_OR_EQUALS": "Less or equal" + } } } \ No newline at end of file diff --git a/frontend/public/i18n/fr.json b/frontend/public/i18n/fr.json index cf0dc26..a01e779 100644 --- a/frontend/public/i18n/fr.json +++ b/frontend/public/i18n/fr.json @@ -64,6 +64,10 @@ "NAME": "Nom", "DESCRIPTION": "Description", "COLOR": "Couleur", + "IS_FAVORITE": "Favori", + "USED_AT": "Date d'utilisation", + "CREATED_AT": "Date de création", + "UPDATED_AT": "Date de modification", "CODE_FORMAT_WARN": "L'application utilise désormais des noms différents pour les types de codes. Enregistrez la carte avec le nouveau type de code.", "SCAN": { "PERMISSION_ERROR": "L'accès au périphérique vidéo est refusé ou la fonctionnalité n'est pas prise en charge.", @@ -156,5 +160,33 @@ "IMAGE_VERSION": "Version de l'image Docker", "EXAMPLES": "Exemples de codes pris en charge", "CHANGELOG": "journal des modifications" + }, + "SORT_FILTER": { + "TITLE": "Tri et filtrage", + "SORT_BY": "Trier par", + "SORT_DIR": "Direction", + "SORT_DIRS": { + "ASC": "Croissant", + "DESC": "Décroissant" + }, + "FILTER_BY": "Filtrer par", + "CRITERIA": "Critère", + "VALUE": "Valeur", + "ADD_FILTER": "Ajouter un filtre", + "BOOL_LABEL": { + "TRUE": "Oui", + "FALSE": "Non" + }, + "CRITERIAS": { + "LIKE": "Contient", + "EQUALS": "Égal à", + "NOT_EQUALS": "Différent de", + "NULL": "Est vide", + "NOT_NULL": "N'est pas vide", + "GREATER": "Supérieur à", + "LESS": "Inférieur à", + "GREATER_OR_EQUALS": "Supérieur ou égal à", + "LESS_OR_EQUALS": "Inférieur ou égal à" + } } } \ No newline at end of file diff --git a/frontend/public/i18n/ru.json b/frontend/public/i18n/ru.json index b30e5bd..14c2435 100644 --- a/frontend/public/i18n/ru.json +++ b/frontend/public/i18n/ru.json @@ -64,6 +64,10 @@ "NAME": "Наименование", "DESCRIPTION": "Описание", "COLOR": "Цвет", + "IS_FAVORITE": "Избранное", + "USED_AT": "Дата использования", + "CREATED_AT": "Дата создания", + "UPDATED_AT": "Дата изменения", "CODE_FORMAT_WARN": "Теперь приложение использует другие наименования для типов кодов. Сохраните карточку с новым типом кода.", "SCAN": { "PERMISSION_ERROR": "Доступ к видеоустройству запрещен или функция не поддерживается.", @@ -156,5 +160,33 @@ "IMAGE_VERSION": "Версия Docker-образа", "EXAMPLES": "Примеры поддерживаемых кодов", "CHANGELOG": "Журнал изменений" + }, + "SORT_FILTER": { + "TITLE": "Сортировка и фильтрация", + "SORT_BY": "Сортировать по", + "SORT_DIR": "Направление", + "SORT_DIRS": { + "ASC": "По возрастанию", + "DESC": "По убыванию" + }, + "FILTER_BY": "Фильтровать по", + "CRITERIA": "Критерий", + "VALUE": "Значение", + "ADD_FILTER": "Добавить фильтр", + "BOOL_LABEL": { + "TRUE": "Да", + "FALSE": "Нет" + }, + "CRITERIAS": { + "LIKE": "Включает", + "EQUALS": "Равно", + "NOT_EQUALS": "Не равно", + "NULL": "Пустое значение", + "NOT_NULL": "Не пустое значение", + "GREATER": "Больше", + "LESS": "Меньше", + "GREATER_OR_EQUALS": "Больше или равно", + "LESS_OR_EQUALS": "Меньше или равно" + } } } \ No newline at end of file diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index bc8ed4e..630e2f9 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -35,6 +35,7 @@ import { UserEffects } from './entities/user/state/user.effects'; import { userInitializer } from './core/app-initializers/user-initializer'; import { localeInitializer } from './core/app-initializers/locale-initializer'; import { appInitializer } from './core/app-initializers/app-initializer'; +import { provideNativeDateAdapter } from '@angular/material/core'; export const appConfig: ApplicationConfig = { providers: [ @@ -47,6 +48,7 @@ export const appConfig: ApplicationConfig = { provideEffects([AppEffects, AuthEffects, UserEffects]), provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }), provideHttpClient(withInterceptors([getTokenInterceptor])), + provideNativeDateAdapter(), importProvidersFrom( TranslateModule.forRoot({ loader: { diff --git a/frontend/src/app/app.consts.ts b/frontend/src/app/app.consts.ts index 9fce0b2..f6ad00f 100644 --- a/frontend/src/app/app.consts.ts +++ b/frontend/src/app/app.consts.ts @@ -14,6 +14,14 @@ export enum ELocalStorageKey { * Flag indicationg that app loaded after an update. */ AFTER_UPDATE = 'cardholder-after-update', + /** + * Last sorting model + */ + CARD_SORTING = 'cardholder-card-sorting', + /** + * Last filters model + */ + CARD_FILTERS = 'cardholder-card-filters', } /** * Enum of useful regexp diff --git a/frontend/src/app/entities/cards/cards-api.service.ts b/frontend/src/app/entities/cards/cards-api.service.ts index 9e4e1bd..9068766 100644 --- a/frontend/src/app/entities/cards/cards-api.service.ts +++ b/frontend/src/app/entities/cards/cards-api.service.ts @@ -31,4 +31,8 @@ export class CardApiService extends BaseApiService<'cards'> { list(): Observable { return this.httpClient.get(`${this.basePath}`); } + + patch(id: number, body: Partial): Observable { + return this.httpClient.patch(`${this.basePath}/${id}`, body); + } } diff --git a/frontend/src/app/entities/cards/cards-interface.ts b/frontend/src/app/entities/cards/cards-interface.ts index 6890b22..ce8152b 100644 --- a/frontend/src/app/entities/cards/cards-interface.ts +++ b/frontend/src/app/entities/cards/cards-interface.ts @@ -1,10 +1,28 @@ +import { TypeToString } from 'src/app/shared/types/type-to-string'; + export interface ICardBase { code: string; code_type: string; name: string; description: string; color: string; + is_favorite?: boolean; + used_at?: string; } export interface ICard extends ICardBase { id: number; + created_at: string; + updated_at: string; } +export const ECardFieldType: { [K in keyof ICard]: TypeToString } = { + id: 'number', + code: 'string', + code_type: 'string', + name: 'string', + description: 'string', + color: 'string', + is_favorite: 'boolean', + used_at: 'date', + created_at: 'date', + updated_at: 'date', +}; diff --git a/frontend/src/app/entities/cards/state/cards.actions.ts b/frontend/src/app/entities/cards/state/cards.actions.ts index 6be930f..ef6443a 100644 --- a/frontend/src/app/entities/cards/state/cards.actions.ts +++ b/frontend/src/app/entities/cards/state/cards.actions.ts @@ -28,5 +28,8 @@ export const CardsActions = createActionGroup({ 'set form': props<{ form: ICardBase }>(), 'save card': emptyProps(), 'exit card': emptyProps(), + patchListItem: props<{ id: number; body: Partial }>(), + patchListItemSuccess: props<{ card: ICard }>(), + patchListItemError: props<{ error: HttpErrorResponse }>(), }, }); diff --git a/frontend/src/app/entities/cards/state/cards.effects.ts b/frontend/src/app/entities/cards/state/cards.effects.ts index 9508035..3bca50a 100644 --- a/frontend/src/app/entities/cards/state/cards.effects.ts +++ b/frontend/src/app/entities/cards/state/cards.effects.ts @@ -38,10 +38,10 @@ export class CardsEffects { switchMap((action) => this.cardsApiService.list().pipe( map((list) => CardsActions.listSuccess({ list })), - catchError((error) => of(CardsActions.listError({ error }))) - ) - ) - ) + catchError((error) => of(CardsActions.listError({ error }))), + ), + ), + ), ); read$ = createEffect(() => @@ -50,10 +50,10 @@ export class CardsEffects { switchMap((action) => this.cardsApiService.read(action.id).pipe( map((info) => CardsActions.readSuccess({ info })), - catchError((error) => of(CardsActions.readError({ error }))) - ) - ) - ) + catchError((error) => of(CardsActions.readError({ error }))), + ), + ), + ), ); saveCard$ = createEffect(() => @@ -64,10 +64,10 @@ export class CardsEffects { of( !!active.info ? CardsActions.update({ id: active.info.id, body: active.form }) - : CardsActions.create({ body: active.form }) - ) - ) - ) + : CardsActions.create({ body: active.form }), + ), + ), + ), ); create$ = createEffect(() => @@ -76,10 +76,10 @@ export class CardsEffects { switchMap((action) => this.cardsApiService.create(action.body).pipe( map((info) => CardsActions.createSuccess({ info })), - catchError((error) => of(CardsActions.createError({ error }))) - ) - ) - ) + catchError((error) => of(CardsActions.createError({ error }))), + ), + ), + ), ); createSuccess$ = createEffect( @@ -90,9 +90,9 @@ export class CardsEffects { this.router.navigate([`/cards/${action.info.id}`], { replaceUrl: true, }); - }) + }), ), - { dispatch: false } + { dispatch: false }, ); update$ = createEffect(() => @@ -101,10 +101,10 @@ export class CardsEffects { switchMap((action) => this.cardsApiService.update(action.id, action.body).pipe( map((info) => CardsActions.updateSuccess({ info })), - catchError((error) => of(CardsActions.updateError({ error }))) - ) - ) - ) + catchError((error) => of(CardsActions.updateError({ error }))), + ), + ), + ), ); showSaveSnack$ = createEffect( @@ -113,11 +113,11 @@ export class CardsEffects { ofType(CardsActions.createSuccess, CardsActions.updateSuccess), tap(() => { this.snackService.success( - this.translateService.instant('CARDS.CARD.SUCCESS.SAVE') + this.translateService.instant('CARDS.CARD.SUCCESS.SAVE'), ); - }) + }), ), - { dispatch: false } + { dispatch: false }, ); deleteAttempt$ = createEffect(() => @@ -132,11 +132,11 @@ export class CardsEffects { .afterClosed() .pipe( switchMap((res) => - res ? of(CardsActions.delete({ id: info.id })) : EMPTY - ) - ) - ) - ) + res ? of(CardsActions.delete({ id: info.id })) : EMPTY, + ), + ), + ), + ), ); delete$ = createEffect(() => @@ -145,10 +145,10 @@ export class CardsEffects { switchMap((action) => this.cardsApiService.delete(action.id).pipe( map(() => CardsActions.deleteSuccess()), - catchError((error) => of(CardsActions.deleteError({ error }))) - ) - ) - ) + catchError((error) => of(CardsActions.deleteError({ error }))), + ), + ), + ), ); deleteSuccess$ = createEffect( @@ -157,12 +157,24 @@ export class CardsEffects { ofType(CardsActions.deleteSuccess), tap((action) => { this.snackService.success( - this.translateService.instant('CARDS.CARD.SUCCESS.DELETE') + this.translateService.instant('CARDS.CARD.SUCCESS.DELETE'), ); this.router.navigate(['/cards'], { replaceUrl: true }); - }) + }), + ), + { dispatch: false }, + ); + + patchListItem$ = createEffect(() => + this.actions$.pipe( + ofType(CardsActions.patchListItem), + switchMap((action) => + this.cardsApiService.patch(action.id, action.body).pipe( + map((card) => CardsActions.patchListItemSuccess({ card })), + catchError((error) => of(CardsActions.patchListItemError({ error }))), + ), ), - { dispatch: false } + ), ); showErrors$ = createEffect( @@ -173,12 +185,13 @@ export class CardsEffects { CardsActions.createError, CardsActions.readError, CardsActions.updateError, - CardsActions.deleteError + CardsActions.deleteError, + CardsActions.patchListItemError, ), tap((action) => { this.snackService.error(action.error); - }) + }), ), - { dispatch: false } + { dispatch: false }, ); } diff --git a/frontend/src/app/entities/cards/state/cards.reducers.ts b/frontend/src/app/entities/cards/state/cards.reducers.ts index 7538bec..1d5edbc 100644 --- a/frontend/src/app/entities/cards/state/cards.reducers.ts +++ b/frontend/src/app/entities/cards/state/cards.reducers.ts @@ -27,7 +27,7 @@ export const cardsReducer = createReducer( (state, payload) => ({ ...state, isLoading: true, - }) + }), ), on(CardsActions.listSuccess, (state, payload) => ({ ...state, @@ -46,7 +46,7 @@ export const cardsReducer = createReducer( hasChanges: false, }, isLoading: false, - }) + }), ), on(CardsActions.deleteSuccess, (state, payload) => ({ ...state, @@ -62,7 +62,7 @@ export const cardsReducer = createReducer( color: null, }; const hasChanges = Object.entries(payload.form).some( - ([k, v]) => v != info[k as keyof ICardBase] + ([k, v]) => v != info[k as keyof ICardBase], ); return { ...state, @@ -86,6 +86,18 @@ export const cardsReducer = createReducer( (state, payload) => ({ ...state, isLoading: false, - }) - ) + }), + ), + on(CardsActions.patchListItemSuccess, (state, payload) => { + const list = [...state.list]; + const index = list.findIndex((i) => i.id == payload.card.id); + list[index] = { + ...list[index], + ...payload.card, + }; + return { + ...state, + list, + }; + }), ); diff --git a/frontend/src/app/features/cards/cards.component.html b/frontend/src/app/features/cards/cards.component.html index bb2a12d..4c47010 100644 --- a/frontend/src/app/features/cards/cards.component.html +++ b/frontend/src/app/features/cards/cards.component.html @@ -26,6 +26,14 @@ } + + + } + } + + + + + + diff --git a/frontend/src/app/features/sort-filter-dialog/sort-filter-dialog.component.scss b/frontend/src/app/features/sort-filter-dialog/sort-filter-dialog.component.scss new file mode 100644 index 0000000..4ae1a7c --- /dev/null +++ b/frontend/src/app/features/sort-filter-dialog/sort-filter-dialog.component.scss @@ -0,0 +1,19 @@ +@use 'mixins' as mx; + +.mat-dialog-content { + max-height: unset; +} + +.form { + @include mx.flex-container(2); + border-bottom: 1px solid var(--mat-sys-outline-variant); + margin-bottom: 20px; + + &-filter-by { + flex-basis: 100%; + } +} + +.mat-dialog-actions { + @include mx.mat-dialog-actions; +} diff --git a/frontend/src/app/features/sort-filter-dialog/sort-filter-dialog.component.spec.ts b/frontend/src/app/features/sort-filter-dialog/sort-filter-dialog.component.spec.ts new file mode 100644 index 0000000..6f1a7e0 --- /dev/null +++ b/frontend/src/app/features/sort-filter-dialog/sort-filter-dialog.component.spec.ts @@ -0,0 +1,141 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { + ISortFilterDialogData, + SortFilterDialogComponent, +} from './sort-filter-dialog.component'; +import { ICard } from 'src/app/entities/cards/cards-interface'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { createMatDialogRefMock, TestTranslateModule } from 'src/app/test'; +import { Filter } from 'src/app/shared/types'; + +const dialogData: ISortFilterDialogData = { + title: 'TEST TITLE', + sorting: { + options: [ + { key: 'name', label: 'CARDS.CARD.NAME' }, + { key: 'used_at', label: 'CARDS.CARD.USED_AT' }, + { key: 'updated_at', label: 'CARDS.CARD.UPDATED_AT' }, + { key: 'created_at', label: 'CARDS.CARD.CREATED_AT' }, + ], + value: { + key: 'name', + direction: 'asc', + }, + }, + filter: { + options: [ + { + key: 'description', + type: 'string', + criterias: [ + Filter.Criteria.LIKE, + Filter.Criteria.NOT_NULL, + Filter.Criteria.NULL, + ], + label: 'CARDS.CARD.DESCRIPTION', + }, + { + key: 'is_favorite', + type: 'boolean', + criterias: [Filter.Criteria.EQUALS], + label: 'CARDS.CARD.IS_FAVORITE', + }, + { + key: 'used_at', + type: 'date', + criterias: [ + Filter.Criteria.GREATER_OR_EQUALS, + Filter.Criteria.LESS_OR_EQUALS, + Filter.Criteria.GREATER, + Filter.Criteria.LESS, + ], + label: 'CARDS.CARD.USED_AT', + }, + ], + value: [ + { + key: 'description', + criteria: Filter.Criteria.LIKE, + value: 'test', + }, + { + key: 'is_favorite', + criteria: Filter.Criteria.EQUALS, + value: true, + }, + ], + }, +}; + +describe('SortFilterDialogComponent', () => { + let component: SortFilterDialogComponent; + let fixture: ComponentFixture>; + let template: HTMLElement; + + let matDialogRefMock: ReturnType; + + beforeEach(() => { + matDialogRefMock = createMatDialogRefMock(); + + TestBed.configureTestingModule({ + imports: [TestTranslateModule], + providers: [ + { provide: MatDialogRef, useValue: matDialogRefMock }, + { provide: MAT_DIALOG_DATA, useValue: dialogData }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SortFilterDialogComponent); + component = fixture.componentInstance; + template = fixture.debugElement.nativeElement; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should display forms from data', () => { + fixture.detectChanges(); + + let formsElements = template.querySelectorAll('form'); + expect(formsElements.length).toEqual(3); + + const anotherFilter: Filter.Model = { + key: 'used_at', + criteria: Filter.Criteria.GREATER_OR_EQUALS, + value: '2025-07-04T21:00:00.000Z', + }; + }); + + it('should add filter', () => { + fixture.detectChanges(); + + let addFiterButton: HTMLButtonElement = template.querySelector( + 'mat-dialog-content > button', + ); + expect(addFiterButton).toBeTruthy(); + + addFiterButton.click(); + fixture.detectChanges(); + + let formsElements = template.querySelectorAll('form'); + expect(formsElements.length).toEqual(4); + }); + + it('should remove filter', () => { + fixture.detectChanges(); + + const filterFormRemoveButton: HTMLElement = template.querySelector( + 'mat-dialog-content form[name="filter-form"] [name="filter-form-remove-button"]', + ); + expect(filterFormRemoveButton).toBeTruthy(); + + filterFormRemoveButton.click(); + fixture.detectChanges(); + + let formsElements = template.querySelectorAll('form'); + expect(formsElements.length).toEqual(2); + }); +}); diff --git a/frontend/src/app/features/sort-filter-dialog/sort-filter-dialog.component.ts b/frontend/src/app/features/sort-filter-dialog/sort-filter-dialog.component.ts new file mode 100644 index 0000000..aff6c5e --- /dev/null +++ b/frontend/src/app/features/sort-filter-dialog/sort-filter-dialog.component.ts @@ -0,0 +1,230 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, +} from '@angular/core'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatSelectModule } from '@angular/material/select'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { Filter, Sorting } from 'src/app/shared/types'; +import { + FormArray, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MatButton, MatButtonModule } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatTimepickerModule } from '@angular/material/timepicker'; + +/** + * Data for {@link SortFilterDialogComponent} + */ +export interface ISortFilterDialogData { + /** + * Title of the dialog. + * @default 'SORT_FILTER.TITLE' + */ + title?: string; + sorting?: { + options: Sorting.Option[]; + value: Sorting.Model; + }; + filter?: { + options: Filter.Option[]; + value: Filter.Model[]; + }; +} +/** + * Result of {@link SortFilterDialogComponent} + */ +export interface ISortFilterDialogResult { + sortingModel?: Sorting.Model; + filterModels?: Filter.Model[]; +} +/** + * Yupe of filter form + */ +type TFilterFormArrayItem = FormGroup<{ + option: FormControl>; + criteria: FormControl; + value: FormControl; +}>; + +@Component({ + selector: 'app-sort-filter-dialog', + imports: [ + MatDialogModule, + MatInputModule, + MatFormFieldModule, + MatCheckboxModule, + MatDatepickerModule, + TranslateModule, + MatSelectModule, + MatButtonModule, + ReactiveFormsModule, + MatIcon, + MatButton, + MatTimepickerModule, + ], + templateUrl: './sort-filter-dialog.component.html', + styleUrl: './sort-filter-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SortFilterDialogComponent { + private readonly matDialogRef = inject(MatDialogRef); + private readonly data: ISortFilterDialogData = inject(MAT_DIALOG_DATA); + private readonly translateService = inject(TranslateService); + /** + * Maximum number of filters + * @default 10 + */ + public readonly maxFilters = input(10); + /** + * Title of the dialog + */ + public readonly title = this.data?.title ?? 'SORT_FILTER.TITLE'; + /** + * Sorting options + */ + public readonly sortingOptions = this.data?.sorting?.options; + /** + * Sorting orders + */ + public readonly sortingOrders: { + value: Sorting.Direction; + label: string; + icon: string; + }[] = [ + { value: 'asc', label: 'SORT_FILTER.SORT_DIRS.ASC', icon: 'arrow_upwards' }, + { + value: 'desc', + label: 'SORT_FILTER.SORT_DIRS.DESC', + icon: 'arrow_downwards', + }, + ]; + /** + * Sorting form + */ + public readonly sortingForm = new FormGroup({ + key: new FormControl(this.data?.sorting?.value?.key ?? null, [ + Validators.required, + ]), + direction: new FormControl( + this.data?.sorting?.value?.direction ?? null, + [Validators.required], + ), + }); + + /** + * Options for select of boolean typed filter + */ + public readonly booleanOption: { value: boolean; label: string }[] = (() => { + const labels = this.translateService.instant('SORT_FILTER.BOOL_LABEL'); + return [ + { value: true, label: labels.TRUE }, + { value: false, label: labels.FALSE }, + ]; + })(); + /** + * Enum of filter criterias + */ + public readonly FilterCriteria = Filter.Criteria; + /** + * Filter options + */ + public readonly filterOptions = this.data?.filter?.options; + /** + * Names of the criterias + */ + public readonly criteriasNames = this.translateService.instant( + 'SORT_FILTER.CRITERIAS', + ); + /** + * Array of filter forms + */ + public readonly filterFormArray = new FormArray< + TFilterFormArrayItem + >([]); + + constructor() { + if (this.data?.filter?.value?.length) { + for (const item of this.data.filter.value) { + const form = this.getFilterFormArrayItem(item); + this.filterFormArray.push(form); + } + } + } + + private getFilterFormArrayItem( + value?: Filter.Model, + ): TFilterFormArrayItem { + const option = value + ? this.filterOptions.find((o) => o.key == value.key) + : null; + const form: TFilterFormArrayItem = new FormGroup({ + option: new FormControl(option, [Validators.required]), + criteria: new FormControl(value?.criteria, [Validators.required]), + value: new FormControl(value?.value), + }); + return form; + } + + public addFilter(): void { + this.filterFormArray.push(this.getFilterFormArrayItem()); + } + + public removeFilter(form: TFilterFormArrayItem): void { + const index = this.filterFormArray.controls.findIndex((f) => f === form); + this.filterFormArray.controls.splice(index, 1); + } + + public confirm(): void { + const data: ISortFilterDialogResult = {}; + if (this.sortingForm.valid) { + data.sortingModel = this.sortingForm.getRawValue(); + } + const filterModels: Filter.Model[] = []; + for (const f of this.filterFormArray.controls) { + if (f.valid) { + const _value = f.controls.value.value as any; + let value = null; + if (_value) { + switch (f.controls.option.value.type) { + case 'number': + value = Number(_value); + break; + case 'bigint': + value = BigInt(_value); + break; + default: + value = _value; + } + } + filterModels.push({ + key: f.controls.option.value.key, + criteria: f.controls.criteria.value, + value: value, + }); + } + } + if (filterModels.length) { + data.filterModels = filterModels; + } + this.matDialogRef.close(data); + } + + public cancel(): void { + this.matDialogRef.close(); + } +} diff --git a/frontend/src/app/shared/types/index.ts b/frontend/src/app/shared/types/index.ts new file mode 100644 index 0000000..347fee3 --- /dev/null +++ b/frontend/src/app/shared/types/index.ts @@ -0,0 +1,5 @@ +export * from './ns-sorting'; +export * from './ns-filter'; +export * from './premitive'; +export * from './interface-to-form'; +export * from './type-to-string'; diff --git a/frontend/src/app/shared/types/ns-filter.ts b/frontend/src/app/shared/types/ns-filter.ts new file mode 100644 index 0000000..53dfe32 --- /dev/null +++ b/frontend/src/app/shared/types/ns-filter.ts @@ -0,0 +1,123 @@ +import { Premitive } from './premitive'; +import { TypeToString } from './type-to-string'; + +/** + * App filters namespace + */ +export namespace Filter { + /** + * Criterias of filtering + */ + export enum Criteria { + LIKE = 'LIKE', + EQUALS = 'EQUALS', + NOT_EQUALS = 'NOT_EQUALS', + NULL = 'NULL', + NOT_NULL = 'NOT_NULL', + GREATER = 'GREATER', + LESS = 'LESS', + GREATER_OR_EQUALS = 'GREATER_OR_EQUALS', + LESS_OR_EQUALS = 'LESS_OR_EQUALS', + } + /** + * Model of app filters + */ + export interface Model { + key: K; + criteria: Criteria; + value: T[K]; + } + /** + * Option of filtering + */ + export interface Option { + key: K; + label: string; + type: TypeToString; + criterias: Criteria[]; + } + /** + * Filter entities + * @param items entities + * @param model filter model + * @param type type of key + * @returns + */ + export const filterBy = ( + items: T[], + model: Model, + type: Premitive, + ): T[] => { + try { + switch (model.criteria) { + case 'LIKE': { + const value = String(model.value).toLowerCase(); + return items.filter((item) => { + const itemValue = String(item[model.key]).toLowerCase(); + return itemValue.includes(value); + }); + } + case 'EQUALS': + return items.filter((item) => item[model.key] === model.value); + case 'NOT_EQUALS': + return items.filter((item) => item[model.key] !== model.value); + case 'NULL': + return items.filter((item) => + [null, undefined].includes(item[model.key]), + ); + case 'NOT_NULL': + return items.filter( + (item) => ![null, undefined].includes(item[model.key]), + ); + case 'GREATER': + switch (type) { + case 'date': + return items.filter( + (item) => + new Date(item[model.key] as string) > + new Date(model.value as string), + ); + default: + return items.filter((item) => item[model.key] > model.value); + } + case 'LESS': + switch (type) { + case 'date': + return items.filter( + (item) => + new Date(item[model.key] as string) < + new Date(model.value as string), + ); + default: + return items.filter((item) => item[model.key] < model.value); + } + case 'GREATER_OR_EQUALS': + switch (type) { + case 'date': + return items.filter( + (item) => + new Date(item[model.key] as string) >= + new Date(model.value as string), + ); + default: + return items.filter((item) => item[model.key] >= model.value); + } + case 'LESS_OR_EQUALS': + switch (type) { + case 'date': + return items.filter( + (item) => + new Date(item[model.key] as string) <= + new Date(model.value as string), + ); + default: + return items.filter((item) => item[model.key] <= model.value); + } + default: + return items; + } + } catch { + return items; + } + }; +} diff --git a/frontend/src/app/shared/types/ns-sorting.ts b/frontend/src/app/shared/types/ns-sorting.ts new file mode 100644 index 0000000..c8ff8b1 --- /dev/null +++ b/frontend/src/app/shared/types/ns-sorting.ts @@ -0,0 +1,69 @@ +import { Premitive } from './premitive'; + +/** + * App sorting namespace + */ +export namespace Sorting { + /** + * Sorting direction + */ + export type Direction = 'asc' | 'desc'; + /** + * Model of sorting + */ + export interface Model { + key: K; + direction: Direction; + } + /** + * Option of sorting + */ + export interface Option { + key: keyof T; + label: string; + } + /** + * Sort entities + * @param items entities + * @param model sorting model + * @param type type of key + * @returns + */ + export const sortBy = ( + items: T[], + model: Model, + type: Premitive, + ): T[] => { + try { + let comperator: (a: any, b: any) => number; + switch (type) { + case 'boolean': { + comperator = (a: boolean, b: boolean) => +a - +b; + break; + } + case 'string': { + comperator = (a: string, b: string) => a.localeCompare(b); + break; + } + case 'date': { + comperator = (a: string, b: string) => { + const at = new Date(a).valueOf(); + const bt = new Date(b).valueOf(); + return at - bt; + }; + break; + } + default: { + comperator = (a: any, b: any) => a - b; + } + } + return [...items].sort((a, b) => + model.direction == 'asc' + ? comperator(a[model.key], b[model.key]) + : comperator(b[model.key], a[model.key]), + ); + } catch { + return items; + } + }; +} diff --git a/frontend/src/app/shared/types/premitive.ts b/frontend/src/app/shared/types/premitive.ts new file mode 100644 index 0000000..2d3bdd3 --- /dev/null +++ b/frontend/src/app/shared/types/premitive.ts @@ -0,0 +1,6 @@ +/** + * Premitive types as strings. + * Not quiet a 'types' because of 'date'. + * It is used to sorting and filtering. + */ +export type Premitive = 'boolean' | 'string' | 'number' | 'date' | 'bigint'; diff --git a/frontend/src/app/shared/types/type-to-string.ts b/frontend/src/app/shared/types/type-to-string.ts new file mode 100644 index 0000000..5ec0b74 --- /dev/null +++ b/frontend/src/app/shared/types/type-to-string.ts @@ -0,0 +1,20 @@ +/** + * Helper type that extracts type name of {@link T}. + */ +export type TypeToString = T extends string + ? 'string' | 'date' + : T extends number + ? 'number' + : T extends bigint + ? 'bigint' + : T extends boolean + ? 'boolean' + : T extends Date + ? 'date' + : T extends any[] + ? 'array' + : T extends object + ? 'object' + : T extends undefined + ? 'undefined' + : 'unknown'; diff --git a/frontend/src/app/test/test-state.ts b/frontend/src/app/test/test-state.ts index 121e996..792851e 100644 --- a/frontend/src/app/test/test-state.ts +++ b/frontend/src/app/test/test-state.ts @@ -49,6 +49,8 @@ export const testAppState: ITestAppState = { 'description': null, 'color': '#d40c0c', 'id': 1, + 'updated_at': null, + 'created_at': null, }, { 'code': '0123456789012', @@ -57,6 +59,8 @@ export const testAppState: ITestAppState = { 'description': 'test desc\nnewline', 'color': '#057eff', 'id': 2, + 'updated_at': null, + 'created_at': null, }, ], active: null, diff --git a/frontend/src/extensions/local-storage-json.spec.ts b/frontend/src/extensions/local-storage-json.spec.ts new file mode 100644 index 0000000..8f39bd5 --- /dev/null +++ b/frontend/src/extensions/local-storage-json.spec.ts @@ -0,0 +1 @@ +import './local-storage-json'; diff --git a/frontend/src/extensions/local-storage-json.ts b/frontend/src/extensions/local-storage-json.ts index 7e0c148..e29eb27 100644 --- a/frontend/src/extensions/local-storage-json.ts +++ b/frontend/src/extensions/local-storage-json.ts @@ -1,11 +1,7 @@ -interface Storage { - getItemJson(key: string): T | null; - setItemJson(key: string, value: T): void; -} -declare module 'localStorage' { +declare global { interface Storage { - getItemJson(key: string): T | null; - setItemJson(key: string, value: T): void; + getItemJson(key: string): T | null; + setItemJson(key: string, value: T): void; } } @@ -23,6 +19,9 @@ Storage.prototype.getItemJson = function (key) { } return null; }; + Storage.prototype.setItemJson = function (key, value) { this.setItem(key, JSON.stringify(value)); }; + +export {};