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/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 1bd1fb7f..053f1a52 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, Name, Url, IconUrl) - VALUES (@IndexPosition, @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,6 +31,7 @@ INSERT INTO IconsConnected (CardId, IconId) SELECT co.Id AS CardId, co.IndexPosition, + co.RowColumnId, co.Name AS CardName, co.Url, co.IconUrl, @@ -41,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**/"; @@ -56,18 +66,26 @@ 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 SET Name = @Name, IndexPosition = @IndexPosition, + RowColumnId = @RowColumnId, Url = @Url, IconUrl = @IconUrl /**where**/"; public const string UpdateBatchCardQuery = @" UPDATE Card - SET IndexPosition = @IndexPosition + SET IndexPosition = @IndexPosition, + RowColumnId = @RowColumnId WHERE Id = @Id; UPDATE Settings @@ -100,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..459694e9 --- /dev/null +++ b/Controllers/RowController.cs @@ -0,0 +1,23 @@ +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()); + + [HttpPost("batch/edit")] + public async Task Edit([FromBody] BatchEditRowColumnCommand commands) => + await meditor.Send(commands); +} \ No newline at end of file diff --git a/Data/DbInitializer.cs b/Data/DbInitializer.cs index 666ccb85..5cececa4 100644 --- a/Data/DbInitializer.cs +++ b/Data/DbInitializer.cs @@ -16,12 +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, + 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 ff00a487..ce7b3c8e 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 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.css b/Gridly-Client/src/app/components/grid/grid.component.css index 797a9331..a380404a 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.css +++ b/Gridly-Client/src/app/components/grid/grid.component.css @@ -1,12 +1,19 @@ .grid-layout{ - padding: 1em; - margin-top: 3em; + padding: 2em; + margin-top: 1em; display: flex; - flex-wrap: wrap; - gap: 16px; - align-items: flex-start; - align-content: flex-start; - min-height: 100%; + flex-direction: column; + gap: 32px; +} + +.grid-row { + display: flex; + flex-wrap: nowrap; + gap: 32px; +} + +.grid-row-empty { + min-height: 120px; } .grid-card-style { diff --git a/Gridly-Client/src/app/components/grid/grid.component.html b/Gridly-Client/src/app/components/grid/grid.component.html index 829d8559..33b2a029 100644 --- a/Gridly-Client/src/app/components/grid/grid.component.html +++ b/Gridly-Client/src/app/components/grid/grid.component.html @@ -1,15 +1,39 @@ -@if (cards$ | async; as cards) { -
- @for (card of cards; track card.id) { -
- -
+@if (rows$ | async; as rows) { +
+ @for (row of rows; track $index === row.id) { +
+ @for (card of row.cards; 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..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): void; + Drop(event: unknown, rows: RowColumnModel[], rowIndex: number): void; }; @Component({ @@ -24,21 +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 }; - const gridServiceMock = {} as { inEditMode: () => boolean }; + const gridServiceMock = {} as { + rows$: BehaviorSubject; + inEditMode: () => boolean; + setRowsForView: jest.Mock; + }; 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(); + 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] }, @@ -48,7 +56,6 @@ describe('GridComponent', () => { await TestBed.configureTestingModule({ imports: [GridComponent], providers: [ - { provide: CardService, useValue: cardServiceMock }, { provide: GridService, useValue: gridServiceMock }, ], }).compileComponents(); @@ -58,47 +65,104 @@ 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('does not reorder card when edit mode is disabled', () => { + editMode.set(false); - 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, rows, 1); - expect(cards.map((card) => card.id)).toEqual([2, 1]); + expect(gridServiceMock.setRowsForView).not.toHaveBeenCalled(); }); - it('does not reorder card when edit mode is disabled', () => { - editMode.set(false); - + 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 = { - container: { data: cards }, - previousIndex: 0, currentIndex: 1, + item: { data: movedCard }, } as never; - (gridComponent as GridComponentTestHarness).Drop(event); + (gridComponent as GridComponentTestHarness).Drop(event, rowColumns, 2); - expect(cards.map((card) => card.id)).toEqual([1, 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(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 8f25663f..2e2293df 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 { Component, ElementRef, 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 { RowColumnModel } from "../../models/rowColumn.Model"; + @Component({ selector: 'app-grid', imports: [CommonModule, CdkDropList, CdkDrag, CardComponent], @@ -14,14 +15,69 @@ import { CardComponent } from '../card/card.component'; }) export class GridComponent { - #cardService = inject(CardService); #gridService = inject(GridService); - protected readonly cards$ = this.#cardService.cards$; + @ViewChild('gridLayout') private gridLayout?: ElementRef; + + protected readonly rows$ = this.#gridService.rows$; protected readonly editActive = this.#gridService.inEditMode; + protected emptyRow: CardModel[] = []; - protected Drop(event: CdkDragDrop): void { + protected Drop(event: CdkDragDrop, rows: RowColumnModel[], newRowPosition: number): void { if (!this.editActive()) return; - moveItemInArray(event.container.data, event.previousIndex, event.currentIndex); + + const droppedCard = event.item.data as CardModel; + const rowsWithoutDroppedCard = rows.map(row => ({ + ...row, + cards: row.cards.filter(card => card.id !== droppedCard.id), + })); + + 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(this.updateRowPositions(rowsWithoutDroppedCard)); + } + + 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 { + return `card-row-${rowIndex}`; + } + + protected RowIds(rows: RowColumnModel[]): string[] { + return [ + ...rows.map(row => this.RowId(row.rowPosition!)), + this.RowId(rows.length + 1), + ]; + } + + private getRowIndex(rowId: string): number { + return Number(rowId.replace('card-row-', '')); } } 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 9c36c89e..7e9e8416 100644 --- a/Gridly-Client/src/app/constants/url.strings.util.ts +++ b/Gridly-Client/src/app/constants/url.strings.util.ts @@ -8,7 +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/directives/resizable.directive.ts b/Gridly-Client/src/app/directives/resizable.directive.ts index 47098caf..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; @@ -85,8 +83,6 @@ export class ResizableDirective { titleHidden: this.targetCard.settings?.titleHidden ?? false }; - this.#cardService.update(this.targetCard); - 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'); 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/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/card_services/card.service.spec.ts b/Gridly-Client/src/app/services/card_services/card.service.spec.ts index 65afcd7f..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', @@ -78,24 +80,41 @@ describe('CardService', () => { expect(endpointMock.get).toHaveBeenCalledTimes(4); }); - it('stores resized card settings before batch saving', async () => { - endpointMock.batchEdit.mockReturnValue(of([cardA, cardB])); + it('groups cards into rows when a row exceeds the max width', () => { + const rows = service.toRows([cardA, cardB], 400); - const resizedCard: CardModel = { - ...cardA, - settings: { - ...cardA.settings!, - width: 500, - height: 300, - }, - }; + expect(rows.map((row) => row.map((card) => card.id))).toEqual([[1], [2]]); + expect(rows.flat()).toEqual([ + { ...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 }, + ]); + }); + + it('updates row and row position when rows are changed', () => { + service.setRows([[cardA], [cardB]], 1000); + + expect(service.currentCards()).toEqual([ + { ...cardA, indexPosition: 0, rowPosition: 1 }, + { ...cardB, indexPosition: 0, rowPosition: 2 }, + ]); + }); - service.update(resizedCard); - await service.batchEdit(service.currentCards()); + it('removes empty rows when cards move out of them', () => { + service.setRows([[], [cardB, cardA]], 1000); - expect(endpointMock.batchEdit).toHaveBeenCalledWith([ - resizedCard, - cardB, + expect(service.currentCards()).toEqual([ + { ...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 f21df1a2..bf8f47e0 100644 --- a/Gridly-Client/src/app/services/card_services/card.service.ts +++ b/Gridly-Client/src/app/services/card_services/card.service.ts @@ -34,23 +34,80 @@ 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 => { - 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(updatedCards); - }; + setRows(rows: CardModel[][], maxRowWidth = 0): void { + 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(); + + 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) { + this.moveOverflowCards(normalizedRows, maxRowWidth); + } + + return this.updateCardPositions(normalizedRows); + } + + private updateCardPositions(rows: CardModel[][]): CardModel[][] { + return rows + .filter((row) => row.length > 0) + .map((row, rowIndex) => + row.map((card, indexPosition) => ({ + ...card, + 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, + ); + + return cardsWidth + Math.max(row.length - 1, 0); + } refresh(): void { this.#api.get().pipe(take(1)) 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 new file mode 100644 index 00000000..3f1cdb67 --- /dev/null +++ b/Gridly-Client/src/app/services/endpoint_services/rowColumn.endpoint.service.ts @@ -0,0 +1,23 @@ +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)); + } + 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 08a93f77..9922197f 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,45 @@ -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"; +import {CardModel} from "../../models/card.Model"; @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); + 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 { + 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 c050e113..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) ? + 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.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..f4f049c3 --- /dev/null +++ b/Handlers/ColumnRowHandler.cs @@ -0,0 +1,57 @@ +using Gridly.Command; +using Gridly.Models; +using Gridly.Repositories; +using Gridly.Services; +using MediatR; + +namespace Gridly.Handlers; +public class ColumnRowHandler( + IColumnRowRepository columnRowRepository, + ICardRepository cardRepository): + IRequestHandler, + 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 storedRowColumns = (await columnRowRepository.Get())?.ToList(); + var cards = await cardRepository.Get(); + foreach (var row in storedRowColumns) + foreach (var card in cards) + if (card.RowColumnId == row.Id) + { + if (row.Cards is null) + row.Cards = new List(); + + row.Cards.Add(card); + } + + 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/Factories/CardFactory.cs b/Handlers/Factories/CardFactory.cs similarity index 96% rename from Factories/CardFactory.cs rename to Handlers/Factories/CardFactory.cs index 4dbe3db5..016945c0 100644 --- a/Factories/CardFactory.cs +++ b/Handlers/Factories/CardFactory.cs @@ -10,6 +10,7 @@ public static CardModel Create(CardDtoModel dto) { Id = dto.CardId, IndexPosition = dto. IndexPosition, + 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 52624aa2..94375548 100644 --- a/Helpers/ComponentHandlerHelper.cs +++ b/Helpers/ComponentHandlerHelper.cs @@ -19,7 +19,7 @@ public bool DeleteIcon(CardModel Card) => public IEnumerable SetIndexValues(List cards) { - for (int i = 0; i < cards.Count(); i++) + for (int i = 0; i < cards.Count; i++) cards[i].IndexPosition = i +1; return cards; diff --git a/Models/CardModel.cs b/Models/CardModel.cs index ad5ddcab..4d13c6e6 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("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 a3a8fe1c..eb15b489 100644 --- a/Repositories/CardRepository.cs +++ b/Repositories/CardRepository.cs @@ -36,6 +36,7 @@ public async Task BatchEdit(IEnumerable? cards) { c.Id, c.IndexPosition, + 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..3e5d29c4 --- /dev/null +++ b/Repositories/ColumnRowRepository.cs @@ -0,0 +1,43 @@ +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 Task> BatchEdit(IEnumerable columnRows) + { + //TODO implement query + throw new NotImplementedException(); + } + + 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..fcc67e95 --- /dev/null +++ b/Repositories/IColumnRowRepository.cs @@ -0,0 +1,10 @@ +using Gridly.Models; + +namespace Gridly.Repositories; + +public interface IColumnRowRepository +{ + public Task Insert(ColumnRowModel columnRow); + public Task> Get(); + public Task> BatchEdit(IEnumerable columnRows); +} \ No newline at end of file diff --git a/Tests/Factories/CardFactoryTests.cs b/Tests/Factories/CardFactoryTests.cs index 37938178..1e63df91 100644 --- a/Tests/Factories/CardFactoryTests.cs +++ b/Tests/Factories/CardFactoryTests.cs @@ -12,6 +12,7 @@ public void Create_MapsDtoFieldsToCardModel() { CardId = 12, IndexPosition = 3, + RowColumnId = 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.RowColumnId, result.RowColumnId); Assert.Equal(dto.CardName, result.Name); Assert.Equal(dto.Url, result.Url); Assert.Equal(dto.IconUrl, result.IconUrl);