diff --git a/libs/dc-workflow/projects/dc-workflow/src/lib/abstract/abstract-dc-workflow.ts b/libs/dc-workflow/projects/dc-workflow/src/lib/abstract/abstract-dc-workflow.ts index a26740e3..e31510aa 100644 --- a/libs/dc-workflow/projects/dc-workflow/src/lib/abstract/abstract-dc-workflow.ts +++ b/libs/dc-workflow/projects/dc-workflow/src/lib/abstract/abstract-dc-workflow.ts @@ -1,7 +1,7 @@ import { DcWorkflow } from '../definition/dc-workflow'; -import { Component, computed, effect, inject, input } from '@angular/core'; +import { Component, computed, effect, inject, input, linkedSignal, signal } from '@angular/core'; import { DcWorkflowModel, DcWorkflowSortOption } from '../models/dc-workflow-model'; -import { XtCompositeComponent, XtResolverService } from 'xt-components'; +import { XtCompositeComponent, XtMessageHandler, XtResolverService } from 'xt-components'; import { XtSignalStore, XtSortBy, @@ -10,6 +10,7 @@ import { XtStoreManagerService } from 'xt-store'; import { ManagedData } from 'xt-type'; +import { FormGroup } from '@angular/forms'; /** * A workflow base class based on the xt-store signalStore @@ -28,9 +29,37 @@ export class AbstractDcWorkflow extends XtCom protected readonly resolver = inject (XtResolverService); protected readonly storeMgr = inject(XtStoreManagerService); + protected readonly errorHandler = inject(XtMessageHandler); + + /** + * The store that manages the access to the data + * @protected + */ protected store : XtSignalStore | null | undefined = undefined; + /** + * True whenever the store is updating something + */ + updating = signal (false); + + /** + * A shortcut to the entityname being managed + */ + entityName = linkedSignal( () => { + return this.config().entity; + }); + + /** + * A simple signal telling when a new store is being used. For example, when the entity handled has changed + * @protected + */ + protected storeChanged=signal(false); + + /** + * Triggered when the workflow config has been updated. Do we need another store, or just reconfigure the sort / filtering ? + * @protected + */ protected configUpdated = effect( async ()=> { const config = this.config(); const curStore = this.store; @@ -68,6 +97,7 @@ export class AbstractDcWorkflow extends XtCom * @protected */ protected displayableElements = computed(() => { + const enforceStoreChange = this.storeChanged(); // Recalculate in case the store has changed const config = this.config(); const entities = this.safeFindStore().entities(); const selectConfig = config.display; @@ -87,7 +117,37 @@ export class AbstractDcWorkflow extends XtCom return true; }); }); - /** + + /** + * Safely loads the data from the store. + */ + protected fetchFromStore () { + //console.debug("ListDetails fetchFromStore"); + const entityName = this.entityName(); + if (entityName!=null) { + try { + this.updating.set(true); + // If the entity is different, then we must change the whole store + if (this.store?.entityName()!=entityName) { + this.store = this.safeFindStore(); + this.storeChanged.update((oldVal)=> !oldVal); // Force update whenever the store changed + } + // console.debug("Store set to "+this.store.entityName()); + this.store.fetchEntities().catch((error) => { + this.errorHandler.errorOccurred(error, "Error loading entities "+entityName); + }).finally(() => { + this.updating.set(false); +// console.debug("Store fetched values ",this.store?.entities()); + });//.then(() => {console.debug('Yes')}).finally(() => {console.debug('Finish')}); + } catch (error) { + this.updating.set(false); + } + } else { + this.store = null; + } + } + + /** * Returns the element that should be selected given the config and the store content * @protected */ diff --git a/libs/xt-components/projects/xt-components/src/lib/plugin/xt-plugin-info.ts b/libs/xt-components/projects/xt-components/src/lib/plugin/xt-plugin-info.ts index 412111cf..2606985c 100644 --- a/libs/xt-components/projects/xt-components/src/lib/plugin/xt-plugin-info.ts +++ b/libs/xt-components/projects/xt-components/src/lib/plugin/xt-plugin-info.ts @@ -1,6 +1,5 @@ import { XtTypeInfo } from 'xt-type'; import { XtOutputType } from '../xt-component'; -import { XtActionHandler } from '../action/xt-action-handler'; import { XtWorkflow } from '../workflow/xt-workflow'; /** Information about a registered component plugin */ diff --git a/libs/xt-components/projects/xt-components/src/lib/registry/xt-plugin-registry.spec.ts b/libs/xt-components/projects/xt-components/src/lib/registry/xt-plugin-registry.spec.ts index fd27e77d..5bee9489 100644 --- a/libs/xt-components/projects/xt-components/src/lib/registry/xt-plugin-registry.spec.ts +++ b/libs/xt-components/projects/xt-components/src/lib/registry/xt-plugin-registry.spec.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { xtPluginRegistry } from '../../globals'; import { XtActionHandler, XtActionResult } from '../action/xt-action-handler'; import { XtContext } from '../xt-context'; -import { IStoreProvider } from '../store/store-support'; describe('XtPluginRegistry', () => { diff --git a/plugins/xt-default/projects/default-plugin/src/app/app.config.ts b/plugins/xt-default/projects/default-plugin/src/app/app.config.ts index 63cfd798..af933ea0 100644 --- a/plugins/xt-default/projects/default-plugin/src/app/app.config.ts +++ b/plugins/xt-default/projects/default-plugin/src/app/app.config.ts @@ -4,15 +4,64 @@ import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { providePrimeNG } from 'primeng/config'; +import { definePreset } from '@primeuix/themes'; import Aura from '@primeuix/themes/aura'; +const AppPreset = definePreset(Aura, { + semantic: { + colorScheme: { + light: { + surface: { + 0: '#ffffff', + 50: '#f8f8fc', + 100: '#efeff8', + 200: '#dedff0', + 300: '#c4c7df', + 400: '#9aa0bf', + 500: '#747b9a', + 600: '#5a607a', + 700: '#42475b', + 800: '#2a2f3c', + 900: '#171a22', + 950: '#0b0d12' + }, + content: { + background: '{surface.50}' + } + }, + dark: { + surface: { + 950: '#101118', + 900: '#171923', + 800: '#222737', + 700: '#333a52', + 600: '#48506c', + 500: '#66708c', + 400: '#97a0b8', + 300: '#c4cadb', + 200: '#e2e6f0', + 100: '#f3f5fa', + 50: '#fafbfe', + 0: '#ffffff' + }, + content: { + background: '{surface.950}' + } + } + } + } +}); + export const appConfig: ApplicationConfig = { providers: [provideZonelessChangeDetection(), provideBrowserGlobalErrorListeners(), provideAnimationsAsync(), providePrimeNG({ theme: { - preset: Aura + preset: AppPreset, + options: { + darkModeSelector: '.app-dark' + } } }), provideRouter(routes)] diff --git a/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.css b/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.css index e69de29b..08f9dbcb 100644 --- a/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.css +++ b/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.css @@ -0,0 +1,3 @@ +.list_view_container { + padding: 4rem; +} diff --git a/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.html b/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.html index 6d9466b9..9ca15655 100644 --- a/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.html +++ b/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.html @@ -6,10 +6,15 @@
+ +
+ +
- - - Selected element is {{selectedValue() | json}} - +
+ List View + + Selected element is {{selectedModel() | json}} +
diff --git a/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.ts b/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.ts index 24f6adbb..887d392b 100644 --- a/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.ts +++ b/plugins/xt-default/projects/default-plugin/src/app/test-object-set/test-object-set.component.ts @@ -1,10 +1,11 @@ -import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, model, signal } from '@angular/core'; import { AutoComplete, AutoCompleteSelectEvent } from 'primeng/autocomplete'; import { JsonPipe } from '@angular/common'; -import { XtComponentOutput, XtRenderComponent } from 'xt-components'; +import { XtRenderComponent } from 'xt-components'; import { FormsModule } from '@angular/forms'; import { Panel } from 'primeng/panel'; import { DefaultObjectSetComponent } from '../../../../default/src/lib/object-set/default-object-set.component'; +import { CarouselObjectSetComponent } from '../../../../default/src/lib/object-set/carousel-object-set.component'; import { AppComponent } from '../app.component'; @Component({ @@ -21,7 +22,13 @@ import { AppComponent } from '../app.component'; changeDetection: ChangeDetectionStrategy.OnPush }) export class TestObjectSetComponent { - selectedObject= signal('simple'); + selectedObject = signal('simple'); + + selectedComponent = signal('Default'); + + componentOptions() { + return ['Default', 'Carousel']; + } value = signal(this.loadObject('simple') ); @@ -34,6 +41,10 @@ export class TestObjectSetComponent { this.value.set(this.loadObject ($event.value)); } + componentSwitch($event: AutoCompleteSelectEvent) { + this.selectedComponent.set($event.value); + } + loadObject (objName:string) :any { if (objName=='long') { const ret = []; @@ -119,21 +130,13 @@ export class TestObjectSetComponent { }[objName]; } - objectComponentType() { - return DefaultObjectSetComponent; - } - - selectedValue = signal(null); + objectComponentType = computed(() => + this.selectedComponent() === 'Carousel' ? CarouselObjectSetComponent : DefaultObjectSetComponent + ); - componentOutputChange (newOutput:XtComponentOutput|null) { - if (newOutput?.valueSelected!=null) { - newOutput.valueSelected.subscribe ((value) => { - this.selectedValue.set(value); - }) - } - } + selectedModel = model(null); - valueType= computed(() => { + valueType = computed(() => { const displayedType=this.selectedObject(); return displayedType=='references'?'bookType':undefined; }); diff --git a/plugins/xt-default/projects/default-plugin/src/main.ts b/plugins/xt-default/projects/default-plugin/src/main.ts index 35b00f34..79dc5682 100644 --- a/plugins/xt-default/projects/default-plugin/src/main.ts +++ b/plugins/xt-default/projects/default-plugin/src/main.ts @@ -2,5 +2,11 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { AppComponent } from './app/app.component'; +const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); +document.documentElement.classList.toggle('app-dark', prefersDark.matches); +prefersDark.addEventListener('change', (e) => { + document.documentElement.classList.toggle('app-dark', e.matches); +}); + bootstrapApplication(AppComponent, appConfig) .catch((err) => console.error(err)); diff --git a/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.css b/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.css new file mode 100644 index 00000000..0a168aab --- /dev/null +++ b/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.css @@ -0,0 +1,43 @@ +:host { + display: block; + margin: 0 auto; + max-width: 100vw; + +} + +:host ::ng-deep .p-carousel-viewport { + max-height: 30vh; + padding: 1.5rem 0.5rem; +} + +.carousel-object-set__panel { + cursor: pointer; + border: 2px solid transparent; + border-radius: 0.75rem; + padding: 1rem; + min-width: 0; + transition: + transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), + border-color 0.2s ease, + box-shadow 0.2s ease; + background: var(--p-content-background); +} + +.carousel-object-set__panel:hover { + border-color: var(--p-primary-200); +} + +.carousel-object-set__panel--selected { + border-color: var(--p-primary-400); + box-shadow: 0 0 0 1px var(--p-primary-400), 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.carousel-object-set__panel--selected-scale { + transform: scale(1.12); +} + +@media (max-width: 600px) { + :host ::ng-deep .p-carousel-viewport { + max-height: 65vh; + } +} diff --git a/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.html b/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.html new file mode 100644 index 00000000..79c43cb9 --- /dev/null +++ b/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.html @@ -0,0 +1,13 @@ + + + @let isSelected = selectedElement() === elt; + @let isHorizontal = carouselOrientation() === 'horizontal'; + + + diff --git a/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.spec.ts b/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.spec.ts new file mode 100644 index 00000000..f671ef1a --- /dev/null +++ b/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.spec.ts @@ -0,0 +1,264 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CarouselObjectSetComponent } from './carousel-object-set.component'; +import { provideZonelessChangeDetection } from '@angular/core'; +import { StoreTestHelper, XtBaseContext, XtResolverService } from 'xt-components'; +import { registerDefaultPlugin } from '../register'; +import { By } from '@angular/platform-browser'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { Carousel } from 'primeng/carousel'; + +type TestData = { + simpleText: string; + simpleDate: Date; + simpleNumber: number; + simpleBoolean: boolean; +}; + +type IdentifiedTestData = TestData & { + _id: string; +}; + +describe('CarouselObjectSetComponent', () => { + + let resolverService: XtResolverService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CarouselObjectSetComponent], + providers: [provideZonelessChangeDetection()] + }) + .compileComponents(); + + StoreTestHelper.ensureTestProviderOnly(); + resolverService = TestBed.inject(XtResolverService); + registerDefaultPlugin(resolverService); + }); + + it('should create', () => { + let component: CarouselObjectSetComponent; + let fixture: ComponentFixture>; + + fixture = TestBed.createComponent(CarouselObjectSetComponent); + let context = new XtBaseContext('INLINE_VIEW'); + context.setDisplayValue([{ + simpleText: 'bonjour', + simpleDate: new Date(1971, 1, 1), + simpleNumber: 11, + simpleBoolean: false + }]); + + fixture.componentRef.setInput('context', context); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component).toBeTruthy(); + }); + + it('should display items in carousel', () => { + let component: CarouselObjectSetComponent; + let fixture: ComponentFixture>; + + fixture = TestBed.createComponent(CarouselObjectSetComponent); + let context = new XtBaseContext('INLINE_VIEW'); + context.setDisplayValue([{ + simpleText: 'bonjour', + simpleDate: new Date(1971, 1, 1), + simpleNumber: 11, + simpleBoolean: false + }, { + simpleText: 'hola', + simpleDate: new Date(1972, 2, 2), + simpleNumber: 12, + simpleBoolean: true + }, { + simpleText: 'guten tag', + simpleDate: new Date(1973, 3, 3), + simpleNumber: 13, + simpleBoolean: false + }]); + + fixture.componentRef.setInput('context', context); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component).toBeTruthy(); + expect(component.valueSet().length).toEqual(3); + + const carouselEl = fixture.debugElement.query(By.directive(Carousel)); + expect(carouselEl).toBeTruthy(); + }); + + it('should enable element selection', () => { + let component: CarouselObjectSetComponent; + let fixture: ComponentFixture>; + + fixture = TestBed.createComponent(CarouselObjectSetComponent); + let context = new XtBaseContext('LIST_VIEW'); + context.setDisplayValue([{ + simpleText: 'bonjour', + simpleDate: new Date(1971, 1, 1), + simpleNumber: 11, + simpleBoolean: false + }, { + simpleText: 'hola', + simpleDate: new Date(1972, 2, 2), + simpleNumber: 12, + simpleBoolean: true + }, { + simpleText: 'guten tag', + simpleDate: new Date(1973, 3, 3), + simpleNumber: 13, + simpleBoolean: false + }]); + + fixture.componentRef.setInput('context', context); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component).toBeTruthy(); + component.selectionChange(component.valueSet()[1]); + fixture.detectChanges(); + + expect(component.selectedElement()).toBeTruthy(); + expect(component.selectedElement()?.simpleNumber).toEqual(12); + }); + + it('should keep element selection if possible', () => { + let component: CarouselObjectSetComponent; + let fixture: ComponentFixture>; + + fixture = TestBed.createComponent(CarouselObjectSetComponent); + let context = new XtBaseContext('LIST_VIEW'); + context.setDisplayValue([{ + _id: '11', + simpleText: 'bonjour', + simpleDate: new Date(1971, 1, 1), + simpleNumber: 11, + simpleBoolean: false + }, { + _id: '12', + simpleText: 'hola', + simpleDate: new Date(1972, 2, 2), + simpleNumber: 12, + simpleBoolean: true + }, { + _id: '13', + simpleText: 'guten tag', + simpleDate: new Date(1973, 3, 3), + simpleNumber: 13, + simpleBoolean: false + }]); + + fixture.componentRef.setInput('context', context); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component).toBeTruthy(); + component.selectionChange(component.valueSet()[1]); + fixture.detectChanges(); + + expect(component.selectedElement()).toBeTruthy(); + expect(component.selectedElement()?.simpleNumber).toEqual(12); + + context.setDisplayValue([{ + _id: '11', + simpleText: 'bonjour', + simpleDate: new Date(1971, 1, 1), + simpleNumber: 11, + simpleBoolean: false + }, { + _id: '13', + simpleText: 'guten tag', + simpleDate: new Date(1973, 3, 3), + simpleNumber: 13, + simpleBoolean: false + }, { + _id: '12', + simpleText: 'hola', + simpleDate: new Date(1972, 2, 2), + simpleNumber: 12, + simpleBoolean: true + }]); + + expect(component.selectedElement()).toBeTruthy(); + expect(component.selectedElement()?.simpleNumber).toEqual(12); + + context.setDisplayValue([{ + _id: '11', + simpleText: 'bonjour', + simpleDate: new Date(1971, 1, 1), + simpleNumber: 11, + simpleBoolean: false + }, { + _id: '12', + simpleText: 'hola', + simpleDate: new Date(1972, 2, 2), + simpleNumber: 12, + simpleBoolean: true + }]); + + expect(component.selectedElement()).toBeTruthy(); + expect(component.selectedElement()?.simpleNumber).toEqual(12); + + context.setDisplayValue([{ + _id: '11', + simpleText: 'bonjour', + simpleDate: new Date(1971, 1, 1), + simpleNumber: 11, + simpleBoolean: false + }]); + + expect(component.selectedElement()).toBeNull(); + }); + + it('should keep selection even without Ids', () => { + let component: CarouselObjectSetComponent; + let fixture: ComponentFixture>; + + fixture = TestBed.createComponent(CarouselObjectSetComponent); + let context = new XtBaseContext('LIST_VIEW'); + const values: Array = [{ + simpleText: 'bonjour', + simpleDate: new Date(1971, 1, 1), + simpleNumber: 11, + simpleBoolean: false + }, { + simpleText: 'hola', + simpleDate: new Date(1972, 2, 2), + simpleNumber: 12, + simpleBoolean: true + }, { + simpleText: 'guten tag', + simpleDate: new Date(1973, 3, 3), + simpleNumber: 13, + simpleBoolean: false + }]; + + context.setDisplayValue(values); + + fixture.componentRef.setInput('context', context); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component).toBeTruthy(); + component.selectionChange(component.valueSet()[1]); + fixture.detectChanges(); + + expect(component.selectedElement()).toBeTruthy(); + expect(component.selectedElement()?.simpleNumber).toEqual(12); + + values[1].simpleNumber = 14; + context.setDisplayValue([...values]); + + expect(component.selectedElement()).toBeTruthy(); + expect(component.selectedElement()?.simpleNumber).toEqual(14); + + context.setDisplayValue(values.slice(0, 2)); + expect(component.selectedElement()).toBeTruthy(); + expect(component.selectedElement()?.simpleNumber).toEqual(14); + + context.setDisplayValue(values.slice(0, 1)); + expect(component.selectedElement()).toBeNull(); + }); +}); diff --git a/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.ts b/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.ts new file mode 100644 index 00000000..fc1462c6 --- /dev/null +++ b/plugins/xt-default/projects/default/src/lib/object-set/carousel-object-set.component.ts @@ -0,0 +1,131 @@ +import { ChangeDetectionStrategy, Component, computed, effect, linkedSignal, model, output, signal, ViewChild } from '@angular/core'; +import { XtCompositeComponent, XtRenderComponent } from 'xt-components'; +import { Carousel } from 'primeng/carousel'; + +@Component({ + selector: 'lib-carousel-object-set', + imports: [Carousel, XtRenderComponent], + templateUrl: './carousel-object-set.component.html', + styleUrl: './carousel-object-set.component.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CarouselObjectSetComponent extends XtCompositeComponent { + selected = model(); + protected valueSelectedAsOutput = output(); + + private portraitQuery = window.matchMedia('(max-width: 599.98px) and (orientation: portrait)'); + isPortrait = signal(this.portraitQuery.matches); + + numVisible = computed(() => this.isPortrait() ? 1 : 3); + centerOffset = computed(() => Math.floor((this.numVisible() - 1) / 2)); + carouselOrientation = computed<'horizontal' | 'vertical'>(() => this.isPortrait() ? 'vertical' : 'horizontal'); + + valueSet = computed(() => { + const ret = this.context().value(); + if (Array.isArray(ret)) { + return ret as T[]; + } else if (ret != null) { + return [ret] as T[]; + } else return []; + }); + + valueType = computed(() => { + const vt = this.context().valueType; + if (vt == null) return undefined; + return vt.endsWith('[]') ? vt.substring(0, vt.length - 2) : vt; + }); + + private selectionContext = computed(() => ({ + values: this.valueSet(), + current: this.selected() + })); + + selectedElement = linkedSignal<{values: T[]|null, current: T|null}, T|null>({ + source: this.selectionContext, + computation: (source, previous) => { + if (source.values == null || source.values.length === 0) return null; + + if (source.current != null) { + const found = source.values.find((toCheck) => { + if ((toCheck as any)._id != null && (source.current as any)._id != null) { + return (toCheck as any)._id === (source.current as any)._id; + } + return toCheck === source.current; + }); + if (found) return found; + } + + if (previous?.value != null) { + return source.values.find((toCheck) => { + if ((toCheck as any)._id != null) { + return (toCheck as any)._id === (previous.value as any)._id; + } + return toCheck === previous.value; + }) ?? null; + } + + return null; + } + }); + + @ViewChild('carouselRef') carousel?: Carousel; + + constructor() { + super(); + this.portraitQuery.addEventListener('change', (e) => this.isPortrait.set(e.matches)); + effect(() => { + const items = this.valueSet(); + if (items.length > 0 && this.selectedElement() == null) { + const offset = this.centerOffset(); + const element = items[Math.min(offset, items.length - 1)]; + this.selectedElement.set(element); + this.selected.set(element); + this.currentPage.set(0); + } + }); + } + + onCarouselPage(event: any): void { + const page = event.page ?? 0; + this.currentPage.set(page); + const items = this.valueSet(); + if (items.length === 0) return; + const centerIndex = page + this.centerOffset(); + const index = Math.min(centerIndex, items.length - 1); + const element = items[index]; + this.selectedElement.set(element); + this.selected.set(element); + if (this.outputsObject.valueSelected != null) { + this.outputsObject.valueSelected.emit(element); + } + } + + currentPage = signal(0); + + selectionChange(newElement: any) { + this.selectedElement.set(newElement); + this.selected.set(newElement); + if (this.outputsObject.valueSelected != null) { + this.outputsObject.valueSelected.emit(newElement); + } + const items = this.valueSet(); + const index = items.indexOf(newElement); + if (index === -1) return; + const total = items.length; + const numVis = this.numVisible(); + const targetPage = Math.max(0, Math.min(index - this.centerOffset(), total - numVis)); + this.currentPage.set(targetPage); + if (this.carousel) { + this.carousel.page = targetPage; + } + } + + override setupInputOutput() { + const parentModel = this.models(); + if (parentModel?.valueSelected != null) { + this.selected = parentModel.valueSelected; + } else { + this.outputsObject.valueSelected = this.valueSelectedAsOutput; + } + } +} diff --git a/plugins/xt-default/projects/default/src/public-api.ts b/plugins/xt-default/projects/default/src/public-api.ts index 22df5ede..43636ff1 100644 --- a/plugins/xt-default/projects/default/src/public-api.ts +++ b/plugins/xt-default/projects/default/src/public-api.ts @@ -5,4 +5,5 @@ export * from './lib/object/default-object.component'; export * from './lib/primitive/default-primitive.component'; export * from './lib/object-set/default-object-set.component'; +export * from './lib/object-set/carousel-object-set.component'; export * from './lib/register'; diff --git a/plugins/xt-workflow/projects/workflow-plugin/src/app/test-workflow/test-workflow.component.html b/plugins/xt-workflow/projects/workflow-plugin/src/app/test-workflow/test-workflow.component.html index 9127ae8b..bdd0847b 100644 --- a/plugins/xt-workflow/projects/workflow-plugin/src/app/test-workflow/test-workflow.component.html +++ b/plugins/xt-workflow/projects/workflow-plugin/src/app/test-workflow/test-workflow.component.html @@ -1,4 +1,9 @@ -Testing the default List Details workflow +Testing the workflows + +
+ Select the workflow type: + +
Select the default sort: @@ -7,6 +12,7 @@
+ @if (dataLoaded()) { } diff --git a/plugins/xt-workflow/projects/workflow-plugin/src/app/test-workflow/test-workflow.component.ts b/plugins/xt-workflow/projects/workflow-plugin/src/app/test-workflow/test-workflow.component.ts index a6103ad6..bf8043ba 100644 --- a/plugins/xt-workflow/projects/workflow-plugin/src/app/test-workflow/test-workflow.component.ts +++ b/plugins/xt-workflow/projects/workflow-plugin/src/app/test-workflow/test-workflow.component.ts @@ -6,6 +6,7 @@ import { ListDetailsComponent } from '../../../../workflow/src/lib/list-details/ import { Button } from 'primeng/button'; import { DcWorkflowModel, WfwRender } from 'dc-workflow'; import { StoreTestBed } from 'xt-store'; +import { Select, SelectChangeEvent } from 'primeng/select'; @Component({ selector: 'app-test', @@ -13,7 +14,8 @@ import { StoreTestBed } from 'xt-store'; FormsModule, ReactiveFormsModule, Button, - WfwRender + WfwRender, + Select ], templateUrl: './test-workflow.component.html', styleUrl: './test-workflow.component.css' @@ -48,6 +50,22 @@ export class TestWorkflowComponent implements OnInit, OnDestroy { name:'ATest2', creationDate:new Date(2014,6,23), value: 22 + },{ + name:'Other Test', + creationDate:new Date(2026,4,19), + value: 34 + },{ + name:'Random Test', + creationDate:new Date(2025,10,9), + value: 4 + },{ + name:'New value Test', + creationDate:new Date(2016,8,1), + value: 64 + },{ + name:'Value Test', + creationDate:new Date(2028,11,23), + value: 34 }]).then(() => { this.dataLoaded.set(true); }); @@ -58,7 +76,7 @@ export class TestWorkflowComponent implements OnInit, OnDestroy { protected workflowConfig = computed(() => ({ entity:'test', - workflow:'list-detail', + workflow:this.workflowType() as 'list-detail'|'carousel', data: { sort: { [this.sortField()]: this.sortDir() @@ -66,6 +84,8 @@ export class TestWorkflowComponent implements OnInit, OnDestroy { } })); + protected workflowType = signal('list-detail'); + protected toggleSort(field: string) { if (this.sortField() === field) { this.sortDir.update(d => d === 'ascending' ? 'descending' : 'ascending'); @@ -79,4 +99,11 @@ export class TestWorkflowComponent implements OnInit, OnDestroy { this.subscriptions.unsubscribe(); } + protected setWorkflowType(value: string) { + this.workflowType.set(value); + } + + protected listWorkflowTypes():string[] { + return ['list-detail','carousel'] + } } diff --git a/plugins/xt-workflow/projects/workflow/package.json b/plugins/xt-workflow/projects/workflow/package.json index e5fe6554..d4bd8b5b 100644 --- a/plugins/xt-workflow/projects/workflow/package.json +++ b/plugins/xt-workflow/projects/workflow/package.json @@ -5,10 +5,10 @@ "url": "https://github.com/dont-code/ng-xtend.git" }, "peerDependencies": { - "@angular/common": "^20.0.0", - "@angular/core": "^20.0.0", - "xt-components": ">= 0.6.4", - "xt-store": ">= 0.6.4" + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", + "xt-components": ">= 0.7.0", + "xt-store": ">= 0.7.0" }, "dependencies": { "tslib": "^2.3.0" diff --git a/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.css b/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.css new file mode 100644 index 00000000..eeeaa748 --- /dev/null +++ b/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.css @@ -0,0 +1,140 @@ +:host { + display: block; +} + +:host ::ng-deep .p-carousel-viewport { + padding: 2rem 0.5rem; +} + +.carousel--empty { + padding-top: 0.5rem; +} + +:host-context(.app-dark) .carousel__panel, +:host-context(.app-dark) .carousel__state { + background: color-mix(in srgb, var(--p-surface-900) 94%, var(--p-primary-950) 6%); +} + +:host-context(.app-dark) .carousel__panel--selected { + border-color: var(--p-primary-500); + background: color-mix(in srgb, var(--p-surface-900) 90%, var(--p-primary-950) 10%); + box-shadow: + 0 4px 6px rgb(0 0 0 / 0.25), + 0 20px 60px rgb(0 0 0 / 0.35), + 0 0 32px color-mix(in srgb, var(--p-primary-500) 25%, transparent 75%); +} + +@media (max-width: 767px) { + + .carousel__state { + min-height: 14rem; + padding: 1.5rem; + } +} + +.carousel__panel { + border-radius: 1.5rem; + background: color-mix(in srgb, var(--p-surface-0) 92%, var(--p-primary-50) 8%); + box-shadow: 0 1px 2px rgb(0 0 0 / 0.04), 0 14px 40px rgb(0 0 0 / 0.06); + border: 1px solid color-mix(in srgb, var(--p-surface-300) 50%, transparent 50%); + transition: + transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.35s ease, + border-color 0.35s ease, + opacity 0.35s ease, + filter 0.35s ease; + margin: 0 0.5rem; + padding: 1.75rem; + position: relative; +} + +.carousel__panel--selected { + border-color: var(--p-primary-400); + border-width: 2px; + box-shadow: + 0 4px 12px rgb(0 0 0 / 0.08), + 0 24px 64px rgb(0 0 0 / 0.12), + 0 0 32px color-mix(in srgb, var(--p-primary-400) 20%, transparent 80%); + background: color-mix(in srgb, var(--p-surface-0) 80%, var(--p-primary-50) 20%); + z-index: 2; +} + +.carousel__panel--selected-scale { + transform: scale(1.12); +} + +:host ::ng-deep .p-carousel-item { + display: flex; + flex-direction: column; +} + +.carousel__panel--editing { + flex: 1; + min-height: 0; + box-sizing: border-box; + overflow-y: auto; +} + +.carousel__state { + min-height: 18rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 0.75rem; + text-align: center; + border-radius: 1.5rem; + padding: 2rem; + background: color-mix(in srgb, var(--p-surface-0) 94%, var(--p-primary-50) 6%); + color: var(--p-text-color); +} + +.carousel__state i { + font-size: 2rem; + opacity: 0.55; +} + +.carousel__state span { + font-weight: 600; +} + +.carousel__state small { + opacity: 0.7; +} + +.carousel__state--empty { + min-height: 16rem; +} + +.carousel__edit-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + width: 2rem; + height: 2rem; + border-radius: 50%; + border: none; + background: var(--p-primary-400); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.5; + transition: opacity 0.2s ease; +} + +.carousel__edit-btn:hover { + opacity: 1; +} + +.carousel__edit-btn i { + font-size: 0.85rem; +} + +.carousel__actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + justify-content: flex-end; +} diff --git a/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.html b/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.html new file mode 100644 index 00000000..ff04cc7b --- /dev/null +++ b/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.html @@ -0,0 +1,59 @@ +@let entName = entityName(); + +@if (entName != null) { + + @if (store?.loading()) { + + } @else if ((store?.entities()?.length ?? 0) === 0) { + + } @else { + + + + @let isEditing = editingEntity() === elt; + @let isSelected = selectedElement() === elt; + @let isHorizontal = carouselOrientation() === 'horizontal'; + + + + } + +} @else { + +} diff --git a/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.spec.ts b/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.spec.ts new file mode 100644 index 00000000..1597165e --- /dev/null +++ b/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.spec.ts @@ -0,0 +1,310 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CarouselComponent } from './carousel.component'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { registerDefaultPlugin } from 'xt-plugin-default'; +import { StoreTestBed } from 'xt-store'; +import { XtResolverService, XtUnitTestHelper } from 'xt-components'; +import { DcWorkflowModel } from 'dc-workflow'; +import { By } from '@angular/platform-browser'; +import { Carousel } from 'primeng/carousel'; +import { FormGroup } from '@angular/forms'; + +describe('Carousel Component', () => { + let component: CarouselComponent; + let fixture: ComponentFixture>; + let storeTestBed: StoreTestBed; + let resolver: XtResolverService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CarouselComponent], + }).compileComponents(); + + registerDefaultPlugin(TestBed.inject(XtResolverService)); + storeTestBed = new StoreTestBed(); + StoreTestBed.ensureMemoryProviderOnly(); + + }); + + it('should create', async () => { + fixture = TestBed.createComponent(CarouselComponent); + fixture.componentRef.setInput('config',{}); + component = fixture.componentInstance; + await fixture.whenStable(); + expect(component).toBeTruthy(); + }); + + it ('should display carousel', async () => { + + await storeTestBed.defineTestDataFor('CarouselTest', [{ + name: 'Test Book', + published: new Date(1970,10, 5) + }, { + name: 'Another Book', + published: new Date(2010,7, 15) + } + ]); + + fixture = TestBed.createComponent(CarouselComponent); + fixture.componentRef.setInput ('config', { + entity: 'CarouselTest', + workflow: 'carousel', + data: { + sort: { + 'published':'descending' + } + }, + selection: { + field: { + key: 'published', + type: 'closest-before' + } + } + } as DcWorkflowModel); + + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + const carouselComponent = fixture.debugElement.query(By.directive(Carousel)); + return carouselComponent != null; + }); + + let carouselComponent = fixture.debugElement.query(By.directive(Carousel)); + expect(carouselComponent).toBeTruthy(); + + expect(carouselComponent.nativeElement.textContent?.indexOf('Another Book')).not.toBe(-1); + }); + + it('should select element on click', async () => { + await storeTestBed.defineTestDataFor('CarouselTest', [{ + name: 'Test Book', + published: new Date(1970,10, 5) + }, { + name: 'Another Book', + published: new Date(2010,7, 15) + }]); + + fixture = TestBed.createComponent(CarouselComponent); + fixture.componentRef.setInput('config', { + entity: 'CarouselTest', + workflow: 'carousel', + data: { sort: { 'published': 'descending' } }, + selection: { field: { key: 'published', type: 'closest-before' } } + } as DcWorkflowModel); + + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + const panels = fixture.debugElement.queryAll(By.css('.carousel__panel')); + return panels.length > 0; + }); + + const panels = fixture.debugElement.queryAll(By.css('.carousel__panel')); + panels[0].nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.selectedElement()).toBeTruthy(); + expect(component.selectedElement()?.name).toEqual('Another Book'); + }); + + it('should show edit button on selected element and enter edit mode', async () => { + await storeTestBed.defineTestDataFor('CarouselTest', [{ + name: 'Test Book', + published: new Date(1970,10, 5) + }, { + name: 'Another Book', + published: new Date(2010,7, 15) + }]); + + fixture = TestBed.createComponent(CarouselComponent); + fixture.componentRef.setInput('config', { + entity: 'CarouselTest', + workflow: 'carousel', + data: { sort: { 'published': 'descending' } }, + selection: { field: { key: 'published', type: 'closest-before' } } + } as DcWorkflowModel); + + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + return fixture.debugElement.queryAll(By.css('.carousel__panel')).length > 0; + }); + + const panels = fixture.debugElement.queryAll(By.css('.carousel__panel')); + panels[0].nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + return fixture.debugElement.query(By.css('.carousel__edit-btn')) != null; + }); + + const editBtn = fixture.debugElement.query(By.css('.carousel__edit-btn')); + expect(editBtn).toBeTruthy(); + + editBtn.nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect((component as any).editingEntity()).toBeTruthy(); + expect((component as any).editingEntity()).toEqual(component.selectedElement()); + + const saveBtn = fixture.debugElement.query(By.css('.carousel__actions p-button')); + expect(saveBtn).toBeTruthy(); + }); + + it('should cancel edit mode', async () => { + await storeTestBed.defineTestDataFor('CarouselTest', [{ + name: 'Test Book', + published: new Date(1970,10, 5) + }, { + name: 'Another Book', + published: new Date(2010,7, 15) + }]); + + fixture = TestBed.createComponent(CarouselComponent); + fixture.componentRef.setInput('config', { + entity: 'CarouselTest', + workflow: 'carousel', + data: { sort: { 'published': 'descending' } }, + selection: { field: { key: 'published', type: 'closest-before' } } + } as DcWorkflowModel); + + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + return fixture.debugElement.queryAll(By.css('.carousel__panel')).length > 0; + }); + + const panels = fixture.debugElement.queryAll(By.css('.carousel__panel')); + panels[0].nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + return fixture.debugElement.query(By.css('.carousel__edit-btn')) != null; + }); + + const editBtn = fixture.debugElement.query(By.css('.carousel__edit-btn')); + editBtn.nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect((component as any).editingEntity()).toBeTruthy(); + + component.cancelEdit(); + fixture.detectChanges(); + + expect((component as any).editingEntity()).toBeNull(); + expect((component as any).canSave()).toBeFalsy(); + }); + + it('should modify form and enable save', async () => { + await storeTestBed.defineTestDataFor('CarouselTest', [{ + name: 'Test Book', + published: new Date(1970,10, 5) + }]); + + fixture = TestBed.createComponent(CarouselComponent); + fixture.componentRef.setInput('config', { + entity: 'CarouselTest', + workflow: 'carousel' + } as DcWorkflowModel); + + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + return fixture.debugElement.queryAll(By.css('.carousel__panel')).length > 0; + }); + + const panels = fixture.debugElement.queryAll(By.css('.carousel__panel')); + panels[0].nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + return fixture.debugElement.query(By.css('.carousel__edit-btn')) != null; + }); + + const editBtn = fixture.debugElement.query(By.css('.carousel__edit-btn')); + editBtn.nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect((component as any).editingEntity()).toBeTruthy(); + expect((component as any).editForm()).toBeTruthy(); + + const editorForm = (component as any).editForm().get('editor') as FormGroup; + expect(editorForm).toBeTruthy(); + expect(editorForm.contains('name')).toBeTruthy(); + + editorForm.get('name')?.setValue('Modified Book'); + editorForm.markAsDirty(); + fixture.detectChanges(); + + expect((component as any).canSave()).toBeTruthy(); + }); + + it('should save entity after edit', async () => { + await storeTestBed.defineTestDataFor('CarouselTest', [{ + name: 'Test Book', + published: new Date(1970,10, 5) + }]); + + fixture = TestBed.createComponent(CarouselComponent); + fixture.componentRef.setInput('config', { + entity: 'CarouselTest', + workflow: 'carousel' + } as DcWorkflowModel); + + component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + return fixture.debugElement.queryAll(By.css('.carousel__panel')).length > 0; + }); + + const panels = fixture.debugElement.queryAll(By.css('.carousel__panel')); + panels[0].nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + await XtUnitTestHelper.waitFor(() => { + return fixture.debugElement.query(By.css('.carousel__edit-btn')) != null; + }); + + const editBtn = fixture.debugElement.query(By.css('.carousel__edit-btn')); + editBtn.nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const editorForm = (component as any).editForm().get('editor') as FormGroup; + editorForm.get('name')?.setValue('Updated Book'); + editorForm.markAsDirty(); + fixture.detectChanges(); + + expect((component as any).canSave()).toBeTruthy(); + + await component.saveEdit(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect((component as any).editingEntity()).toBeNull(); + + const updated = (component as any).safeFindStore().entities().find((e: any) => e.name === 'Updated Book'); + expect(updated).toBeTruthy(); + }); +}); diff --git a/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.ts b/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.ts new file mode 100644 index 00000000..ae7c3ab7 --- /dev/null +++ b/plugins/xt-workflow/projects/workflow/src/lib/carousel/carousel.component.ts @@ -0,0 +1,151 @@ +import { Component, computed, effect, inject, linkedSignal, OnDestroy, OnInit, signal, ViewChild } from '@angular/core'; +import { AbstractDcWorkflow } from 'dc-workflow'; +import { XtMessageHandler, XtRenderComponent, updateFormGroupWithValue } from 'xt-components'; +import { ManagedData } from 'xt-type'; +import { Carousel } from 'primeng/carousel'; +import { ProgressSpinner } from 'primeng/progressspinner'; +import { Button } from 'primeng/button'; +import { FormBuilder, FormGroup, ReactiveFormsModule, PristineChangeEvent } from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { BreakpointObserver, Breakpoints, LayoutModule } from '@angular/cdk/layout'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { map } from 'rxjs'; + +/** + * A workflow displaying the list through a carousel. It automatically selects the first element to display + */ +@Component({ + selector: 'wfw-carousel', + imports: [ + Carousel, + ProgressSpinner, XtRenderComponent, + ReactiveFormsModule, Button + ], + templateUrl: './carousel.component.html', + styleUrl: './carousel.component.css', +}) +export class CarouselComponent extends AbstractDcWorkflow implements OnInit, OnDestroy { + + protected bkObserver = inject(BreakpointObserver); + protected formBuilder = inject(FormBuilder); + + isPortrait=toSignal(this.bkObserver.observe(Breakpoints.HandsetPortrait).pipe(map (result => { + return result.matches; + }))); + + numVisible = computed(() => { + if (this.isPortrait()) return 1; + else return 3; + }); + + carouselOrientation = computed<'horizontal'|'vertical'>(() => { + if (this.isPortrait()) return 'vertical'; + else return 'horizontal'; + }); + + selectedElement = signal(null); + protected editingEntity = signal(null); + protected editForm = signal(this.formBuilder.group({})); + protected canSave = signal(false); + protected saving = signal(false); + private subscriptions = new Subscription(); + + @ViewChild('carouselRef') carousel?: Carousel; + + constructor() { + super(); + effect(() => { + const items = this.displayableElements(); + if (items.length > 0 && this.selectedElement() == null) { + const numVis = this.numVisible(); + const center = Math.floor((numVis - 1) / 2); + this.selectedElement.set(items[Math.min(center, items.length - 1)]); + } + }); + } + + override ngOnInit() { + super.ngOnInit(); + this.fetchFromStore(); + } + + selectElement(element: T): void { + this.selectedElement.set(element); + const items = this.displayableElements(); + const index = items.indexOf(element); + if (index === -1) return; + const total = items.length; + const offset = Math.floor((this.numVisible() - 1) / 2); + const targetPage = Math.max(0, Math.min(index - offset, total - this.numVisible())); + if (this.carousel) { + this.carousel.page = targetPage; + } + } + + onCarouselPage(event: any): void { + const page = event.page ?? 0; + const items = this.displayableElements(); + if (items.length === 0) return; + const numVis = this.numVisible(); + const centerIndex = page + Math.floor((numVis - 1) / 2); + const index = Math.min(centerIndex, items.length - 1); + this.selectedElement.set(items[index]); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + editEntity(element: T): void { + if (this.saving()) return; + this.selectElement(element); + this.editingEntity.set(element); + this.updateEditForm(); + } + + cancelEdit(): void { + this.editingEntity.set(null); + this.canSave.set(false); + this.saving.set(false); + this.subscriptions.unsubscribe(); + this.subscriptions = new Subscription(); + } + + async saveEdit(): Promise { + const toSave = this.editForm().value?.editor as T; + if (toSave == null) { + throw new Error("Trying to save an entity that is not bound to form"); + } + try { + this.saving.set(true); + const savedValue = await this.safeFindStore().storeEntity(toSave); + this.cancelEdit(); + this.selectElement(savedValue); + await this.fetchFromStore(); + } catch (error) { + this.errorHandler.errorOccurred(error, "Error saving entity with id " + (toSave as any)._id); + this.saving.set(false); + } + } + + private updateEditForm(): void { + const entity = this.editingEntity(); + const form = this.formBuilder.group({}, {updateOn: 'change'}); + if (entity != null) { + updateFormGroupWithValue(form, entity, this.entityName(), this.resolver.typeResolver); + } + this.listenToFormEvent(form); + this.editForm.set(this.formBuilder.group({ editor: form })); + } + + private listenToFormEvent(form: FormGroup): void { + this.subscriptions.unsubscribe(); + this.subscriptions = new Subscription(); + this.subscriptions.add(form.events.subscribe(event => { + const pristine = (event as PristineChangeEvent).pristine ?? true; + if (!pristine) { + this.canSave.set(true); + } + })); + } +} diff --git a/plugins/xt-workflow/projects/workflow/src/lib/list-details/list-details.component.spec.ts b/plugins/xt-workflow/projects/workflow/src/lib/list-details/list-details.component.spec.ts index 6ea726b9..8fabc0fa 100644 --- a/plugins/xt-workflow/projects/workflow/src/lib/list-details/list-details.component.spec.ts +++ b/plugins/xt-workflow/projects/workflow/src/lib/list-details/list-details.component.spec.ts @@ -10,7 +10,6 @@ import { By } from '@angular/platform-browser'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { MessageService } from 'primeng/api'; import { DcWorkflowModel } from 'dc-workflow'; -import { RouterTestingHarness } from '@angular/router/testing'; import { FormGroup } from '@angular/forms'; import { Table } from 'primeng/table'; diff --git a/plugins/xt-workflow/projects/workflow/src/lib/list-details/list-details.component.ts b/plugins/xt-workflow/projects/workflow/src/lib/list-details/list-details.component.ts index df00ef21..afde5d42 100644 --- a/plugins/xt-workflow/projects/workflow/src/lib/list-details/list-details.component.ts +++ b/plugins/xt-workflow/projects/workflow/src/lib/list-details/list-details.component.ts @@ -30,11 +30,6 @@ import { AbstractDcWorkflow } from 'dc-workflow'; }) export class ListDetailsComponent extends AbstractDcWorkflow implements OnInit, OnDestroy { protected readonly formBuilder = inject(FormBuilder); - protected readonly errorHandler = inject(XtMessageHandler); - - entityName = linkedSignal( () => { - return this.config().entity; - }); editForm = signal(this.formBuilder.group ({ editor: this.formBuilder.group({}) })); @@ -64,12 +59,12 @@ export class ListDetailsComponent extends AbstractDcWorkf formValue = signal(null); - protected storeChanged=signal(false); - + /** + * Display in the list the selected item being edited if any + */ displayedEntities = computed(() => { // console.debug("ListDetails init Displayed Entities"); - const storeChanged=this.storeChanged(); // Enforce recalculation whenever the store is changed - const entities = this.store?.entities() ?? []; + const entities = this.displayableElements(); const value = this.formValue(); const selected = this.selectedEntity(); if (value != null && selected != null && (selected as any)._id != null) { @@ -89,7 +84,6 @@ export class ListDetailsComponent extends AbstractDcWorkf saving = signal (false); newing = signal (false); deleting = signal (false); - updating = signal (false); selectedEntity= model(); @@ -119,31 +113,6 @@ export class ListDetailsComponent extends AbstractDcWorkf this.fetchFromStore(); } - fetchFromStore () { - //console.debug("ListDetails fetchFromStore"); - const entityName = this.entityName(); - if (entityName!=null) { - try { - this.updating.set(true); - // If the entity is different, then we must change the whole store - if (this.store?.entityName()!=entityName) { - this.store = this.safeFindStore(); - this.storeChanged.update((oldVal)=> !oldVal); // Force update whenever the store changed - } - // console.debug("Store set to "+this.store.entityName()); - this.store.fetchEntities().catch((error) => { - this.errorHandler.errorOccurred(error, "Error loading entities "+entityName); - }).finally(() => { - this.updating.set(false); -// console.debug("Store fetched values ",this.store?.entities()); - });//.then(() => {console.debug('Yes')}).finally(() => {console.debug('Finish')}); - } catch (error) { - this.updating.set(false); - } - } else { - this.store = null; - } - } updateEditForm () { const entity = this.selectedEntity(); diff --git a/plugins/xt-workflow/projects/workflow/src/lib/register.ts b/plugins/xt-workflow/projects/workflow/src/lib/register.ts index 75778733..2b0ecd28 100644 --- a/plugins/xt-workflow/projects/workflow/src/lib/register.ts +++ b/plugins/xt-workflow/projects/workflow/src/lib/register.ts @@ -1,5 +1,6 @@ import { XtResolverService } from 'xt-components'; import { ListDetailsComponent } from './list-details/list-details.component'; +import { CarouselComponent } from './carousel/carousel.component'; export function registerWorkflowPlugin (resolverService:XtResolverService):boolean { const pluginName = "Plugin Workflow"; @@ -10,7 +11,11 @@ export function registerWorkflowPlugin (resolverService:XtResolverService):boole name:'wfwListDetails', class:ListDetailsComponent, workflowsHandled:['list-detail'] - } + },{ + name:'wfwCarousel', + class:CarouselComponent, + workflowsHandled:['carousel'] + } ] }) return true;