From a2d81000e9ea7872a353046e665f6d7066fbd007 Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Sat, 13 Jun 2026 11:46:07 +0200 Subject: [PATCH 01/11] Implement card row positioning --- .../app/components/grid/grid.component.css | 16 +++- .../app/components/grid/grid.component.html | 50 ++++++++++--- .../components/grid/grid.component.spec.ts | 37 ++++++++-- .../src/app/components/grid/grid.component.ts | 58 +++++++++++++-- .../src/app/directives/resizable.directive.ts | 6 +- Gridly-Client/src/app/models/card.Model.ts | 1 + .../card_services/card.service.spec.ts | 32 +++++++- .../services/card_services/card.service.ts | 73 ++++++++++++++++++- 8 files changed, 239 insertions(+), 34 deletions(-) diff --git a/Gridly-Client/src/app/components/grid/grid.component.css b/Gridly-Client/src/app/components/grid/grid.component.css index 797a9331..093913b8 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.css +++ b/Gridly-Client/src/app/components/grid/grid.component.css @@ -2,13 +2,23 @@ padding: 1em; margin-top: 3em; display: flex; - flex-wrap: wrap; + flex-direction: column; gap: 16px; - align-items: flex-start; - align-content: flex-start; min-height: 100%; } +.grid-row { + display: flex; + flex-wrap: nowrap; + gap: 16px; + align-items: flex-start; + min-height: 250px; +} + +.grid-row-empty { + min-height: 48px; +} + .grid-card-style { background-color: #141517; border-bottom: 20px; diff --git a/Gridly-Client/src/app/components/grid/grid.component.html b/Gridly-Client/src/app/components/grid/grid.component.html index 829d8559..2b537196 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.html +++ b/Gridly-Client/src/app/components/grid/grid.component.html @@ -1,15 +1,41 @@ -@if (cards$ | async; as cards) { -
- @for (card of cards; track card.id) { -
- -
+@if (rows$ | async; as rows) { +
+ @for (row of rows; track $index; let rowIndex = $index) { +
+ @for (card of row; track card.id) { +
+ +
+ } +
+ } + @if (editActive()) { +
+ +
}
} diff --git a/Gridly-Client/src/app/components/grid/grid.component.spec.ts b/Gridly-Client/src/app/components/grid/grid.component.spec.ts index 4b594dab..ba68a9d0 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.spec.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.spec.ts @@ -8,7 +8,7 @@ import { GridService } from '../../services/grid_services/grid.service'; import { GridComponent } from './grid.component'; type GridComponentTestHarness = GridComponent & { - Drop(event: unknown): void; + Drop(event: unknown, rows: CardModel[][], rowIndex: number): void; }; @Component({ @@ -27,7 +27,11 @@ describe('GridComponent', () => { let cardsSubject: BehaviorSubject; let editMode: ReturnType>; - const cardServiceMock = {} as { cards$: Observable }; + const cardServiceMock = {} as { + cards$: Observable; + setRows: jest.Mock; + toRows: jest.Mock; + }; const gridServiceMock = {} as { inEditMode: () => boolean }; beforeEach(async () => { @@ -37,6 +41,8 @@ describe('GridComponent', () => { ]; cardsSubject = new BehaviorSubject(cards); cardServiceMock.cards$ = cardsSubject.asObservable(); + cardServiceMock.setRows = jest.fn(); + cardServiceMock.toRows = jest.fn((cardsToGroup: CardModel[]) => [cardsToGroup]); editMode = signal(true); gridServiceMock.inEditMode = editMode.asReadonly(); @@ -66,28 +72,43 @@ describe('GridComponent', () => { it('reorders card when drag-drop happens in edit mode', () => { const event = { - container: { data: cards }, + previousContainer: { id: 'card-row-0' }, previousIndex: 0, currentIndex: 1, + item: { data: cards[0] }, } as never; - (gridComponent as GridComponentTestHarness).Drop(event); + (gridComponent as GridComponentTestHarness).Drop(event, [cards], 0); - expect(cards.map((card) => card.id)).toEqual([2, 1]); + expect(cardServiceMock.setRows).toHaveBeenCalledWith([[cards[1], cards[0]]], 0); }); it('does not reorder card when edit mode is disabled', () => { editMode.set(false); const event = { - container: { data: cards }, + previousContainer: { id: 'card-row-0' }, previousIndex: 0, currentIndex: 1, + item: { data: cards[0] }, } as never; - (gridComponent as GridComponentTestHarness).Drop(event); + (gridComponent as GridComponentTestHarness).Drop(event, [cards], 0); - expect(cards.map((card) => card.id)).toEqual([1, 2]); + expect(cardServiceMock.setRows).not.toHaveBeenCalled(); + }); + + it('moves a card into a new row', () => { + const event = { + previousContainer: { id: 'card-row-0' }, + previousIndex: 1, + currentIndex: 0, + item: { data: cards[1] }, + } as never; + + (gridComponent as GridComponentTestHarness).Drop(event, [cards], 1); + + expect(cardServiceMock.setRows).toHaveBeenCalledWith([[cards[0]], [cards[1]]], 0); }); it('keeps the card DOM element stable when a resized card object is emitted', () => { diff --git a/Gridly-Client/src/app/components/grid/grid.component.ts b/Gridly-Client/src/app/components/grid/grid.component.ts index 8f25663f..a8e9e71f 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.ts @@ -1,10 +1,11 @@ -import { Component, inject } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, HostListener, ViewChild, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CardService } from "../../services/card_services/card.service"; -import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from "@angular/cdk/drag-drop"; +import { CdkDrag, CdkDragDrop, CdkDropList } from "@angular/cdk/drag-drop"; import { CardModel } from '../../models/card.Model'; import { GridService } from '../../services/grid_services/grid.service'; import { CardComponent } from '../card/card.component'; +import { BehaviorSubject, combineLatest, map } from 'rxjs'; @Component({ selector: 'app-grid', imports: [CommonModule, CdkDropList, CdkDrag, CardComponent], @@ -13,15 +14,60 @@ import { CardComponent } from '../card/card.component'; styleUrls: ['./grid.component.css'], }) -export class GridComponent { +export class GridComponent implements AfterViewInit { #cardService = inject(CardService); #gridService = inject(GridService); - protected readonly cards$ = this.#cardService.cards$; + @ViewChild('gridLayout') private gridLayout?: ElementRef; + + private readonly gridWidthSubject = new BehaviorSubject(0); + protected readonly rows$ = combineLatest([this.#cardService.cards$, this.gridWidthSubject]).pipe( + map(([cards, maxRowWidth]) => this.#cardService.toRows(cards, maxRowWidth)), + ); protected readonly editActive = this.#gridService.inEditMode; - protected Drop(event: CdkDragDrop): void { + ngAfterViewInit(): void { + queueMicrotask(() => this.updateGridWidth()); + } + + @HostListener('window:resize') + OnResize(): void { + this.updateGridWidth(); + } + + protected Drop(event: CdkDragDrop, rows: CardModel[][], rowIndex: number): void { if (!this.editActive()) return; - moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + + const updatedRows = rows.map((row) => [...row]); + const previousRowIndex = this.getRowIndex(event.previousContainer.id); + const movedCard = event.item.data as CardModel; + + if (previousRowIndex === rowIndex) { + const row = updatedRows[rowIndex]; + row.splice(event.previousIndex, 1); + row.splice(event.currentIndex, 0, movedCard); + } else { + updatedRows[previousRowIndex]?.splice(event.previousIndex, 1); + updatedRows[rowIndex] = updatedRows[rowIndex] ?? []; + updatedRows[rowIndex].splice(event.currentIndex, 0, movedCard); + } + + this.#cardService.setRows(updatedRows, this.gridWidthSubject.value); + } + + protected RowId(rowIndex: number): string { + return `card-row-${rowIndex}`; + } + + protected RowIds(rows: CardModel[][]): string[] { + return [...rows, []].map((_, rowIndex) => this.RowId(rowIndex)); + } + + private updateGridWidth(): void { + this.gridWidthSubject.next(this.gridLayout?.nativeElement.clientWidth ?? 0); + } + + private getRowIndex(rowId: string): number { + return Number(rowId.replace('card-row-', '')); } } diff --git a/Gridly-Client/src/app/directives/resizable.directive.ts b/Gridly-Client/src/app/directives/resizable.directive.ts index 47098caf..df0c0c59 100644 --- a/Gridly-Client/src/app/directives/resizable.directive.ts +++ b/Gridly-Client/src/app/directives/resizable.directive.ts @@ -85,7 +85,7 @@ export class ResizableDirective { titleHidden: this.targetCard.settings?.titleHidden ?? false }; - this.#cardService.update(this.targetCard); + this.#cardService.update(this.targetCard, this.GetGridWidth()); this.renderer.setStyle(this.cardElement, 'height', adjustedHeight + 'px'); this.renderer.setStyle(this.cardElement, 'width', adjustedWidth + 'px'); @@ -157,4 +157,8 @@ export class ResizableDirective { if (value <= 800) return 700; return 800; } + + private GetGridWidth(): number { + return this.cardElement?.closest('.grid-layout')?.clientWidth ?? 0; + } } diff --git a/Gridly-Client/src/app/models/card.Model.ts b/Gridly-Client/src/app/models/card.Model.ts index ba5c8b5e..f9ace4ec 100644 --- a/Gridly-Client/src/app/models/card.Model.ts +++ b/Gridly-Client/src/app/models/card.Model.ts @@ -4,6 +4,7 @@ import {IconModel} from "./icon.Model"; export class CardModel { id!: number; indexPosition!: number; + rowPosition?: number; iconUrl?: string; name!: string; url!: string; diff --git a/Gridly-Client/src/app/services/card_services/card.service.spec.ts b/Gridly-Client/src/app/services/card_services/card.service.spec.ts index 65afcd7f..cec84a84 100644 --- a/Gridly-Client/src/app/services/card_services/card.service.spec.ts +++ b/Gridly-Client/src/app/services/card_services/card.service.spec.ts @@ -94,8 +94,36 @@ describe('CardService', () => { await service.batchEdit(service.currentCards()); expect(endpointMock.batchEdit).toHaveBeenCalledWith([ - resizedCard, - cardB, + { ...resizedCard, indexPosition: 1, rowPosition: 1 }, + { ...cardB, indexPosition: 1, rowPosition: 2 }, + ]); + }); + + it('groups cards into rows when a row exceeds the max width', () => { + const rows = service.toRows([cardA, cardB], 400); + + expect(rows.map((row) => row.map((card) => card.id))).toEqual([[1], [2]]); + expect(rows.flat()).toEqual([ + { ...cardA, indexPosition: 1, rowPosition: 1 }, + { ...cardB, indexPosition: 2, rowPosition: 1 }, + ]); + }); + + it('updates row and row position when rows are changed', () => { + service.setRows([[cardA], [cardB]], 1000); + + expect(service.currentCards()).toEqual([ + { ...cardA, indexPosition: 1, rowPosition: 1 }, + { ...cardB, indexPosition: 2, rowPosition: 1 }, + ]); + }); + + it('removes empty rows when cards move out of them', () => { + service.setRows([[], [cardB, cardA]], 1000); + + expect(service.currentCards()).toEqual([ + { ...cardB, indexPosition: 1, rowPosition: 1 }, + { ...cardA, indexPosition: 1, rowPosition: 2 }, ]); }); }); diff --git a/Gridly-Client/src/app/services/card_services/card.service.ts b/Gridly-Client/src/app/services/card_services/card.service.ts index f21df1a2..e0da4348 100644 --- a/Gridly-Client/src/app/services/card_services/card.service.ts +++ b/Gridly-Client/src/app/services/card_services/card.service.ts @@ -8,6 +8,8 @@ import { CardEndpointService } from '../endpoint_services/card.endpoint.service' @Injectable({ providedIn: 'root' }) export class CardService { #api = inject(CardEndpointService); + private readonly cardGap = 16; + private readonly defaultCardWidth = 250; private readonly cardsSubject = new BehaviorSubject([]); @@ -34,7 +36,7 @@ export class CardService { add = (card: CardModel) => firstValueFrom(this.add$(card)).then(() => this.refresh()); delete = (id: number) => firstValueFrom(this.delete$(id)).then(() => this.refresh()); - update = (card: CardModel): void => { + update = (card: CardModel, maxRowWidth = 0): void => { const updatedCards = this.currentCards().map((currentCard) => { if (currentCard.id !== card.id) return currentCard; @@ -49,9 +51,76 @@ export class CardService { }, }; }); - this.cardsSubject.next(updatedCards); + this.cardsSubject.next(this.flattenRows(this.toRows(updatedCards, maxRowWidth))); }; + setRows(rows: CardModel[][], maxRowWidth = 0): void { + this.cardsSubject.next(this.flattenRows(this.normalizeRows(rows, maxRowWidth))); + } + + toRows(cards: CardModel[], maxRowWidth = 0): CardModel[][] { + const hasRowData = cards.some((card) => card.rowPosition !== undefined) || + new Set(cards.map((card) => card.indexPosition)).size !== cards.length; + + if (!hasRowData) { + return this.normalizeRows([ + [...cards].sort((a, b) => a.indexPosition - b.indexPosition), + ], maxRowWidth); + } + + const rows = new Map(); + cards.forEach((card) => { + const rowIndex = Math.max((card.indexPosition ?? 1) - 1, 0); + rows.set(rowIndex, [...(rows.get(rowIndex) ?? []), card]); + }); + + return this.normalizeRows( + [...rows.entries()] + .sort(([first], [second]) => first - second) + .map(([, row]) => [...row].sort((a, b) => (a.rowPosition ?? 0) - (b.rowPosition ?? 0))), + maxRowWidth, + ); + } + + private normalizeRows(rows: CardModel[][], maxRowWidth: number): CardModel[][] { + const normalizedRows = rows.map((row) => [...row]); + + if (maxRowWidth > 0) { + for (let rowIndex = 0; rowIndex < normalizedRows.length; rowIndex++) { + const row = normalizedRows[rowIndex]; + while (row.length > 1 && this.getRowWidth(row) > maxRowWidth) { + const overflowCard = row.pop(); + if (!overflowCard) break; + normalizedRows[rowIndex + 1] = normalizedRows[rowIndex + 1] ?? []; + normalizedRows[rowIndex + 1].unshift(overflowCard); + } + } + } + + return normalizedRows + .filter((row) => row.length > 0) + .map((row, rowIndex) => + row.map((card, rowPosition) => ({ + ...card, + indexPosition: rowIndex + 1, + rowPosition: rowPosition + 1, + })), + ); + } + + private flattenRows(rows: CardModel[][]): CardModel[] { + return rows.flatMap((row) => row); + } + + private getRowWidth(row: CardModel[]): number { + const cardsWidth = row.reduce( + (width, card) => width + (card.settings?.width ?? this.defaultCardWidth), + 0, + ); + + return cardsWidth + Math.max(row.length - 1, 0) * this.cardGap; + } + refresh(): void { this.#api.get().pipe(take(1)) .subscribe((cards) => From f5c8845c9b369891e4498c8d4feeeb5716fa6410 Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Sat, 13 Jun 2026 12:48:01 +0200 Subject: [PATCH 02/11] Fix empty grid row drop list typing --- Gridly-Client/src/app/components/grid/grid.component.html | 2 +- Gridly-Client/src/app/components/grid/grid.component.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Gridly-Client/src/app/components/grid/grid.component.html b/Gridly-Client/src/app/components/grid/grid.component.html index 2b537196..66612fb3 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.html +++ b/Gridly-Client/src/app/components/grid/grid.component.html @@ -32,7 +32,7 @@ cdkDropListOrientation="horizontal" [id]="RowId(rows.length)" [cdkDropListConnectedTo]="RowIds(rows)" - [cdkDropListData]="[]" + [cdkDropListData]="emptyRowData" (cdkDropListDropped)="Drop($event, rows, rows.length)">
diff --git a/Gridly-Client/src/app/components/grid/grid.component.ts b/Gridly-Client/src/app/components/grid/grid.component.ts index a8e9e71f..2cfdd09d 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.ts @@ -21,6 +21,7 @@ export class GridComponent implements AfterViewInit { @ViewChild('gridLayout') private gridLayout?: ElementRef; private readonly gridWidthSubject = new BehaviorSubject(0); + protected readonly emptyRowData: CardModel[] = []; protected readonly rows$ = combineLatest([this.#cardService.cards$, this.gridWidthSubject]).pipe( map(([cards, maxRowWidth]) => this.#cardService.toRows(cards, maxRowWidth)), ); From d7a14efe36b3d0513cacf15c4305bcc2275979ef Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Mon, 22 Jun 2026 00:15:00 +0200 Subject: [PATCH 03/11] Clean upp, added missing row postion on query and in dapper. --- Constants/QueryStrings.cs | 8 +++++--- Data/DbInitializer.cs | 1 + .../src/app/components/grid/grid.component.css | 11 ++++------- .../src/app/components/grid/grid.component.html | 1 - .../src/app/directives/resizable.directive.ts | 6 ------ Models/CardModel.cs | 1 + 6 files changed, 11 insertions(+), 17 deletions(-) diff --git a/Constants/QueryStrings.cs b/Constants/QueryStrings.cs index 1bd1fb7f..e32563f3 100644 --- a/Constants/QueryStrings.cs +++ b/Constants/QueryStrings.cs @@ -3,8 +3,8 @@ namespace Gridly.Constants; public class QueryStrings { public const string InsertToCardQuery = @" - INSERT INTO Card (IndexPosition, Name, Url, IconUrl) - VALUES (@IndexPosition, @Name, @Url, @IconUrl); + INSERT INTO Card (IndexPosition, RowPosition, Name, Url, IconUrl) + VALUES (@IndexPosition, @RowPosition, @Name, @Url, @IconUrl); SELECT * FROM Card WHERE Id = last_insert_rowid();"; public const string InsertToSettingsQuery = @" @@ -26,6 +26,7 @@ INSERT INTO IconsConnected (CardId, IconId) SELECT co.Id AS CardId, co.IndexPosition, + co.RowPosition, co.Name AS CardName, co.Url, co.IconUrl, @@ -61,13 +62,14 @@ UPDATE Icon UPDATE Card SET Name = @Name, IndexPosition = @IndexPosition, + RowPosition = @RowPosition, Url = @Url, IconUrl = @IconUrl /**where**/"; public const string UpdateBatchCardQuery = @" UPDATE Card - SET IndexPosition = @IndexPosition + SET IndexPosition = @IndexPosition, RowPosition = @RowPosition WHERE Id = @Id; UPDATE Settings diff --git a/Data/DbInitializer.cs b/Data/DbInitializer.cs index 666ccb85..71932022 100644 --- a/Data/DbInitializer.cs +++ b/Data/DbInitializer.cs @@ -19,6 +19,7 @@ await connection.ExecuteAsync( CREATE TABLE IF NOT EXISTS Card( Id INTEGER PRIMARY KEY AUTOINCREMENT, IndexPosition INTEGER NOT NULL, + RowPosition INTEGER NOT NULL, Name TEXT, URL TEXT, IconUrl TEXT); diff --git a/Gridly-Client/src/app/components/grid/grid.component.css b/Gridly-Client/src/app/components/grid/grid.component.css index 093913b8..4d9d5e17 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.css +++ b/Gridly-Client/src/app/components/grid/grid.component.css @@ -1,18 +1,15 @@ .grid-layout{ - padding: 1em; - margin-top: 3em; + padding: 2em; + margin-top: 1em; display: flex; flex-direction: column; - gap: 16px; - min-height: 100%; + gap: 32px; } .grid-row { display: flex; flex-wrap: nowrap; - gap: 16px; - align-items: flex-start; - min-height: 250px; + gap: 32px; } .grid-row-empty { diff --git a/Gridly-Client/src/app/components/grid/grid.component.html b/Gridly-Client/src/app/components/grid/grid.component.html index 66612fb3..c06cfe8c 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.html +++ b/Gridly-Client/src/app/components/grid/grid.component.html @@ -15,7 +15,6 @@ class="shadow-sm grid-card-style" [style.height.px]="card.settings?.height" [style.width.px]="card.settings?.width" - [style.flex]="'0 0 ' + (card.settings?.width ?? 250) + 'px'" cdkDrag [cdkDragData]="card" [cdkDragDisabled]="!editActive()" diff --git a/Gridly-Client/src/app/directives/resizable.directive.ts b/Gridly-Client/src/app/directives/resizable.directive.ts index df0c0c59..56a065fb 100644 --- a/Gridly-Client/src/app/directives/resizable.directive.ts +++ b/Gridly-Client/src/app/directives/resizable.directive.ts @@ -85,8 +85,6 @@ export class ResizableDirective { titleHidden: this.targetCard.settings?.titleHidden ?? false }; - this.#cardService.update(this.targetCard, this.GetGridWidth()); - this.renderer.setStyle(this.cardElement, 'height', adjustedHeight + 'px'); this.renderer.setStyle(this.cardElement, 'width', adjustedWidth + 'px'); this.renderer.setStyle(this.cardElement, 'flex', '0 0 ' + adjustedWidth + 'px'); @@ -157,8 +155,4 @@ export class ResizableDirective { if (value <= 800) return 700; return 800; } - - private GetGridWidth(): number { - return this.cardElement?.closest('.grid-layout')?.clientWidth ?? 0; - } } diff --git a/Models/CardModel.cs b/Models/CardModel.cs index ad5ddcab..80fc953f 100644 --- a/Models/CardModel.cs +++ b/Models/CardModel.cs @@ -6,6 +6,7 @@ public class CardModel { [JsonPropertyName("id")] public int Id { get; set; } [JsonPropertyName("indexPosition")] public int? IndexPosition { get; set; } + [JsonPropertyName("rowPosition")] public int? RowPosition { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("url")] public string? Url { get; set; } [JsonPropertyName("iconData")] public IconModel? IconData { get; set; } From 46a8caff3a8ae5f6be190bbb3ddc121b4261fb22 Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Tue, 23 Jun 2026 11:23:00 +0200 Subject: [PATCH 04/11] fix lint fail, clean up grid.component.* --- .../app/components/grid/grid.component.css | 2 +- .../src/app/components/grid/grid.component.ts | 17 ++------------- .../src/app/directives/resizable.directive.ts | 2 -- .../card_services/card.service.spec.ts | 21 ------------------- 4 files changed, 3 insertions(+), 39 deletions(-) diff --git a/Gridly-Client/src/app/components/grid/grid.component.css b/Gridly-Client/src/app/components/grid/grid.component.css index 4d9d5e17..a380404a 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.css +++ b/Gridly-Client/src/app/components/grid/grid.component.css @@ -13,7 +13,7 @@ } .grid-row-empty { - min-height: 48px; + min-height: 120px; } .grid-card-style { diff --git a/Gridly-Client/src/app/components/grid/grid.component.ts b/Gridly-Client/src/app/components/grid/grid.component.ts index 2cfdd09d..c2fd1f14 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, ElementRef, HostListener, ViewChild, inject } from '@angular/core'; +import { Component, ElementRef, HostListener, ViewChild, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CardService } from "../../services/card_services/card.service"; import { CdkDrag, CdkDragDrop, CdkDropList } from "@angular/cdk/drag-drop"; @@ -14,7 +14,7 @@ import { BehaviorSubject, combineLatest, map } from 'rxjs'; styleUrls: ['./grid.component.css'], }) -export class GridComponent implements AfterViewInit { +export class GridComponent { #cardService = inject(CardService); #gridService = inject(GridService); @@ -27,15 +27,6 @@ export class GridComponent implements AfterViewInit { ); protected readonly editActive = this.#gridService.inEditMode; - ngAfterViewInit(): void { - queueMicrotask(() => this.updateGridWidth()); - } - - @HostListener('window:resize') - OnResize(): void { - this.updateGridWidth(); - } - protected Drop(event: CdkDragDrop, rows: CardModel[][], rowIndex: number): void { if (!this.editActive()) return; @@ -64,10 +55,6 @@ export class GridComponent implements AfterViewInit { return [...rows, []].map((_, rowIndex) => this.RowId(rowIndex)); } - private updateGridWidth(): void { - this.gridWidthSubject.next(this.gridLayout?.nativeElement.clientWidth ?? 0); - } - private getRowIndex(rowId: string): number { return Number(rowId.replace('card-row-', '')); } diff --git a/Gridly-Client/src/app/directives/resizable.directive.ts b/Gridly-Client/src/app/directives/resizable.directive.ts index 56a065fb..56d8fe32 100644 --- a/Gridly-Client/src/app/directives/resizable.directive.ts +++ b/Gridly-Client/src/app/directives/resizable.directive.ts @@ -1,6 +1,5 @@ import { Directive, ElementRef, HostListener, inject, Input, Renderer2 } from '@angular/core'; import { CardModel } from '../models/card.Model'; -import { CardService } from '../services/card_services/card.service'; @Directive({ standalone: true, @@ -10,7 +9,6 @@ import { CardService } from '../services/card_services/card.service'; export class ResizableDirective { private el = inject(ElementRef); private renderer = inject(Renderer2); - #cardService = inject(CardService); @Input() canResize!: boolean; @Input({ required: true }) targetCard!: CardModel; diff --git a/Gridly-Client/src/app/services/card_services/card.service.spec.ts b/Gridly-Client/src/app/services/card_services/card.service.spec.ts index cec84a84..fffffb30 100644 --- a/Gridly-Client/src/app/services/card_services/card.service.spec.ts +++ b/Gridly-Client/src/app/services/card_services/card.service.spec.ts @@ -78,27 +78,6 @@ describe('CardService', () => { expect(endpointMock.get).toHaveBeenCalledTimes(4); }); - it('stores resized card settings before batch saving', async () => { - endpointMock.batchEdit.mockReturnValue(of([cardA, cardB])); - - const resizedCard: CardModel = { - ...cardA, - settings: { - ...cardA.settings!, - width: 500, - height: 300, - }, - }; - - service.update(resizedCard); - await service.batchEdit(service.currentCards()); - - expect(endpointMock.batchEdit).toHaveBeenCalledWith([ - { ...resizedCard, indexPosition: 1, rowPosition: 1 }, - { ...cardB, indexPosition: 1, rowPosition: 2 }, - ]); - }); - it('groups cards into rows when a row exceeds the max width', () => { const rows = service.toRows([cardA, cardB], 400); From 04ac55e97948e24484570e8d8c0c24ce4be8fd45 Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Tue, 23 Jun 2026 11:25:53 +0200 Subject: [PATCH 05/11] remove unused import --- Gridly-Client/src/app/components/grid/grid.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gridly-Client/src/app/components/grid/grid.component.ts b/Gridly-Client/src/app/components/grid/grid.component.ts index c2fd1f14..f2c5b3fe 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, HostListener, ViewChild, inject } from '@angular/core'; +import { Component, ElementRef, ViewChild, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CardService } from "../../services/card_services/card.service"; import { CdkDrag, CdkDragDrop, CdkDropList } from "@angular/cdk/drag-drop"; From 202a2d9deb9ca46bb5e7f2f8db10c1729f771aea Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Tue, 23 Jun 2026 11:53:00 +0200 Subject: [PATCH 06/11] clean up card service --- .../services/card_services/card.service.ts | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/Gridly-Client/src/app/services/card_services/card.service.ts b/Gridly-Client/src/app/services/card_services/card.service.ts index e0da4348..e90eb8d1 100644 --- a/Gridly-Client/src/app/services/card_services/card.service.ts +++ b/Gridly-Client/src/app/services/card_services/card.service.ts @@ -8,8 +8,6 @@ import { CardEndpointService } from '../endpoint_services/card.endpoint.service' @Injectable({ providedIn: 'root' }) export class CardService { #api = inject(CardEndpointService); - private readonly cardGap = 16; - private readonly defaultCardWidth = 250; private readonly cardsSubject = new BehaviorSubject([]); @@ -36,24 +34,6 @@ export class CardService { add = (card: CardModel) => firstValueFrom(this.add$(card)).then(() => this.refresh()); delete = (id: number) => firstValueFrom(this.delete$(id)).then(() => this.refresh()); - update = (card: CardModel, maxRowWidth = 0): void => { - const updatedCards = this.currentCards().map((currentCard) => { - if (currentCard.id !== card.id) return currentCard; - - return { - ...currentCard, - ...card, - settings: { - width: card.settings?.width ?? currentCard.settings?.width ?? 250, - height: card.settings?.height ?? currentCard.settings?.height ?? 250, - imageHidden: card.settings?.imageHidden ?? currentCard.settings?.imageHidden ?? false, - titleHidden: card.settings?.titleHidden ?? currentCard.settings?.titleHidden ?? false, - }, - }; - }); - this.cardsSubject.next(this.flattenRows(this.toRows(updatedCards, maxRowWidth))); - }; - setRows(rows: CardModel[][], maxRowWidth = 0): void { this.cardsSubject.next(this.flattenRows(this.normalizeRows(rows, maxRowWidth))); } @@ -113,12 +93,11 @@ export class CardService { } private getRowWidth(row: CardModel[]): number { - const cardsWidth = row.reduce( - (width, card) => width + (card.settings?.width ?? this.defaultCardWidth), - 0, + const cardsWidth = row.reduce((width, card) => + width + card.settings!.width, 0, ); - return cardsWidth + Math.max(row.length - 1, 0) * this.cardGap; + return cardsWidth + Math.max(row.length - 1, 0); } refresh(): void { From 8161c30482ee3b4e31619b4d8cb27c5a2fedcafa Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Wed, 24 Jun 2026 22:39:07 +0200 Subject: [PATCH 07/11] clean up and make code more easier to read. --- .../app/services/card_services/card.service.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/Gridly-Client/src/app/services/card_services/card.service.ts b/Gridly-Client/src/app/services/card_services/card.service.ts index e90eb8d1..c0a31418 100644 --- a/Gridly-Client/src/app/services/card_services/card.service.ts +++ b/Gridly-Client/src/app/services/card_services/card.service.ts @@ -35,19 +35,10 @@ export class CardService { delete = (id: number) => firstValueFrom(this.delete$(id)).then(() => this.refresh()); setRows(rows: CardModel[][], maxRowWidth = 0): void { - this.cardsSubject.next(this.flattenRows(this.normalizeRows(rows, maxRowWidth))); + this.cardsSubject.next(this.normalizeRows(rows, maxRowWidth).flatMap((row) => row)); } toRows(cards: CardModel[], maxRowWidth = 0): CardModel[][] { - const hasRowData = cards.some((card) => card.rowPosition !== undefined) || - new Set(cards.map((card) => card.indexPosition)).size !== cards.length; - - if (!hasRowData) { - return this.normalizeRows([ - [...cards].sort((a, b) => a.indexPosition - b.indexPosition), - ], maxRowWidth); - } - const rows = new Map(); cards.forEach((card) => { const rowIndex = Math.max((card.indexPosition ?? 1) - 1, 0); @@ -88,10 +79,6 @@ export class CardService { ); } - private flattenRows(rows: CardModel[][]): CardModel[] { - return rows.flatMap((row) => row); - } - private getRowWidth(row: CardModel[]): number { const cardsWidth = row.reduce((width, card) => width + card.settings!.width, 0, From 10d998d67a55458dcccd71032ca60505838f2c6b Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Thu, 25 Jun 2026 23:35:04 +0200 Subject: [PATCH 08/11] changes to sorting on cliend, added backend function. --- Constants/QueryStrings.cs | 3 +- Dtos/CardDtoModel.cs | 1 + Factories/CardFactory.cs | 1 + .../components/grid/grid.component.spec.ts | 37 ++++++++-- .../src/app/components/grid/grid.component.ts | 15 +++- .../card_services/card.service.spec.ts | 24 +++++-- .../services/card_services/card.service.ts | 72 ++++++++++++------- Handlers/CardHandler.cs | 4 +- Helpers/ComponentHandlerHelper.cs | 13 +++- Repositories/CardRepository.cs | 1 + Tests/Factories/CardFactoryTests.cs | 2 + 11 files changed, 132 insertions(+), 41 deletions(-) diff --git a/Constants/QueryStrings.cs b/Constants/QueryStrings.cs index e32563f3..e7932e61 100644 --- a/Constants/QueryStrings.cs +++ b/Constants/QueryStrings.cs @@ -69,7 +69,8 @@ UPDATE Card public const string UpdateBatchCardQuery = @" UPDATE Card - SET IndexPosition = @IndexPosition, RowPosition = @RowPosition + SET IndexPosition = @IndexPosition, + RowPosition = @RowPosition WHERE Id = @Id; UPDATE Settings diff --git a/Dtos/CardDtoModel.cs b/Dtos/CardDtoModel.cs index ff00a487..4f49e764 100644 --- a/Dtos/CardDtoModel.cs +++ b/Dtos/CardDtoModel.cs @@ -4,6 +4,7 @@ public class CardDtoModel { public int CardId { get; set; } public int IndexPosition { get; set; } + public int RowPosition { get; set; } public string CardName { get; set; } public string Url { get; set; } public string IconUrl { get; set; } diff --git a/Factories/CardFactory.cs b/Factories/CardFactory.cs index 4dbe3db5..4008b760 100644 --- a/Factories/CardFactory.cs +++ b/Factories/CardFactory.cs @@ -10,6 +10,7 @@ public static CardModel Create(CardDtoModel dto) { Id = dto.CardId, IndexPosition = dto. IndexPosition, + RowPosition = dto. RowPosition, Name = dto.CardName, Url = dto.Url, IconUrl = dto.IconUrl, diff --git a/Gridly-Client/src/app/components/grid/grid.component.spec.ts b/Gridly-Client/src/app/components/grid/grid.component.spec.ts index ba68a9d0..0e4e1f68 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.spec.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.spec.ts @@ -36,8 +36,8 @@ describe('GridComponent', () => { beforeEach(async () => { cards = [ - { id: 1, indexPosition: 1, name: 'One', url: 'https://one.example' }, - { id: 2, indexPosition: 2, name: 'Two', url: 'https://two.example' }, + { id: 1, indexPosition: 0, rowPosition: 1, name: 'One', url: 'https://one.example' }, + { id: 2, indexPosition: 1, rowPosition: 1, name: 'Two', url: 'https://two.example' }, ]; cardsSubject = new BehaviorSubject(cards); cardServiceMock.cards$ = cardsSubject.asObservable(); @@ -80,7 +80,12 @@ describe('GridComponent', () => { (gridComponent as GridComponentTestHarness).Drop(event, [cards], 0); - expect(cardServiceMock.setRows).toHaveBeenCalledWith([[cards[1], cards[0]]], 0); + expect(cardServiceMock.setRows).toHaveBeenCalledWith([ + [ + { ...cards[1], indexPosition: 0, rowPosition: 1 }, + { ...cards[0], indexPosition: 1, rowPosition: 1 }, + ], + ], 0); }); it('does not reorder card when edit mode is disabled', () => { @@ -108,7 +113,31 @@ describe('GridComponent', () => { (gridComponent as GridComponentTestHarness).Drop(event, [cards], 1); - expect(cardServiceMock.setRows).toHaveBeenCalledWith([[cards[0]], [cards[1]]], 0); + expect(cardServiceMock.setRows).toHaveBeenCalledWith([ + [{ ...cards[0], indexPosition: 0, rowPosition: 1 }], + [{ ...cards[1], indexPosition: 0, rowPosition: 2 }], + ], 0); + }); + + it('updates indexes when a card moves into an existing row', () => { + const thirdCard = { id: 3, indexPosition: 0, rowPosition: 2, name: 'Three', url: 'https://three.example' }; + const rows = [[cards[0], cards[1]], [thirdCard]]; + const event = { + previousContainer: { id: 'card-row-0' }, + previousIndex: 1, + currentIndex: 1, + item: { data: cards[1] }, + } as never; + + (gridComponent as GridComponentTestHarness).Drop(event, rows, 1); + + expect(cardServiceMock.setRows).toHaveBeenCalledWith([ + [{ ...cards[0], indexPosition: 0, rowPosition: 1 }], + [ + { ...thirdCard, indexPosition: 0, rowPosition: 2 }, + { ...cards[1], indexPosition: 1, rowPosition: 2 }, + ], + ], 0); }); it('keeps the card DOM element stable when a resized card object is emitted', () => { diff --git a/Gridly-Client/src/app/components/grid/grid.component.ts b/Gridly-Client/src/app/components/grid/grid.component.ts index f2c5b3fe..e69a12d0 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.ts @@ -44,7 +44,20 @@ export class GridComponent { updatedRows[rowIndex].splice(event.currentIndex, 0, movedCard); } - this.#cardService.setRows(updatedRows, this.gridWidthSubject.value); + const positionedRows = this.updateCardPositions(updatedRows); + this.#cardService.setRows(positionedRows, this.gridWidthSubject.value); + } + + private updateCardPositions(rows: CardModel[][]): CardModel[][] { + return rows + .filter((row) => row.length > 0) + .map((row, rowIndex) => + row.map((card, indexPosition) => ({ + ...card, + indexPosition, + rowPosition: rowIndex + 1, + })), + ); } protected RowId(rowIndex: number): string { diff --git a/Gridly-Client/src/app/services/card_services/card.service.spec.ts b/Gridly-Client/src/app/services/card_services/card.service.spec.ts index fffffb30..88585e6f 100644 --- a/Gridly-Client/src/app/services/card_services/card.service.spec.ts +++ b/Gridly-Client/src/app/services/card_services/card.service.spec.ts @@ -10,6 +10,7 @@ describe('CardService', () => { const cardA: CardModel = { id: 1, indexPosition: 1, + rowPosition: 1, name: 'Alpha', url: 'https://alpha.example', iconData: { name: 'dashboard', type: 'svg', base64Data: 'abc', materialIcon: 'dashboard' }, @@ -18,6 +19,7 @@ describe('CardService', () => { const cardB: CardModel = { id: 2, indexPosition: 2, + rowPosition: 1, name: 'Beta', url: 'https://beta.example', iconUrl: 'https://cdn.example/icon.png', @@ -83,8 +85,18 @@ describe('CardService', () => { expect(rows.map((row) => row.map((card) => card.id))).toEqual([[1], [2]]); expect(rows.flat()).toEqual([ - { ...cardA, indexPosition: 1, rowPosition: 1 }, - { ...cardB, indexPosition: 2, rowPosition: 1 }, + { ...cardA, indexPosition: 0, rowPosition: 1 }, + { ...cardB, indexPosition: 0, rowPosition: 2 }, + ]); + }); + + it('keeps cards from the same API row horizontal', () => { + const rows = service.toRows([cardB, cardA], 1000); + + expect(rows.map((row) => row.map((card) => card.id))).toEqual([[1, 2]]); + expect(rows.flat()).toEqual([ + { ...cardA, indexPosition: 0, rowPosition: 1 }, + { ...cardB, indexPosition: 1, rowPosition: 1 }, ]); }); @@ -92,8 +104,8 @@ describe('CardService', () => { service.setRows([[cardA], [cardB]], 1000); expect(service.currentCards()).toEqual([ - { ...cardA, indexPosition: 1, rowPosition: 1 }, - { ...cardB, indexPosition: 2, rowPosition: 1 }, + { ...cardA, indexPosition: 0, rowPosition: 1 }, + { ...cardB, indexPosition: 0, rowPosition: 2 }, ]); }); @@ -101,8 +113,8 @@ describe('CardService', () => { service.setRows([[], [cardB, cardA]], 1000); expect(service.currentCards()).toEqual([ - { ...cardB, indexPosition: 1, rowPosition: 1 }, - { ...cardA, indexPosition: 1, rowPosition: 2 }, + { ...cardB, indexPosition: 0, rowPosition: 1 }, + { ...cardA, indexPosition: 1, rowPosition: 1 }, ]); }); }); diff --git a/Gridly-Client/src/app/services/card_services/card.service.ts b/Gridly-Client/src/app/services/card_services/card.service.ts index c0a31418..bf8f47e0 100644 --- a/Gridly-Client/src/app/services/card_services/card.service.ts +++ b/Gridly-Client/src/app/services/card_services/card.service.ts @@ -35,50 +35,72 @@ export class CardService { delete = (id: number) => firstValueFrom(this.delete$(id)).then(() => this.refresh()); setRows(rows: CardModel[][], maxRowWidth = 0): void { - this.cardsSubject.next(this.normalizeRows(rows, maxRowWidth).flatMap((row) => row)); + const normalizedRows = this.normalizeRows(rows, maxRowWidth); + this.cardsSubject.next(normalizedRows.flat()); } toRows(cards: CardModel[], maxRowWidth = 0): CardModel[][] { + const rows = this.groupCardsByRow(cards); + const sortedRows = this.getSortedRows(rows); + + return this.normalizeRows(sortedRows, maxRowWidth); + } + + private groupCardsByRow(cards: CardModel[]): Map { const rows = new Map(); - cards.forEach((card) => { - const rowIndex = Math.max((card.indexPosition ?? 1) - 1, 0); - rows.set(rowIndex, [...(rows.get(rowIndex) ?? []), card]); - }); - - return this.normalizeRows( - [...rows.entries()] - .sort(([first], [second]) => first - second) - .map(([, row]) => [...row].sort((a, b) => (a.rowPosition ?? 0) - (b.rowPosition ?? 0))), - maxRowWidth, - ); + + for (const card of cards) { + const rowIndex = Math.max((card.rowPosition ?? 1) - 1, 0); + const row = rows.get(rowIndex) ?? []; + row.push(card); + rows.set(rowIndex, row); + } + + return rows; + } + + private getSortedRows(rows: Map): CardModel[][] { + return [...rows.entries()] + .sort(([first], [second]) => first - second) + .map(([, row]) => [...row].sort((a, b) => (a.indexPosition ?? 0) - (b.indexPosition ?? 0))); } private normalizeRows(rows: CardModel[][], maxRowWidth: number): CardModel[][] { const normalizedRows = rows.map((row) => [...row]); if (maxRowWidth > 0) { - for (let rowIndex = 0; rowIndex < normalizedRows.length; rowIndex++) { - const row = normalizedRows[rowIndex]; - while (row.length > 1 && this.getRowWidth(row) > maxRowWidth) { - const overflowCard = row.pop(); - if (!overflowCard) break; - normalizedRows[rowIndex + 1] = normalizedRows[rowIndex + 1] ?? []; - normalizedRows[rowIndex + 1].unshift(overflowCard); - } - } + this.moveOverflowCards(normalizedRows, maxRowWidth); } - return normalizedRows + return this.updateCardPositions(normalizedRows); + } + + private updateCardPositions(rows: CardModel[][]): CardModel[][] { + return rows .filter((row) => row.length > 0) .map((row, rowIndex) => - row.map((card, rowPosition) => ({ + row.map((card, indexPosition) => ({ ...card, - indexPosition: rowIndex + 1, - rowPosition: rowPosition + 1, + indexPosition, + rowPosition: rowIndex + 1, })), ); } + private moveOverflowCards(rows: CardModel[][], maxRowWidth: number): void { + for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) { + const row = rows[rowIndex]; + + while (row.length > 1 && this.getRowWidth(row) > maxRowWidth) { + const overflowCard = row.pop(); + if (!overflowCard) break; + + rows[rowIndex + 1] = rows[rowIndex + 1] ?? []; + rows[rowIndex + 1].unshift(overflowCard); + } + } + } + private getRowWidth(row: CardModel[]): number { const cardsWidth = row.reduce((width, card) => width + card.settings!.width, 0, diff --git a/Handlers/CardHandler.cs b/Handlers/CardHandler.cs index c050e113..7f713a38 100644 --- a/Handlers/CardHandler.cs +++ b/Handlers/CardHandler.cs @@ -220,14 +220,14 @@ public async Task Handle(BatchEditCardCommand commands, CancellationTok var sortedCards = handlerHelper.SetIndexValues(commands); if(sortedCards == null) return Results.StatusCode(500); - return await cardRepository.BatchEdit(sortedCards) ? + return await cardRepository.BatchEdit(sortedCards.OrderBy(c => c.RowPosition).ThenBy(x => x.IndexPosition)) ? Results.Ok() : Results.StatusCode(500); } public async Task Handle(GetAllCardCommand command, CancellationToken cancellationToken) { var cards = await cardRepository.Get(); - return Results.Ok(cards.OrderBy(c => c.IndexPosition)); + return Results.Ok(cards.OrderBy(c => c.RowPosition).ThenBy(x => x.IndexPosition)); } public async Task Handle(GetCardByIdCommands command, CancellationToken cancellationToken) => diff --git a/Helpers/ComponentHandlerHelper.cs b/Helpers/ComponentHandlerHelper.cs index 52624aa2..54002d01 100644 --- a/Helpers/ComponentHandlerHelper.cs +++ b/Helpers/ComponentHandlerHelper.cs @@ -19,8 +19,17 @@ public bool DeleteIcon(CardModel Card) => public IEnumerable SetIndexValues(List cards) { - for (int i = 0; i < cards.Count(); i++) - cards[i].IndexPosition = i +1; + var rows = cards.GroupBy(card => card.RowPosition) + .Select(group => group.ToList()) + .ToList(); + + foreach (var row in rows) + { + for (int i = 0; i < row.Count; i++) + { + row[i].IndexPosition = i +1; + } + } return cards; } diff --git a/Repositories/CardRepository.cs b/Repositories/CardRepository.cs index a3a8fe1c..590f936e 100644 --- a/Repositories/CardRepository.cs +++ b/Repositories/CardRepository.cs @@ -36,6 +36,7 @@ public async Task BatchEdit(IEnumerable? cards) { c.Id, c.IndexPosition, + c.RowPosition, Width = c.Settings?.Width ?? 250, Height = c.Settings?.Height ?? 250, TitleHidden = c.Settings?.TitleHidden ?? false, diff --git a/Tests/Factories/CardFactoryTests.cs b/Tests/Factories/CardFactoryTests.cs index 37938178..f1ca79d1 100644 --- a/Tests/Factories/CardFactoryTests.cs +++ b/Tests/Factories/CardFactoryTests.cs @@ -12,6 +12,7 @@ public void Create_MapsDtoFieldsToCardModel() { CardId = 12, IndexPosition = 3, + RowPosition = 1, CardName = "Docs", Url = "https://example.test", IconUrl = "/icons/docs.svg" @@ -21,6 +22,7 @@ public void Create_MapsDtoFieldsToCardModel() Assert.Equal(dto.CardId, result.Id); Assert.Equal(dto.IndexPosition, result.IndexPosition); + Assert.Equal(dto.RowPosition, result.RowPosition); Assert.Equal(dto.CardName, result.Name); Assert.Equal(dto.Url, result.Url); Assert.Equal(dto.IconUrl, result.IconUrl); From 7d561b65253817d7080a6694ee2cbc5cbaa9517d Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Sun, 28 Jun 2026 12:22:28 +0200 Subject: [PATCH 09/11] Added new grid endpoint with mediatR and CQRS pattern. Rewrited the client logic when moving cards, added new database table, added new properties to connect cards to a row, added new controller for rows, added new factory class for row, added new model for row, added new url string and query strings for row, fix lint warnings, remove obsolete tests --- Command/GetAllRowColumnsCommands.cs | 5 ++ Command/SaveColumnRowCommands.cs | 6 ++ Constants/QueryStrings.cs | 22 ++++- Controllers/RowController.cs | 19 +++++ Data/DbInitializer.cs | 10 ++- Dtos/CardDtoModel.cs | 2 +- Dtos/ColumnRowDtoModel.cs | 11 +++ .../app/components/grid/grid.component.html | 27 +++--- .../components/grid/grid.component.spec.ts | 61 -------------- .../src/app/components/grid/grid.component.ts | 82 +++++++++++-------- .../src/app/constants/url.strings.util.ts | 4 + .../src/app/models/rowColumn.Model.ts | 8 ++ .../rowColumn.endpoint.service.ts | 20 +++++ .../services/grid_services/grid.service.ts | 33 +++++++- Handlers/CardHandler.cs | 57 +++++++++++-- Handlers/ColumnRowHandler.cs | 49 +++++++++++ .../Factories}/CardFactory.cs | 2 +- Handlers/Factories/ColumnRowFactory.cs | 18 ++++ .../Factories}/IconConnectedFactory.cs | 1 - .../Factories}/IconFactory.cs | 3 +- .../Factories}/SettingsFactory.cs | 1 - Helpers/ComponentHandlerHelper.cs | 2 +- Models/CardModel.cs | 2 +- Models/ColumnRowModel.cs | 9 ++ Program.cs | 1 + Repositories/CardRepository.cs | 2 +- Repositories/ColumnRowRepository.cs | 37 +++++++++ Repositories/IColumnRowRepository.cs | 9 ++ Tests/Factories/CardFactoryTests.cs | 4 +- 29 files changed, 370 insertions(+), 137 deletions(-) create mode 100644 Command/GetAllRowColumnsCommands.cs create mode 100644 Command/SaveColumnRowCommands.cs create mode 100644 Controllers/RowController.cs create mode 100644 Dtos/ColumnRowDtoModel.cs create mode 100644 Gridly-Client/src/app/models/rowColumn.Model.ts create mode 100644 Gridly-Client/src/app/services/endpoint_services/rowColumn.endpoint.service.ts create mode 100644 Handlers/ColumnRowHandler.cs rename {Factories => Handlers/Factories}/CardFactory.cs (96%) create mode 100644 Handlers/Factories/ColumnRowFactory.cs rename {Factories => Handlers/Factories}/IconConnectedFactory.cs (93%) rename {Factories => Handlers/Factories}/IconFactory.cs (90%) rename {Factories => Handlers/Factories}/SettingsFactory.cs (95%) create mode 100644 Models/ColumnRowModel.cs create mode 100644 Repositories/ColumnRowRepository.cs create mode 100644 Repositories/IColumnRowRepository.cs diff --git a/Command/GetAllRowColumnsCommands.cs b/Command/GetAllRowColumnsCommands.cs new file mode 100644 index 00000000..91575520 --- /dev/null +++ b/Command/GetAllRowColumnsCommands.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Gridly.Command; + +public class GetAllRowColumnsCommands : IRequest {} \ No newline at end of file diff --git a/Command/SaveColumnRowCommands.cs b/Command/SaveColumnRowCommands.cs new file mode 100644 index 00000000..add2dfbc --- /dev/null +++ b/Command/SaveColumnRowCommands.cs @@ -0,0 +1,6 @@ +using Gridly.Models; +using MediatR; + +namespace Gridly.Command; + +public class SaveColumnRowCommands : ColumnRowModel, IRequest { } \ No newline at end of file diff --git a/Constants/QueryStrings.cs b/Constants/QueryStrings.cs index e7932e61..2b144f5c 100644 --- a/Constants/QueryStrings.cs +++ b/Constants/QueryStrings.cs @@ -2,9 +2,14 @@ namespace Gridly.Constants; public class QueryStrings { + public const string InsertToRowQuery = @" + INSERT INTO RowColumn (RowPosition, RowWidth) + VALUES (@RowPosition, @RowWidth); + SELECT * FROM RowColumn WHERE Id = last_insert_rowid();"; + public const string InsertToCardQuery = @" - INSERT INTO Card (IndexPosition, RowPosition, Name, Url, IconUrl) - VALUES (@IndexPosition, @RowPosition, @Name, @Url, @IconUrl); + INSERT INTO Card (RowColumnId, IndexPosition, Name, Url, IconUrl) + VALUES (@RowColumnId, @IndexPosition, @Name, @Url, @IconUrl); SELECT * FROM Card WHERE Id = last_insert_rowid();"; public const string InsertToSettingsQuery = @" @@ -26,7 +31,7 @@ INSERT INTO IconsConnected (CardId, IconId) SELECT co.Id AS CardId, co.IndexPosition, - co.RowPosition, + co.RowColumnId, co.Name AS CardName, co.Url, co.IconUrl, @@ -42,6 +47,10 @@ INSERT INTO IconsConnected (CardId, IconId) i.MaterialIcon AS MaterialIcon FROM Card co /**leftjoin**//**where**//**orderby**/"; + public const string SelectRowQuery = @" + SELECT r.Id AS Id, r.RowPosition AS RowPosition, r.RowWidth AS RowWidth + FROM RowColumn r /**leftjoin**//**where**//**orderby**/"; + public const string SelectIconQuery = @" SELECT i.Id, i.Name, i.Type, i.Base64Data, i.MaterialIcon FROM Icon i /**leftjoin**//**where**/"; @@ -57,6 +66,12 @@ UPDATE Icon Base64Data = @Base64Data, MaterialIcon = @MaterialIcon /**where**/"; + + public const string UpdateRowQuery = @" + UPDATE RowColumn + SET RowPosition = @RowPosition, + RowWidth = @RowWidth + /**where**/"; public const string UpdateCardQuery = @" UPDATE Card @@ -103,5 +118,6 @@ UPDATE Settings public const string WhereCardIdEqualsCardIdWithAlias = "co.Id = @cardId"; public const string WhereIconNameEqualsNameWithAlias = "i.Name = @Name"; public const string WhereIconTypeEqualsTypeWithAlias = "i.Type = @Type"; + public const string RowPositionWithAlias = "r.RowPosition;"; public const string IndexPositionWithAlias = "co.IndexPosition;"; } diff --git a/Controllers/RowController.cs b/Controllers/RowController.cs new file mode 100644 index 00000000..e1950eec --- /dev/null +++ b/Controllers/RowController.cs @@ -0,0 +1,19 @@ +using Gridly.Command; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace Gridly.Controllers; + +[ApiController] +[Route("/api/[controller]")] +public class RowController(IMediator meditor) : ControllerBase +{ + [HttpPost("save")] + public async Task Save([FromBody] SaveColumnRowCommands commands) => + await meditor.Send(commands) is null ? + Results.BadRequest() : Results.Ok(); + + [HttpGet("get")] + public async Task Get() => + await meditor.Send(new GetAllRowColumnsCommands()); +} \ No newline at end of file diff --git a/Data/DbInitializer.cs b/Data/DbInitializer.cs index 71932022..5cececa4 100644 --- a/Data/DbInitializer.cs +++ b/Data/DbInitializer.cs @@ -16,13 +16,19 @@ public async Task EnsureTablesCreatedAsync() { await connection.ExecuteAsync( sql:@" + CREATE TABLE IF NOT EXISTS RowColumn( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + RowPosition INTEGER NOT NULL, + RowWidth INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS Card( Id INTEGER PRIMARY KEY AUTOINCREMENT, IndexPosition INTEGER NOT NULL, - RowPosition INTEGER NOT NULL, + RowColumnId INTEGER NOT NULL, Name TEXT, URL TEXT, - IconUrl TEXT); + IconUrl TEXT, + FOREIGN KEY(RowColumnId) REFERENCES RowColumn(Id) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS IconsConnected( Id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/Dtos/CardDtoModel.cs b/Dtos/CardDtoModel.cs index 4f49e764..ce7b3c8e 100644 --- a/Dtos/CardDtoModel.cs +++ b/Dtos/CardDtoModel.cs @@ -4,7 +4,7 @@ public class CardDtoModel { public int CardId { get; set; } public int IndexPosition { get; set; } - public int RowPosition { get; set; } + public int RowColumnId { get; set; } public string CardName { get; set; } public string Url { get; set; } public string IconUrl { get; set; } diff --git a/Dtos/ColumnRowDtoModel.cs b/Dtos/ColumnRowDtoModel.cs new file mode 100644 index 00000000..9422d1f2 --- /dev/null +++ b/Dtos/ColumnRowDtoModel.cs @@ -0,0 +1,11 @@ +using Gridly.Models; + +namespace Gridly.Dtos; + +public class ColumnRowDtoModel +{ + public int Id { get; set; } + public int RowPosition { get; set; } + public IEnumerable Cards { get; set; } + public int RowWidth { get; set; } +} \ No newline at end of file diff --git a/Gridly-Client/src/app/components/grid/grid.component.html b/Gridly-Client/src/app/components/grid/grid.component.html index c06cfe8c..33b2a029 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.html +++ b/Gridly-Client/src/app/components/grid/grid.component.html @@ -1,16 +1,15 @@ @if (rows$ | async; as rows) {
- @for (row of rows; track $index; let rowIndex = $index) { -
- @for (card of row; track card.id) { + @for (row of rows; track $index === row.id) { +
+ @for (card of row.cards; track card.id) {
+ (cdkDropListDropped)="Drop($event, rows, (rows.length +1))">
} diff --git a/Gridly-Client/src/app/components/grid/grid.component.spec.ts b/Gridly-Client/src/app/components/grid/grid.component.spec.ts index 0e4e1f68..910d926e 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.spec.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.spec.ts @@ -64,30 +64,6 @@ describe('GridComponent', () => { fixture.detectChanges(); }); - it('renders one card component per returned component', () => { - const items = fixture.nativeElement.querySelectorAll('app-card-component'); - - expect(items).toHaveLength(2); - }); - - it('reorders card when drag-drop happens in edit mode', () => { - const event = { - previousContainer: { id: 'card-row-0' }, - previousIndex: 0, - currentIndex: 1, - item: { data: cards[0] }, - } as never; - - (gridComponent as GridComponentTestHarness).Drop(event, [cards], 0); - - expect(cardServiceMock.setRows).toHaveBeenCalledWith([ - [ - { ...cards[1], indexPosition: 0, rowPosition: 1 }, - { ...cards[0], indexPosition: 1, rowPosition: 1 }, - ], - ], 0); - }); - it('does not reorder card when edit mode is disabled', () => { editMode.set(false); @@ -103,43 +79,6 @@ describe('GridComponent', () => { expect(cardServiceMock.setRows).not.toHaveBeenCalled(); }); - it('moves a card into a new row', () => { - const event = { - previousContainer: { id: 'card-row-0' }, - previousIndex: 1, - currentIndex: 0, - item: { data: cards[1] }, - } as never; - - (gridComponent as GridComponentTestHarness).Drop(event, [cards], 1); - - expect(cardServiceMock.setRows).toHaveBeenCalledWith([ - [{ ...cards[0], indexPosition: 0, rowPosition: 1 }], - [{ ...cards[1], indexPosition: 0, rowPosition: 2 }], - ], 0); - }); - - it('updates indexes when a card moves into an existing row', () => { - const thirdCard = { id: 3, indexPosition: 0, rowPosition: 2, name: 'Three', url: 'https://three.example' }; - const rows = [[cards[0], cards[1]], [thirdCard]]; - const event = { - previousContainer: { id: 'card-row-0' }, - previousIndex: 1, - currentIndex: 1, - item: { data: cards[1] }, - } as never; - - (gridComponent as GridComponentTestHarness).Drop(event, rows, 1); - - expect(cardServiceMock.setRows).toHaveBeenCalledWith([ - [{ ...cards[0], indexPosition: 0, rowPosition: 1 }], - [ - { ...thirdCard, indexPosition: 0, rowPosition: 2 }, - { ...cards[1], indexPosition: 1, rowPosition: 2 }, - ], - ], 0); - }); - it('keeps the card DOM element stable when a resized card object is emitted', () => { const firstCardElement = fixture.nativeElement.querySelector('.grid-card-style') as HTMLElement; diff --git a/Gridly-Client/src/app/components/grid/grid.component.ts b/Gridly-Client/src/app/components/grid/grid.component.ts index e69a12d0..5801f0b3 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.ts @@ -1,11 +1,11 @@ import { Component, ElementRef, ViewChild, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { CardService } from "../../services/card_services/card.service"; import { CdkDrag, CdkDragDrop, CdkDropList } from "@angular/cdk/drag-drop"; import { CardModel } from '../../models/card.Model'; import { GridService } from '../../services/grid_services/grid.service'; import { CardComponent } from '../card/card.component'; -import { BehaviorSubject, combineLatest, map } from 'rxjs'; +import { RowColumnModel } from "../../models/rowColumn.Model"; + @Component({ selector: 'app-grid', imports: [CommonModule, CdkDropList, CdkDrag, CardComponent], @@ -15,57 +15,67 @@ import { BehaviorSubject, combineLatest, map } from 'rxjs'; }) export class GridComponent { - #cardService = inject(CardService); #gridService = inject(GridService); @ViewChild('gridLayout') private gridLayout?: ElementRef; - private readonly gridWidthSubject = new BehaviorSubject(0); - protected readonly emptyRowData: CardModel[] = []; - protected readonly rows$ = combineLatest([this.#cardService.cards$, this.gridWidthSubject]).pipe( - map(([cards, maxRowWidth]) => this.#cardService.toRows(cards, maxRowWidth)), - ); + protected readonly rows$ = this.#gridService.rows$; protected readonly editActive = this.#gridService.inEditMode; + protected emptyRow: CardModel[] = []; - protected Drop(event: CdkDragDrop, rows: CardModel[][], rowIndex: number): void { + protected Drop(event: CdkDragDrop, rows: RowColumnModel[], newRowPosition: number): void { + //TODO needs to remove a row if a row has only one card thats remove + // Update all rows to have the correct index position if (!this.editActive()) return; - const updatedRows = rows.map((row) => [...row]); - const previousRowIndex = this.getRowIndex(event.previousContainer.id); - const movedCard = event.item.data as CardModel; - - if (previousRowIndex === rowIndex) { - const row = updatedRows[rowIndex]; - row.splice(event.previousIndex, 1); - row.splice(event.currentIndex, 0, movedCard); - } else { - updatedRows[previousRowIndex]?.splice(event.previousIndex, 1); - updatedRows[rowIndex] = updatedRows[rowIndex] ?? []; - updatedRows[rowIndex].splice(event.currentIndex, 0, movedCard); + const droppedCard = event.item.data as CardModel; + + if(rows.length >= newRowPosition) + { + const updatedRows = rows.map(row => { + const cards = [...row.cards]; + const [movedCard] = cards.splice(event.previousIndex, 1); + + cards.splice(event.currentIndex, 0, movedCard); + + return { + ...row, + cards: cards.map((card, index) => ({ + ...card, + rowPosition: newRowPosition, + indexPosition: index, + })), + }; + }); + + this.#gridService.setRowsForView(updatedRows); + return; } - const positionedRows = this.updateCardPositions(updatedRows); - this.#cardService.setRows(positionedRows, this.gridWidthSubject.value); - } + droppedCard.indexPosition = 1; + + const newRow: RowColumnModel = { + id: 0, + rowPosition: newRowPosition, + cards: [droppedCard], + }; + + const rowsWithoutCard = rows.map(row => ({...row, + cards: row.cards.filter(card => card.id !== droppedCard.id), + })); - private updateCardPositions(rows: CardModel[][]): CardModel[][] { - return rows - .filter((row) => row.length > 0) - .map((row, rowIndex) => - row.map((card, indexPosition) => ({ - ...card, - indexPosition, - rowPosition: rowIndex + 1, - })), - ); + this.#gridService.setRowsForView([...rowsWithoutCard, newRow]); } protected RowId(rowIndex: number): string { return `card-row-${rowIndex}`; } - protected RowIds(rows: CardModel[][]): string[] { - return [...rows, []].map((_, rowIndex) => this.RowId(rowIndex)); + protected RowIds(rows: RowColumnModel[]): string[] { + return [ + ...rows.map(row => this.RowId(row.rowPosition!)), + this.RowId(rows.length + 1), + ]; } private getRowIndex(rowId: string): number { diff --git a/Gridly-Client/src/app/constants/url.strings.util.ts b/Gridly-Client/src/app/constants/url.strings.util.ts index 9c36c89e..7cca2c27 100644 --- a/Gridly-Client/src/app/constants/url.strings.util.ts +++ b/Gridly-Client/src/app/constants/url.strings.util.ts @@ -10,6 +10,10 @@ export class UrlStringsUtil { static readonly CardUrlEdit = this.CardUrl+'edit'; static readonly CardsBatchUrlEdit = this.CardUrl+'batch/edit'; + static readonly RowColumnUrl = '/api/row/'; + static readonly RowColumnUrlGet = this.RowColumnUrl+'get'; + static readonly RowColumnUrlSave = this.CardUrl+'save'; + static readonly IconUrl = '/api/icon/'; static readonly IconUrlSearch = this.IconUrl+'search?value='; static readonly IconGet = this.IconUrl+'get'; diff --git a/Gridly-Client/src/app/models/rowColumn.Model.ts b/Gridly-Client/src/app/models/rowColumn.Model.ts new file mode 100644 index 00000000..75662737 --- /dev/null +++ b/Gridly-Client/src/app/models/rowColumn.Model.ts @@ -0,0 +1,8 @@ +import {CardModel} from "./card.Model"; + +export class RowColumnModel { + id!: number; + cards: CardModel[] = []; + rowPosition?: number; + rowWidth?: number; +} diff --git a/Gridly-Client/src/app/services/endpoint_services/rowColumn.endpoint.service.ts b/Gridly-Client/src/app/services/endpoint_services/rowColumn.endpoint.service.ts new file mode 100644 index 00000000..7bff240a --- /dev/null +++ b/Gridly-Client/src/app/services/endpoint_services/rowColumn.endpoint.service.ts @@ -0,0 +1,20 @@ +import { Injectable, inject } from '@angular/core'; +import {UrlStringsUtil} from "../../constants/url.strings.util"; +import {Observable, take} from "rxjs"; +import {HttpClient} from "@angular/common/http"; +import {RowColumnModel} from "../../models/rowColumn.Model"; + +@Injectable({ + providedIn: 'root' +}) + +export class RowColumnEndpointService{ + private http = inject(HttpClient); + + get(): Observable { + return this.http.get(UrlStringsUtil.RowColumnUrlGet).pipe(take(1)); + } + add(rowColumn: RowColumnModel): Observable { + return this.http.post(UrlStringsUtil.RowColumnUrlSave, rowColumn).pipe(take(1)); + } +} diff --git a/Gridly-Client/src/app/services/grid_services/grid.service.ts b/Gridly-Client/src/app/services/grid_services/grid.service.ts index 08a93f77..6f40fff7 100644 --- a/Gridly-Client/src/app/services/grid_services/grid.service.ts +++ b/Gridly-Client/src/app/services/grid_services/grid.service.ts @@ -1,10 +1,41 @@ -import { Injectable, signal } from "@angular/core"; +import {inject, Injectable, Signal, signal} from "@angular/core"; +import {BehaviorSubject, firstValueFrom, Observable, take} from "rxjs"; +import {RowColumnModel} from "../../models/rowColumn.Model"; +import {RowColumnEndpointService} from "../endpoint_services/rowColumn.endpoint.service"; +import {toSignal} from "@angular/core/rxjs-interop"; @Injectable({providedIn: 'root'}) export class GridService { + private readonly RowColumnSubject = new BehaviorSubject([]); private readonly _editMode = signal(false); + + readonly rows$: Observable; readonly inEditMode = this._editMode.asReadonly(); + readonly currentRowColumns: Signal; + + #api = inject(RowColumnEndpointService); + + constructor() { + this.rows$ = this.RowColumnSubject.asObservable(); + this.currentRowColumns = toSignal(this.rows$, { initialValue: [] as RowColumnModel[] }); + this.refresh(); + } + + private add$ = (rowColumn: RowColumnModel) => this.#api.add(rowColumn); + + add = (rowColumn: RowColumnModel) => firstValueFrom(this.add$(rowColumn)).then(() => this.refresh()); setEditMode = (value: boolean) => this._editMode.set(value); toggleEdit = () => this._editMode.update((value) => !value); + + refresh(): void { + this.#api.get().pipe(take(1)) + .subscribe((rowColumns) => + this.RowColumnSubject.next(rowColumns)); + } + + setRowsForView(rows: RowColumnModel[]): void { + this.RowColumnSubject.next(rows); + } + } diff --git a/Handlers/CardHandler.cs b/Handlers/CardHandler.cs index 7f713a38..9df3a3b5 100644 --- a/Handlers/CardHandler.cs +++ b/Handlers/CardHandler.cs @@ -9,6 +9,7 @@ namespace Gridly.Handlers; public class CardHandler( + IColumnRowRepository columnRowRepository, ICardRepository cardRepository, ISettingsRepository settingsRepository, IIconRepository iconRepository, @@ -25,13 +26,31 @@ public class CardHandler( public async Task Handle(SaveCardCommand command, CancellationToken cancellationToken) { - var storedCards = await cardRepository.Get(); + var storedRows = await columnRowRepository.Get(); + var rows = storedRows.ToList(); + var Card = new CardModel(); - if (storedCards == null || storedCards.Count() == 0 ) command.IndexPosition = 1; - else command.IndexPosition = storedCards.Count() + 1; - - var Card = await cardRepository.Insert(command); + if(rows.Any()) + { + var rowWithRoom = GetRowWithRoom(); + if (rowWithRoom != null) + { + command.RowColumnId = rowWithRoom.Id; + command.IndexPosition = rowWithRoom.Cards is null ? 1 : rowWithRoom.Cards.Count + 1; + if(command.IndexPosition == 1) + Card = await cardRepository.Insert(command); + else + await cardRepository.Edit(command); + } + else await CreateNewRow(rowPosition: rows.Count + 1); + } + if (rows.Count == 0) + { + await CreateNewRow(); + Card = await cardRepository.Insert(command); + } + command.Settings ??= SettingsFactory.Create(null); command.Settings.CardId = Card.Id; await settingsRepository.Insert(command.Settings); @@ -40,8 +59,28 @@ public async Task Handle(SaveCardCommand command, CancellationToken can Card.IconData = await iconRepository.Insert(Card.IconData); await iconConnectedRepository.Insert(IconConnectedFactory.Create(Card.Id, Card.IconData.Id)); - + return Results.Ok(); + + ColumnRowModel GetRowWithRoom() + { + foreach (var row in rows) + { + if (row.Cards is null) return row; + + foreach (var card in row.Cards) + if (row.RowWidth !> card.Settings.Width - 32) + return row; + } + return null; + } + + async Task CreateNewRow(int rowPosition = 1) + { + var row = await columnRowRepository.Insert(ColumnRowFactory.Create(new ColumnRowDtoModel { RowPosition = rowPosition, RowWidth = 0, })); + command.RowColumnId = row.Id; + command.IndexPosition = 1; + } } public async Task Handle(DeleteCardCommand command, CancellationToken cancellationToken) @@ -53,7 +92,7 @@ public async Task Handle(DeleteCardCommand command, CancellationToken c await cardRepository.Delete(command.Id) is false) return Results.StatusCode(500); - var Card = cards.FirstOrDefault(x => x.Id == command.Id); + var Card = cards.FirstOrDefault(x => x.Id == command.Id); if (Card != null && Card.IconData != null) { @@ -220,14 +259,14 @@ public async Task Handle(BatchEditCardCommand commands, CancellationTok var sortedCards = handlerHelper.SetIndexValues(commands); if(sortedCards == null) return Results.StatusCode(500); - return await cardRepository.BatchEdit(sortedCards.OrderBy(c => c.RowPosition).ThenBy(x => x.IndexPosition)) ? + return await cardRepository.BatchEdit(sortedCards.OrderBy(c => c.RowColumnId).ThenBy(x => x.IndexPosition)) ? Results.Ok() : Results.StatusCode(500); } public async Task Handle(GetAllCardCommand command, CancellationToken cancellationToken) { var cards = await cardRepository.Get(); - return Results.Ok(cards.OrderBy(c => c.RowPosition).ThenBy(x => x.IndexPosition)); + return Results.Ok(cards.OrderBy(c => c.RowColumnId).ThenBy(x => x.IndexPosition)); } public async Task Handle(GetCardByIdCommands command, CancellationToken cancellationToken) => diff --git a/Handlers/ColumnRowHandler.cs b/Handlers/ColumnRowHandler.cs new file mode 100644 index 00000000..cb57cfaa --- /dev/null +++ b/Handlers/ColumnRowHandler.cs @@ -0,0 +1,49 @@ +using Gridly.Command; +using Gridly.helpers; +using Gridly.Models; +using Gridly.Repositories; +using Gridly.Services; +using MediatR; + +namespace Gridly.Handlers; +public class ColumnRowHandler( + IColumnRowRepository columnRowRepository, + ICardRepository cardRepository): + IRequestHandler, + IRequestHandler +{ + public async Task Handle(SaveColumnRowCommands command, CancellationToken cancellationToken) + { + var rows = await columnRowRepository.Get(); + + if (rows == null) + await columnRowRepository.Insert(command); + + /*int totalCardWith = 0; + foreach (var row in rows) + { + if (row.RowPosition == command.RowPosition) + { + + } + }*/ + return Results.Ok(); + } + + public async Task Handle(GetAllRowColumnsCommands command, CancellationToken cancellationToken) + { + var rowColummns = (await columnRowRepository.Get())?.ToList(); + var cards = await cardRepository.Get(); + foreach (var row in rowColummns) + foreach (var card in cards) + if (card.RowColumnId == row.Id) + { + if (row.Cards is null) + row.Cards = new List(); + + row.Cards.Add(card); + } + + return rowColummns.Any() ? Results.Ok(rowColummns.ToList()) : Results.NoContent(); + } +} diff --git a/Factories/CardFactory.cs b/Handlers/Factories/CardFactory.cs similarity index 96% rename from Factories/CardFactory.cs rename to Handlers/Factories/CardFactory.cs index 4008b760..016945c0 100644 --- a/Factories/CardFactory.cs +++ b/Handlers/Factories/CardFactory.cs @@ -10,7 +10,7 @@ public static CardModel Create(CardDtoModel dto) { Id = dto.CardId, IndexPosition = dto. IndexPosition, - RowPosition = dto. RowPosition, + RowColumnId = dto.RowColumnId, Name = dto.CardName, Url = dto.Url, IconUrl = dto.IconUrl, diff --git a/Handlers/Factories/ColumnRowFactory.cs b/Handlers/Factories/ColumnRowFactory.cs new file mode 100644 index 00000000..764e01ec --- /dev/null +++ b/Handlers/Factories/ColumnRowFactory.cs @@ -0,0 +1,18 @@ +using Gridly.Dtos; +using Gridly.Models; + +namespace Gridly.Factories; + +public static class ColumnRowFactory +{ + public static ColumnRowModel Create(ColumnRowDtoModel dto) + => new() + { + Id = dto.Id, + RowPosition = dto. RowPosition, + RowWidth = dto.RowWidth + }; + + public static IEnumerable CreateMany(IEnumerable dtos) + => dtos.Select(Create); +} \ No newline at end of file diff --git a/Factories/IconConnectedFactory.cs b/Handlers/Factories/IconConnectedFactory.cs similarity index 93% rename from Factories/IconConnectedFactory.cs rename to Handlers/Factories/IconConnectedFactory.cs index b9d810a5..828c3b30 100644 --- a/Factories/IconConnectedFactory.cs +++ b/Handlers/Factories/IconConnectedFactory.cs @@ -1,5 +1,4 @@ using Gridly.Dtos; -using Gridly.Models; namespace Gridly.Factories { diff --git a/Factories/IconFactory.cs b/Handlers/Factories/IconFactory.cs similarity index 90% rename from Factories/IconFactory.cs rename to Handlers/Factories/IconFactory.cs index 7e8107f2..1ae4a7a3 100644 --- a/Factories/IconFactory.cs +++ b/Handlers/Factories/IconFactory.cs @@ -1,5 +1,4 @@ -using Gridly.Dtos; -using Gridly.Models; +using Gridly.Models; namespace Gridly.Factories { diff --git a/Factories/SettingsFactory.cs b/Handlers/Factories/SettingsFactory.cs similarity index 95% rename from Factories/SettingsFactory.cs rename to Handlers/Factories/SettingsFactory.cs index 012b161a..9c66153b 100644 --- a/Factories/SettingsFactory.cs +++ b/Handlers/Factories/SettingsFactory.cs @@ -1,4 +1,3 @@ -using Gridly.Dtos; using Gridly.Models; namespace Gridly.Factories diff --git a/Helpers/ComponentHandlerHelper.cs b/Helpers/ComponentHandlerHelper.cs index 54002d01..a54b71cd 100644 --- a/Helpers/ComponentHandlerHelper.cs +++ b/Helpers/ComponentHandlerHelper.cs @@ -19,7 +19,7 @@ public bool DeleteIcon(CardModel Card) => public IEnumerable SetIndexValues(List cards) { - var rows = cards.GroupBy(card => card.RowPosition) + var rows = cards.GroupBy(card => card.IndexPosition) .Select(group => group.ToList()) .ToList(); diff --git a/Models/CardModel.cs b/Models/CardModel.cs index 80fc953f..4d13c6e6 100644 --- a/Models/CardModel.cs +++ b/Models/CardModel.cs @@ -6,7 +6,7 @@ public class CardModel { [JsonPropertyName("id")] public int Id { get; set; } [JsonPropertyName("indexPosition")] public int? IndexPosition { get; set; } - [JsonPropertyName("rowPosition")] public int? RowPosition { get; set; } + [JsonPropertyName("rowColumnId")] public int? RowColumnId { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("url")] public string? Url { get; set; } [JsonPropertyName("iconData")] public IconModel? IconData { get; set; } diff --git a/Models/ColumnRowModel.cs b/Models/ColumnRowModel.cs new file mode 100644 index 00000000..c9e1053f --- /dev/null +++ b/Models/ColumnRowModel.cs @@ -0,0 +1,9 @@ +namespace Gridly.Models; + +public class ColumnRowModel +{ + public int Id { get; set; } + public int RowPosition { get; set; } + public List Cards { get; set; } + public int RowWidth { get; set; } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index 779f60f7..4027c34f 100644 --- a/Program.cs +++ b/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Repositories/CardRepository.cs b/Repositories/CardRepository.cs index 590f936e..eb15b489 100644 --- a/Repositories/CardRepository.cs +++ b/Repositories/CardRepository.cs @@ -36,7 +36,7 @@ public async Task BatchEdit(IEnumerable? cards) { c.Id, c.IndexPosition, - c.RowPosition, + c.RowColumnId, Width = c.Settings?.Width ?? 250, Height = c.Settings?.Height ?? 250, TitleHidden = c.Settings?.TitleHidden ?? false, diff --git a/Repositories/ColumnRowRepository.cs b/Repositories/ColumnRowRepository.cs new file mode 100644 index 00000000..56832642 --- /dev/null +++ b/Repositories/ColumnRowRepository.cs @@ -0,0 +1,37 @@ +using System.Data; +using Dapper; +using Gridly.Constants; +using Gridly.Data; +using Gridly.Dtos; +using Gridly.Models; + +namespace Gridly.Repositories; + +public class ColumnRowRepository(IDbConnection connection) : IColumnRowRepository +{ + private DbCommandRunner _dbCommandRunner = new (connection); + + public async Task Insert(ColumnRowModel columnRow) + { + return await _dbCommandRunner.Execute(QueryStrings.InsertToRowQuery, columnRow); + } + + public async Task?> Get() + { + var builder = new SqlBuilder(); + + var template = builder.AddTemplate(QueryStrings.SelectRowQuery); + builder.OrderBy(QueryStrings.RowPositionWithAlias); + var Dtos = + await _dbCommandRunner.SelectMany(template.RawSql, template.Parameters); + return Factories.ColumnRowFactory.CreateMany(Dtos); + } + + public async Task Edit(ColumnRowModel columnRow) + { + var builder = new SqlBuilder(); + var template = builder.AddTemplate(QueryStrings.UpdateRowQuery,columnRow); + builder.Where(QueryStrings.WhereIdEqualsId, new { Id = columnRow.Id }); + return await _dbCommandRunner.Execute(template.RawSql,template.Parameters); + } +} \ No newline at end of file diff --git a/Repositories/IColumnRowRepository.cs b/Repositories/IColumnRowRepository.cs new file mode 100644 index 00000000..ade78338 --- /dev/null +++ b/Repositories/IColumnRowRepository.cs @@ -0,0 +1,9 @@ +using Gridly.Models; + +namespace Gridly.Repositories; + +public interface IColumnRowRepository +{ + public Task Insert(ColumnRowModel columnRow); + public Task> Get(); +} \ No newline at end of file diff --git a/Tests/Factories/CardFactoryTests.cs b/Tests/Factories/CardFactoryTests.cs index f1ca79d1..1e63df91 100644 --- a/Tests/Factories/CardFactoryTests.cs +++ b/Tests/Factories/CardFactoryTests.cs @@ -12,7 +12,7 @@ public void Create_MapsDtoFieldsToCardModel() { CardId = 12, IndexPosition = 3, - RowPosition = 1, + RowColumnId = 1, CardName = "Docs", Url = "https://example.test", IconUrl = "/icons/docs.svg" @@ -22,7 +22,7 @@ public void Create_MapsDtoFieldsToCardModel() Assert.Equal(dto.CardId, result.Id); Assert.Equal(dto.IndexPosition, result.IndexPosition); - Assert.Equal(dto.RowPosition, result.RowPosition); + Assert.Equal(dto.RowColumnId, result.RowColumnId); Assert.Equal(dto.CardName, result.Name); Assert.Equal(dto.Url, result.Url); Assert.Equal(dto.IconUrl, result.IconUrl); From 3082dc62310330bde1fcebce0f475ba1b989df7b Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Sun, 28 Jun 2026 22:09:20 +0200 Subject: [PATCH 10/11] change for dotnet test --- Helpers/ComponentHandlerHelper.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/Helpers/ComponentHandlerHelper.cs b/Helpers/ComponentHandlerHelper.cs index a54b71cd..94375548 100644 --- a/Helpers/ComponentHandlerHelper.cs +++ b/Helpers/ComponentHandlerHelper.cs @@ -19,17 +19,8 @@ public bool DeleteIcon(CardModel Card) => public IEnumerable SetIndexValues(List cards) { - var rows = cards.GroupBy(card => card.IndexPosition) - .Select(group => group.ToList()) - .ToList(); - - foreach (var row in rows) - { - for (int i = 0; i < row.Count; i++) - { - row[i].IndexPosition = i +1; - } - } + for (int i = 0; i < cards.Count; i++) + cards[i].IndexPosition = i +1; return cards; } From d3ddd3b8698c3069ece6861ddf7482840900d160 Mon Sep 17 00:00:00 2001 From: Carpenteri1 Date: Mon, 29 Jun 2026 22:36:58 +0200 Subject: [PATCH 11/11] added new endpoints for rows, changes to logic for client when moving cards around, added test, changed query strings --- Command/BatchEditRowColumnCommand.cs | 6 + Constants/QueryStrings.cs | 4 +- Controllers/RowController.cs | 4 + .../components/grid/grid.component.spec.ts | 115 +++++++++++++++--- .../src/app/components/grid/grid.component.ts | 65 +++++----- .../app/components/header/header.component.ts | 2 +- .../src/app/constants/url.strings.util.ts | 3 +- .../card.endpoint.service.ts | 4 +- .../rowColumn.endpoint.service.ts | 3 + .../services/grid_services/grid.service.ts | 4 + Handlers/ColumnRowHandler.cs | 18 ++- Repositories/ColumnRowRepository.cs | 6 + Repositories/IColumnRowRepository.cs | 1 + 13 files changed, 171 insertions(+), 64 deletions(-) create mode 100644 Command/BatchEditRowColumnCommand.cs diff --git a/Command/BatchEditRowColumnCommand.cs b/Command/BatchEditRowColumnCommand.cs new file mode 100644 index 00000000..beed5697 --- /dev/null +++ b/Command/BatchEditRowColumnCommand.cs @@ -0,0 +1,6 @@ +using Gridly.Models; +using MediatR; + +namespace Gridly.Command; + +public class BatchEditRowColumnCommand : List, IRequest {} \ No newline at end of file diff --git a/Constants/QueryStrings.cs b/Constants/QueryStrings.cs index 2b144f5c..053f1a52 100644 --- a/Constants/QueryStrings.cs +++ b/Constants/QueryStrings.cs @@ -77,7 +77,7 @@ UPDATE RowColumn UPDATE Card SET Name = @Name, IndexPosition = @IndexPosition, - RowPosition = @RowPosition, + RowColumnId = @RowColumnId, Url = @Url, IconUrl = @IconUrl /**where**/"; @@ -85,7 +85,7 @@ UPDATE Card public const string UpdateBatchCardQuery = @" UPDATE Card SET IndexPosition = @IndexPosition, - RowPosition = @RowPosition + RowColumnId = @RowColumnId WHERE Id = @Id; UPDATE Settings diff --git a/Controllers/RowController.cs b/Controllers/RowController.cs index e1950eec..459694e9 100644 --- a/Controllers/RowController.cs +++ b/Controllers/RowController.cs @@ -16,4 +16,8 @@ await meditor.Send(commands) is null ? [HttpGet("get")] public async Task Get() => await meditor.Send(new GetAllRowColumnsCommands()); + + [HttpPost("batch/edit")] + public async Task Edit([FromBody] BatchEditRowColumnCommand commands) => + await meditor.Send(commands); } \ No newline at end of file diff --git a/Gridly-Client/src/app/components/grid/grid.component.spec.ts b/Gridly-Client/src/app/components/grid/grid.component.spec.ts index 910d926e..ee9883a8 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.spec.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.spec.ts @@ -1,14 +1,14 @@ import { Component, Input, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { CardModel } from '../../models/card.Model'; import { CardComponent } from '../card/card.component'; -import { CardService } from '../../services/card_services/card.service'; +import { RowColumnModel } from '../../models/rowColumn.Model'; import { GridService } from '../../services/grid_services/grid.service'; import { GridComponent } from './grid.component'; type GridComponentTestHarness = GridComponent & { - Drop(event: unknown, rows: CardModel[][], rowIndex: number): void; + Drop(event: unknown, rows: RowColumnModel[], rowIndex: number): void; }; @Component({ @@ -24,27 +24,29 @@ describe('GridComponent', () => { let fixture: ComponentFixture; let gridComponent: GridComponent; let cards: CardModel[]; - let cardsSubject: BehaviorSubject; + let rows: RowColumnModel[]; + let rowsSubject: BehaviorSubject; let editMode: ReturnType>; - const cardServiceMock = {} as { - cards$: Observable; - setRows: jest.Mock; - toRows: jest.Mock; + const gridServiceMock = {} as { + rows$: BehaviorSubject; + inEditMode: () => boolean; + setRowsForView: jest.Mock; }; - const gridServiceMock = {} as { inEditMode: () => boolean }; beforeEach(async () => { cards = [ { id: 1, indexPosition: 0, rowPosition: 1, name: 'One', url: 'https://one.example' }, { id: 2, indexPosition: 1, rowPosition: 1, name: 'Two', url: 'https://two.example' }, ]; - cardsSubject = new BehaviorSubject(cards); - cardServiceMock.cards$ = cardsSubject.asObservable(); - cardServiceMock.setRows = jest.fn(); - cardServiceMock.toRows = jest.fn((cardsToGroup: CardModel[]) => [cardsToGroup]); + rows = [ + { id: 1, rowPosition: 1, cards }, + ]; + rowsSubject = new BehaviorSubject(rows); editMode = signal(true); + gridServiceMock.rows$ = rowsSubject; gridServiceMock.inEditMode = editMode.asReadonly(); + gridServiceMock.setRowsForView = jest.fn(); TestBed.overrideComponent(GridComponent, { remove: { imports: [CardComponent] }, @@ -54,7 +56,6 @@ describe('GridComponent', () => { await TestBed.configureTestingModule({ imports: [GridComponent], providers: [ - { provide: CardService, useValue: cardServiceMock }, { provide: GridService, useValue: gridServiceMock }, ], }).compileComponents(); @@ -74,20 +75,94 @@ describe('GridComponent', () => { item: { data: cards[0] }, } as never; - (gridComponent as GridComponentTestHarness).Drop(event, [cards], 0); + (gridComponent as GridComponentTestHarness).Drop(event, rows, 1); + + expect(gridServiceMock.setRowsForView).not.toHaveBeenCalled(); + }); + + it('removes the source row and renumbers rows when its only card moves into an existing row', () => { + const movedCard = { id: 1, indexPosition: 0, rowPosition: 1, name: 'One', url: 'https://one.example' }; + const secondCard = { id: 2, indexPosition: 0, rowPosition: 2, name: 'Two', url: 'https://two.example' }; + const thirdCard = { id: 3, indexPosition: 1, rowPosition: 2, name: 'Three', url: 'https://three.example' }; + const fourthCard = { id: 4, indexPosition: 0, rowPosition: 3, name: 'Four', url: 'https://four.example' }; + const rowColumns: RowColumnModel[] = [ + { id: 1, rowPosition: 1, cards: [movedCard] }, + { id: 2, rowPosition: 2, cards: [secondCard, thirdCard] }, + { id: 3, rowPosition: 3, cards: [fourthCard] }, + ]; + const event = { + currentIndex: 1, + item: { data: movedCard }, + } as never; + + (gridComponent as GridComponentTestHarness).Drop(event, rowColumns, 2); + + expect(gridServiceMock.setRowsForView).toHaveBeenCalledWith([ + { + id: 2, + rowPosition: 1, + cards: [ + { ...secondCard, indexPosition: 0, rowPosition: 1 }, + { ...movedCard, indexPosition: 1, rowPosition: 1 }, + { ...thirdCard, indexPosition: 2, rowPosition: 1 }, + ], + }, + { + id: 3, + rowPosition: 2, + cards: [ + { ...fourthCard, indexPosition: 0, rowPosition: 2 }, + ], + }, + ]); + }); + + it('removes the source row and renumbers rows when its only card moves into a new row', () => { + const movedCard = { id: 1, indexPosition: 0, rowPosition: 1, name: 'One', url: 'https://one.example' }; + const secondCard = { id: 2, indexPosition: 0, rowPosition: 2, name: 'Two', url: 'https://two.example' }; + const rowColumns: RowColumnModel[] = [ + { id: 1, rowPosition: 1, cards: [movedCard] }, + { id: 2, rowPosition: 2, cards: [secondCard] }, + ]; + const event = { + currentIndex: 0, + item: { data: movedCard }, + } as never; + + (gridComponent as GridComponentTestHarness).Drop(event, rowColumns, 3); - expect(cardServiceMock.setRows).not.toHaveBeenCalled(); + expect(gridServiceMock.setRowsForView).toHaveBeenCalledWith([ + { + id: 2, + rowPosition: 1, + cards: [ + { ...secondCard, indexPosition: 0, rowPosition: 1 }, + ], + }, + { + id: 0, + rowPosition: 2, + cards: [ + { ...movedCard, indexPosition: 0, rowPosition: 2 }, + ], + }, + ]); }); it('keeps the card DOM element stable when a resized card object is emitted', () => { const firstCardElement = fixture.nativeElement.querySelector('.grid-card-style') as HTMLElement; - cardsSubject.next([ + rowsSubject.next([ { - ...cards[0], - settings: { width: 500, height: 300, imageHidden: false, titleHidden: false }, + ...rows[0], + cards: [ + { + ...cards[0], + settings: { width: 500, height: 300, imageHidden: false, titleHidden: false }, + }, + cards[1], + ], }, - cards[1], ]); fixture.detectChanges(); diff --git a/Gridly-Client/src/app/components/grid/grid.component.ts b/Gridly-Client/src/app/components/grid/grid.component.ts index 5801f0b3..2e2293df 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.ts +++ b/Gridly-Client/src/app/components/grid/grid.component.ts @@ -24,47 +24,46 @@ export class GridComponent { protected emptyRow: CardModel[] = []; protected Drop(event: CdkDragDrop, rows: RowColumnModel[], newRowPosition: number): void { - //TODO needs to remove a row if a row has only one card thats remove - // Update all rows to have the correct index position if (!this.editActive()) return; const droppedCard = event.item.data as CardModel; + const rowsWithoutDroppedCard = rows.map(row => ({ + ...row, + cards: row.cards.filter(card => card.id !== droppedCard.id), + })); - if(rows.length >= newRowPosition) - { - const updatedRows = rows.map(row => { - const cards = [...row.cards]; - const [movedCard] = cards.splice(event.previousIndex, 1); - - cards.splice(event.currentIndex, 0, movedCard); - - return { - ...row, - cards: cards.map((card, index) => ({ - ...card, - rowPosition: newRowPosition, - indexPosition: index, - })), - }; + const targetRowIndex = rowsWithoutDroppedCard.findIndex(row => row.rowPosition === newRowPosition); + + if (targetRowIndex >= 0) { + const targetCards = [...rowsWithoutDroppedCard[targetRowIndex].cards]; + targetCards.splice(event.currentIndex, 0, droppedCard); + rowsWithoutDroppedCard[targetRowIndex] = { + ...rowsWithoutDroppedCard[targetRowIndex], + cards: targetCards, + }; + } else { + rowsWithoutDroppedCard.push({ + id: 0, + rowPosition: newRowPosition, + cards: [droppedCard], }); - - this.#gridService.setRowsForView(updatedRows); - return; } - droppedCard.indexPosition = 1; - - const newRow: RowColumnModel = { - id: 0, - rowPosition: newRowPosition, - cards: [droppedCard], - }; - - const rowsWithoutCard = rows.map(row => ({...row, - cards: row.cards.filter(card => card.id !== droppedCard.id), - })); + this.#gridService.setRowsForView(this.updateRowPositions(rowsWithoutDroppedCard)); + } - this.#gridService.setRowsForView([...rowsWithoutCard, newRow]); + private updateRowPositions(rows: RowColumnModel[]): RowColumnModel[] { + return rows + .filter(row => row.cards.length > 0) + .map((row, rowIndex) => ({ + ...row, + rowPosition: rowIndex + 1, + cards: row.cards.map((card, cardIndex) => ({ + ...card, + rowPosition: rowIndex + 1, + indexPosition: cardIndex + 1, + })), + })); } protected RowId(rowIndex: number): string { diff --git a/Gridly-Client/src/app/components/header/header.component.ts b/Gridly-Client/src/app/components/header/header.component.ts index f7960fa7..a8366eec 100644 --- a/Gridly-Client/src/app/components/header/header.component.ts +++ b/Gridly-Client/src/app/components/header/header.component.ts @@ -52,7 +52,7 @@ export class HeaderComponent { save(): void { this.toggleMenu(); - this.#cardService.batchEdit(this.#cardService.currentCards()); + this.#gridService.batchEdit(this.#gridService.currentRowColumns()); } } diff --git a/Gridly-Client/src/app/constants/url.strings.util.ts b/Gridly-Client/src/app/constants/url.strings.util.ts index 7cca2c27..7e9e8416 100644 --- a/Gridly-Client/src/app/constants/url.strings.util.ts +++ b/Gridly-Client/src/app/constants/url.strings.util.ts @@ -8,11 +8,12 @@ export class UrlStringsUtil { static readonly CardUrlGetById = this.CardUrl+'getbyid/'; static readonly CardUrlSave = this.CardUrl+'save'; static readonly CardUrlEdit = this.CardUrl+'edit'; - static readonly CardsBatchUrlEdit = this.CardUrl+'batch/edit'; + static readonly CardsUrlBatchEdit = this.CardUrl+'batch/edit'; static readonly RowColumnUrl = '/api/row/'; static readonly RowColumnUrlGet = this.RowColumnUrl+'get'; static readonly RowColumnUrlSave = this.CardUrl+'save'; + static readonly RowColumnUrlBatchEdit = this.RowColumnUrl+'batch/edit'; static readonly IconUrl = '/api/icon/'; static readonly IconUrlSearch = this.IconUrl+'search?value='; diff --git a/Gridly-Client/src/app/services/endpoint_services/card.endpoint.service.ts b/Gridly-Client/src/app/services/endpoint_services/card.endpoint.service.ts index bf08a7b9..81f59f86 100644 --- a/Gridly-Client/src/app/services/endpoint_services/card.endpoint.service.ts +++ b/Gridly-Client/src/app/services/endpoint_services/card.endpoint.service.ts @@ -34,6 +34,6 @@ export class CardEndpointService{ } batchEdit(cards: CardModel[]): Observable { - return this.http.post(UrlStringsUtil.CardsBatchUrlEdit, cards).pipe(take(1)); + return this.http.post(UrlStringsUtil.CardsUrlBatchEdit, cards).pipe(take(1)); } -} \ No newline at end of file +} diff --git a/Gridly-Client/src/app/services/endpoint_services/rowColumn.endpoint.service.ts b/Gridly-Client/src/app/services/endpoint_services/rowColumn.endpoint.service.ts index 7bff240a..3f1cdb67 100644 --- a/Gridly-Client/src/app/services/endpoint_services/rowColumn.endpoint.service.ts +++ b/Gridly-Client/src/app/services/endpoint_services/rowColumn.endpoint.service.ts @@ -17,4 +17,7 @@ export class RowColumnEndpointService{ add(rowColumn: RowColumnModel): Observable { return this.http.post(UrlStringsUtil.RowColumnUrlSave, rowColumn).pipe(take(1)); } + batchEdit(cards: RowColumnModel[]): Observable { + return this.http.post(UrlStringsUtil.RowColumnUrlBatchEdit, cards).pipe(take(1)); + } } diff --git a/Gridly-Client/src/app/services/grid_services/grid.service.ts b/Gridly-Client/src/app/services/grid_services/grid.service.ts index 6f40fff7..9922197f 100644 --- a/Gridly-Client/src/app/services/grid_services/grid.service.ts +++ b/Gridly-Client/src/app/services/grid_services/grid.service.ts @@ -3,6 +3,7 @@ import {BehaviorSubject, firstValueFrom, Observable, take} from "rxjs"; import {RowColumnModel} from "../../models/rowColumn.Model"; import {RowColumnEndpointService} from "../endpoint_services/rowColumn.endpoint.service"; import {toSignal} from "@angular/core/rxjs-interop"; +import {CardModel} from "../../models/card.Model"; @Injectable({providedIn: 'root'}) export class GridService { @@ -22,10 +23,13 @@ export class GridService { } private add$ = (rowColumn: RowColumnModel) => this.#api.add(rowColumn); + private batchEdit$ = (rowColumn: RowColumnModel[]) => this.#api.batchEdit(rowColumn); add = (rowColumn: RowColumnModel) => firstValueFrom(this.add$(rowColumn)).then(() => this.refresh()); + batchEdit = (rowColumn: RowColumnModel[]) => firstValueFrom(this.batchEdit$(rowColumn)).then(() => this.refresh()); setEditMode = (value: boolean) => this._editMode.set(value); + toggleEdit = () => this._editMode.update((value) => !value); refresh(): void { diff --git a/Handlers/ColumnRowHandler.cs b/Handlers/ColumnRowHandler.cs index cb57cfaa..f4f049c3 100644 --- a/Handlers/ColumnRowHandler.cs +++ b/Handlers/ColumnRowHandler.cs @@ -1,5 +1,4 @@ using Gridly.Command; -using Gridly.helpers; using Gridly.Models; using Gridly.Repositories; using Gridly.Services; @@ -10,7 +9,8 @@ public class ColumnRowHandler( IColumnRowRepository columnRowRepository, ICardRepository cardRepository): IRequestHandler, - IRequestHandler + IRequestHandler, + IRequestHandler { public async Task Handle(SaveColumnRowCommands command, CancellationToken cancellationToken) { @@ -32,9 +32,9 @@ public async Task Handle(SaveColumnRowCommands command, CancellationTok public async Task Handle(GetAllRowColumnsCommands command, CancellationToken cancellationToken) { - var rowColummns = (await columnRowRepository.Get())?.ToList(); + var storedRowColumns = (await columnRowRepository.Get())?.ToList(); var cards = await cardRepository.Get(); - foreach (var row in rowColummns) + foreach (var row in storedRowColumns) foreach (var card in cards) if (card.RowColumnId == row.Id) { @@ -44,6 +44,14 @@ public async Task Handle(GetAllRowColumnsCommands command, Cancellation row.Cards.Add(card); } - return rowColummns.Any() ? Results.Ok(rowColummns.ToList()) : Results.NoContent(); + return storedRowColumns.Any() ? Results.Ok(storedRowColumns.ToList()) : Results.NoContent(); + } + + public async Task Handle(BatchEditRowColumnCommand commands, CancellationToken cancellationToken) + { + var storedRowColumns = (await columnRowRepository.Get())?.ToList(); + //TODO update rowid for all rows that dont match when updating, so give the cards on that row the current row id they are in. + + return storedRowColumns.Any() ? Results.Ok(storedRowColumns.ToList()) : Results.NoContent(); } } diff --git a/Repositories/ColumnRowRepository.cs b/Repositories/ColumnRowRepository.cs index 56832642..3e5d29c4 100644 --- a/Repositories/ColumnRowRepository.cs +++ b/Repositories/ColumnRowRepository.cs @@ -27,6 +27,12 @@ public async Task Insert(ColumnRowModel columnRow) return Factories.ColumnRowFactory.CreateMany(Dtos); } + public Task> BatchEdit(IEnumerable columnRows) + { + //TODO implement query + throw new NotImplementedException(); + } + public async Task Edit(ColumnRowModel columnRow) { var builder = new SqlBuilder(); diff --git a/Repositories/IColumnRowRepository.cs b/Repositories/IColumnRowRepository.cs index ade78338..fcc67e95 100644 --- a/Repositories/IColumnRowRepository.cs +++ b/Repositories/IColumnRowRepository.cs @@ -6,4 +6,5 @@ public interface IColumnRowRepository { public Task Insert(ColumnRowModel columnRow); public Task> Get(); + public Task> BatchEdit(IEnumerable columnRows); } \ No newline at end of file