@cityway/bo-ui 1.1.0-beta007 → 1.1.0-beta008

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.eslintrc.json +37 -0
  2. package/ng-package.json +17 -0
  3. package/package.json +4 -15
  4. package/src/lib/components/custom-list/custom-list.component.html +35 -0
  5. package/src/lib/components/custom-list/custom-list.component.spec.ts +27 -0
  6. package/src/lib/components/custom-list/custom-list.component.ts +37 -0
  7. package/src/lib/components/drag-n-drop-list/drag-drop.adapter.ts +77 -0
  8. package/src/lib/components/drag-n-drop-list/drag-n-drop-list.component.html +59 -0
  9. package/src/lib/components/drag-n-drop-list/drag-n-drop-list.component.spec.ts +161 -0
  10. package/src/lib/components/drag-n-drop-list/drag-n-drop-list.component.ts +69 -0
  11. package/src/lib/components/drag-n-drop-nested-list/drag-n-drop-nested-list.component.html +119 -0
  12. package/src/lib/components/drag-n-drop-nested-list/drag-n-drop-nested-list.component.ts +224 -0
  13. package/src/lib/components/export-button/export-button.component.html +30 -0
  14. package/src/lib/components/export-button/export-button.component.spec.ts +293 -0
  15. package/src/lib/components/export-button/export-button.component.ts +160 -0
  16. package/src/lib/components/filter-bar/filter-bar.component.html +31 -0
  17. package/src/lib/components/filter-bar/filter-bar.component.ts +175 -0
  18. package/src/lib/components/import-button/import-button.component.html +8 -0
  19. package/src/lib/components/import-button/import-button.component.spec.ts +138 -0
  20. package/src/lib/components/import-button/import-button.component.ts +72 -0
  21. package/src/lib/components/import-wizard/import-wizard.component.html +314 -0
  22. package/src/lib/components/import-wizard/import-wizard.component.spec.ts +854 -0
  23. package/src/lib/components/import-wizard/import-wizard.component.ts +589 -0
  24. package/src/lib/components/page-header/page-header.component.html +62 -0
  25. package/src/lib/components/page-header/page-header.component.spec.ts +98 -0
  26. package/src/lib/components/page-header/page-header.component.ts +128 -0
  27. package/src/lib/components/pagination/pagination.component.html +93 -0
  28. package/src/lib/components/pagination/pagination.component.ts +126 -0
  29. package/src/lib/components/progress-bar/progress-bar.component.html +14 -0
  30. package/src/lib/components/progress-bar/progress-bar.component.spec.ts +54 -0
  31. package/src/lib/components/progress-bar/progress-bar.component.ts +52 -0
  32. package/src/lib/components/table/table.component.html +258 -0
  33. package/src/lib/components/table/table.component.spec.ts +262 -0
  34. package/src/lib/components/table/table.component.ts +382 -0
  35. package/src/lib/components/wizard-stepper/components/wizard-step.component.spec.ts +27 -0
  36. package/src/lib/components/wizard-stepper/components/wizard-step.component.ts +15 -0
  37. package/src/lib/components/wizard-stepper/wizard-stepper.component.html +49 -0
  38. package/src/lib/components/wizard-stepper/wizard-stepper.component.spec.ts +28 -0
  39. package/src/lib/components/wizard-stepper/wizard-stepper.component.ts +75 -0
  40. package/src/lib/models/custom-list.model.ts +14 -0
  41. package/src/lib/models/drag-item.model.ts +112 -0
  42. package/src/lib/models/export.model.ts +47 -0
  43. package/src/lib/models/filter-config.model.ts +14 -0
  44. package/src/lib/models/i18n-key.model.ts +23 -0
  45. package/src/lib/models/import-wizard.model.ts +316 -0
  46. package/src/lib/models/page-header.model.ts +16 -0
  47. package/src/lib/models/pagination.model.ts +11 -0
  48. package/src/lib/models/progress-bar.model.ts +16 -0
  49. package/src/lib/models/table.model.ts +163 -0
  50. package/src/lib/styles/scss/bo-ui.styles.scss +10 -0
  51. package/src/lib/styles/scss/components/_custom-list.scss +20 -0
  52. package/src/lib/styles/scss/components/_drag-n-drop-list.scss +33 -0
  53. package/src/lib/styles/scss/components/_drag-n-drop-nested-list.scss +44 -0
  54. package/src/lib/styles/scss/components/_filter-bar.scss +19 -0
  55. package/src/lib/styles/scss/components/_import-wizard.scss +62 -0
  56. package/src/lib/styles/scss/components/_page-header.scss +11 -0
  57. package/src/lib/styles/scss/components/_pagination.scss +49 -0
  58. package/src/lib/styles/scss/components/_progress-bar.scss +73 -0
  59. package/src/lib/styles/scss/components/_table.scss +56 -0
  60. package/src/lib/utils/column-storage.util.ts +51 -0
  61. package/src/lib/utils/count-i18n.util.ts +57 -0
  62. package/src/lib/utils/download.util.spec.ts +93 -0
  63. package/src/lib/utils/download.util.ts +56 -0
  64. package/src/lib/utils/filter-params.util.ts +26 -0
  65. package/src/public-api.ts +41 -0
  66. package/tsconfig.lib.json +25 -0
  67. package/tsconfig.lib.prod.json +11 -0
  68. package/tsconfig.spec.json +14 -0
  69. package/tslint.json +17 -0
  70. package/cityway-bo-ui-1.1.0-beta005.tgz +0 -0
  71. package/cityway-bo-ui-1.1.0-beta006.tgz +0 -0
  72. package/cityway-bo-ui-1.1.0-beta007.tgz +0 -0
  73. package/fesm2022/cityway-bo-ui.mjs +0 -2079
  74. package/fesm2022/cityway-bo-ui.mjs.map +0 -1
  75. package/index.d.ts +0 -1433
  76. package/styles/bo-ui.styles.min.css +0 -1
  77. package/styles/bo-ui.styles.min.css.map +0 -1
  78. /package/{lib → src/lib}/assets/i18n/common.en.json +0 -0
  79. /package/{lib → src/lib}/assets/i18n/common.fr.json +0 -0
package/.eslintrc.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "extends": "../../.eslintrc.json",
3
+ "ignorePatterns": [
4
+ "!**/*"
5
+ ],
6
+ "overrides": [
7
+ {
8
+ "files": [
9
+ "*.ts"
10
+ ],
11
+ "rules": {
12
+ "@angular-eslint/directive-selector": [
13
+ "error",
14
+ {
15
+ "type": "attribute",
16
+ "prefix": "cw",
17
+ "style": "camelCase"
18
+ }
19
+ ],
20
+ "@angular-eslint/component-selector": [
21
+ "error",
22
+ {
23
+ "type": "element",
24
+ "prefix": "cw",
25
+ "style": "kebab-case"
26
+ }
27
+ ]
28
+ }
29
+ },
30
+ {
31
+ "files": [
32
+ "*.html"
33
+ ],
34
+ "rules": {}
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/bo-ui",
4
+ "lib": {
5
+ "entryFile": "src/public-api.ts"
6
+ },
7
+ "allowedNonPeerDependencies": [
8
+ "tslib"
9
+ ],
10
+ "assets": [
11
+ {
12
+ "glob": "**/*",
13
+ "input": "src/lib/assets",
14
+ "output": "lib/assets"
15
+ }
16
+ ]
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cityway/bo-ui",
3
- "version": "1.1.0-beta007",
3
+ "version": "1.1.0-beta008",
4
4
  "peerDependencies": {
5
5
  "@angular/common": "^20.3.0",
6
6
  "@angular/core": "^20.3.0",
@@ -9,21 +9,10 @@
9
9
  "@angular/cdk": "^20.2.2",
10
10
  "@angular/platform-browser": "^20.3.0",
11
11
  "@angular/router": "^20.3.0",
12
- "@cityway/basic-ui": "^1.1.0-beta007"
12
+ "@cityway/basic-ui": "^1.1.0-beta008"
13
13
  },
14
14
  "dependencies": {
15
15
  "tslib": "^2.8.0"
16
16
  },
17
- "sideEffects": false,
18
- "module": "fesm2022/cityway-bo-ui.mjs",
19
- "typings": "index.d.ts",
20
- "exports": {
21
- "./package.json": {
22
- "default": "./package.json"
23
- },
24
- ".": {
25
- "types": "./index.d.ts",
26
- "default": "./fesm2022/cityway-bo-ui.mjs"
27
- }
28
- }
29
- }
17
+ "sideEffects": false
18
+ }
@@ -0,0 +1,35 @@
1
+ <div class="card custom-card-light">
2
+ <div class="card-body d-flex-v gap-md">
3
+ @for (group of customListData(); track group.title) {
4
+ @if (group.title) {
5
+ <h3>{{ group.title | translate }}</h3>
6
+ }
7
+
8
+ <ul class="custom-description-list list-unstyled fs fs-sm">
9
+ @for (field of group.fields; track field.label) {
10
+ @if (!field.hideIfEmpty || hasValue(field.value)) {
11
+ <li>
12
+ <div class="fs fw-bold" [innerHTML]="'_LABEL.COLON' | translate: { label: (field.label | translate) }"></div>
13
+ <div>
14
+ @switch (field.type) {
15
+ @case ('date') {
16
+ {{ field.value | date: 'short' }}
17
+ }
18
+ @case ('boolean') {
19
+ {{ (field.value ? '_COMMON._PRO_SENTENCE.YES' : '_COMMON._PRO_SENTENCE.NO') | translate }}
20
+ }
21
+ @case ('html') {
22
+ <span [innerHTML]="field.value"></span>
23
+ }
24
+ @default {
25
+ {{ field.translateValue ? (field.value | translate) : field.value }}
26
+ }
27
+ }
28
+ </div>
29
+ </li>
30
+ }
31
+ }
32
+ </ul>
33
+ }
34
+ </div>
35
+ </div>
@@ -0,0 +1,27 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { TranslateModule } from '@ngx-translate/core';
3
+ import { CustomListComponent } from './custom-list.component';
4
+
5
+ describe('CustomListComponent', () => {
6
+ let component: CustomListComponent;
7
+ let fixture: ComponentFixture<CustomListComponent>;
8
+
9
+ beforeEach(async () => {
10
+ // Composant standalone : il s'importe, il ne se declare pas. `TranslateModule.forRoot()`
11
+ // fournit le `TranslateStore` dont depend le pipe `translate` du template.
12
+ await TestBed.configureTestingModule({
13
+ imports: [CustomListComponent, TranslateModule.forRoot()]
14
+ })
15
+ .compileComponents();
16
+ });
17
+
18
+ beforeEach(() => {
19
+ fixture = TestBed.createComponent(CustomListComponent);
20
+ component = fixture.componentInstance;
21
+ fixture.detectChanges();
22
+ });
23
+
24
+ it('should create', () => {
25
+ expect(component).toBeTruthy();
26
+ });
27
+ });
@@ -0,0 +1,37 @@
1
+ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
2
+ import { DataCustomListGroup } from '../../models/custom-list.model';
3
+ import { TranslateModule } from '@ngx-translate/core';
4
+ import { DatePipe } from '@angular/common';
5
+
6
+ @Component({
7
+ selector: 'cw-custom-list',
8
+ templateUrl: './custom-list.component.html',
9
+ standalone: true,
10
+ changeDetection: ChangeDetectionStrategy.OnPush,
11
+ imports: [
12
+ DatePipe,
13
+ TranslateModule
14
+ ]
15
+ })
16
+ export class CustomListComponent {
17
+
18
+ readonly customListData = input<DataCustomListGroup[]>([]);
19
+
20
+ hasValue(value: any): boolean {
21
+ if (value === null || value === undefined) {
22
+ return false;
23
+ }
24
+ if (typeof value === 'string' && value.trim() === '') {
25
+ return false;
26
+ }
27
+ if (Array.isArray(value) && value.length === 0) {
28
+ return false;
29
+ }
30
+ if (typeof value === 'object' && Object.keys(value).length === 0) {
31
+ return false;
32
+ }
33
+
34
+ return true;
35
+ }
36
+
37
+ }
@@ -0,0 +1,77 @@
1
+ import { AbstractControl, FormArray } from '@angular/forms';
2
+ import { DragItem } from '../../models/drag-item.model';
3
+
4
+
5
+ export interface DragDropAdapter<T = any> {
6
+ toDragItems(source: T): DragItem[];
7
+ fromDragItems(items: DragItem[], source: T, peers?: T[]): void;
8
+ }
9
+
10
+ // Adaptateur pour DragItem[] (identité)
11
+ export class ArrayAdapter implements DragDropAdapter<DragItem[]> {
12
+ toDragItems(source: DragItem[]): DragItem[] {
13
+ return source;
14
+ }
15
+ fromDragItems(items: DragItem[], source: DragItem[], _peers?: DragItem[][]): void {
16
+ source.splice(0, source.length, ...items);
17
+ }
18
+ }
19
+
20
+ // Adaptateur pour FormArray
21
+ export class FormArrayAdapter implements DragDropAdapter<FormArray> {
22
+
23
+ constructor(
24
+ private readonly mapper: (ctrl: AbstractControl, index: number) => DragItem,
25
+ private readonly reorderFn?: (ctrl: AbstractControl, newOrder: number) => void
26
+ ) {}
27
+
28
+ toDragItems(source: FormArray): DragItem[] {
29
+ return source.controls.map((ctrl, i) => this.mapper(ctrl, i));
30
+ }
31
+
32
+ fromDragItems(items: DragItem[], source: FormArray, peers: FormArray[] = []): void {
33
+ // Cherche le control correspondant à un id dans la source ou les FormArray voisines
34
+ // (cas du transfert inter-colonnes : le control n'est pas encore dans `source`)
35
+ const findCtrl = (id: DragItem['id']): AbstractControl | undefined => {
36
+ const inSource = source.controls.find(c => this.mapper(c, 0).id === id);
37
+ if (inSource) return inSource;
38
+ for (const peer of peers) {
39
+ const found = peer.controls.find(c => this.mapper(c, 0).id === id);
40
+ if (found) return found;
41
+ }
42
+ return undefined;
43
+ };
44
+
45
+ const controls = items
46
+ .map(item => findCtrl(item.id))
47
+ .filter((c): c is AbstractControl => c != null);
48
+
49
+ // Détacher des peers les controls qu'on va re-pousser ici (évite la double appartenance)
50
+ for (const peer of peers) {
51
+ for (const ctrl of controls) {
52
+ const idx = peer.controls.indexOf(ctrl);
53
+ if (idx >= 0) peer.removeAt(idx, { emitEvent: false });
54
+ }
55
+ }
56
+
57
+ // Évacuer les orphelins de `source` vers `peers[0]` AVANT le clear,
58
+ // pour qu'ils soient retrouvables au prochain appel de fromDragItems
59
+ // (sinon ils seraient garbage-collectés lors d'un transfert inter-colonnes).
60
+ if (peers.length > 0) {
61
+ const wantedSet = new Set(controls);
62
+ const orphans = source.controls.filter(c => !wantedSet.has(c));
63
+ for (const orphan of orphans) {
64
+ const idx = source.controls.indexOf(orphan);
65
+ if (idx >= 0) source.removeAt(idx, { emitEvent: false });
66
+ peers[0].push(orphan, { emitEvent: false });
67
+ }
68
+ }
69
+
70
+ while (source.length) source.removeAt(0, { emitEvent: false });
71
+ controls.forEach((ctrl, i) => {
72
+ if (this.reorderFn) this.reorderFn(ctrl, i + 1);
73
+ source.push(ctrl, { emitEvent: false });
74
+ });
75
+ source.updateValueAndValidity();
76
+ }
77
+ }
@@ -0,0 +1,59 @@
1
+ <div class="drag-drop-list row">
2
+ <div class="col-lg-6 d-flex-v gap-4xs">
3
+ <div class="fs fs-xs fw-bold">{{ availableColumn().title | translate }}</div>
4
+ @if (availableColumn().description; as description) {
5
+ <p class="fs fs-xs">{{ description | translate }}</p>
6
+ }
7
+ @if (availableColumn().descriptionRwd; as descriptionRwd) {
8
+ <p class="d-sm-none fs fs-xs" [innerHTML]="descriptionRwd | translate"></p>
9
+ }
10
+ <ul class="list-unstyled drag-drop-list-column drag-drop-list-column-available" [ngClass]="availableColumn().cssClass"
11
+ cdkDropList #availableList="cdkDropList"
12
+ [cdkDropListData]="_available"
13
+ [cdkDropListConnectedTo]="[activeList]"
14
+ (cdkDropListDropped)="drop($event)">
15
+ @for (item of _available; track item.id) {
16
+ <li cdkDrag [cdkDragDisabled]="item.disabled ?? false">
17
+ <cw-card>
18
+ <ng-container *ngTemplateOutlet="itemTemplate() || defaultTemplate; context: { $implicit: item }"/>
19
+ </cw-card>
20
+ </li>
21
+ }
22
+ </ul>
23
+ </div>
24
+
25
+ <div class="col-lg-6 d-flex-v gap-4xs">
26
+ <div class="fs fs-xs fw-bold">{{ activeColumn().title | translate }}</div>
27
+ @if (activeColumn().description; as description) {
28
+ <p class="fs fs-xs">{{ description | translate }}</p>
29
+ }
30
+ @if (activeColumn().descriptionRwd; as descriptionRwd) {
31
+ <p class="d-sm-none fs fs-xs" [innerHTML]="descriptionRwd | translate"></p>
32
+ }
33
+ <ul class="list-unstyled drag-drop-list-column drag-drop-list-column-active" [ngClass]="activeColumn().cssClass"
34
+ cdkDropList #activeList="cdkDropList"
35
+ [cdkDropListData]="_active"
36
+ [cdkDropListConnectedTo]="[availableList]"
37
+ (cdkDropListDropped)="drop($event)">
38
+ @for (item of _active; track item.id) {
39
+ <li cdkDrag [cdkDragDisabled]="item.disabled ?? false">
40
+ <cw-card>
41
+ <ng-container *ngTemplateOutlet="itemTemplate() || defaultTemplate; context: { $implicit: item }"/>
42
+ </cw-card>
43
+ </li>
44
+ }
45
+ </ul>
46
+ </div>
47
+ </div>
48
+
49
+ <ng-template #defaultTemplate let-item>
50
+ <div class="default-item d-flex justify-content-between">
51
+ <span>
52
+ {{ item.label }}
53
+ @if (item.code) { <span class="text-muted">({{ item.code }})</span> }
54
+ </span>
55
+ @if (item.order != null) {
56
+ <span class="fw-bold">#{{ item.order }}</span>
57
+ }
58
+ </div>
59
+ </ng-template>
@@ -0,0 +1,161 @@
1
+ import { CdkDragDrop } from '@angular/cdk/drag-drop';
2
+ import { ComponentRef } from '@angular/core';
3
+ import { TestBed } from '@angular/core/testing';
4
+ import { AbstractControl, FormArray, FormControl, FormGroup } from '@angular/forms';
5
+ import { TranslateModule } from '@ngx-translate/core';
6
+ import { DragNDropListComponent } from './drag-n-drop-list.component';
7
+ import { FormArrayAdapter } from './drag-drop.adapter';
8
+ import { DragItem } from '../../models/drag-item.model';
9
+
10
+ describe('DragNDropListComponent', () => {
11
+
12
+ // Le TestBed n'etait pas configure du tout : `TranslateModule.forRoot()` fournit le
13
+ // `TranslateStore` dont depend le pipe `translate` du template.
14
+ beforeEach(async () => {
15
+ await TestBed.configureTestingModule({
16
+ imports: [DragNDropListComponent, TranslateModule.forRoot()]
17
+ }).compileComponents();
18
+ });
19
+
20
+ it('should create', () => {
21
+ const fixture = TestBed.createComponent(DragNDropListComponent);
22
+ expect(fixture.componentInstance).toBeTruthy();
23
+ });
24
+
25
+ describe('FormArrayAdapter integration', () => {
26
+ let component: DragNDropListComponent;
27
+ let componentRef: ComponentRef<DragNDropListComponent>;
28
+ let availableItems: FormArray;
29
+ let activeItems: FormArray;
30
+
31
+ const makeRow = (id: string, order: number): FormGroup =>
32
+ new FormGroup({
33
+ id: new FormControl(id),
34
+ order: new FormControl(order),
35
+ });
36
+
37
+ const setup = (left: AbstractControl[], right: AbstractControl[]): void => {
38
+ availableItems = new FormArray<any>(left);
39
+ activeItems = new FormArray<any>(right);
40
+
41
+ const fixture = TestBed.createComponent(DragNDropListComponent);
42
+ component = fixture.componentInstance;
43
+ componentRef = fixture.componentRef;
44
+
45
+ componentRef.setInput('adapter', new FormArrayAdapter(
46
+ c => ({ id: c.get('id')!.value, label: c.get('id')!.value, order: c.get('order')!.value }),
47
+ (c, n) => c.get('order')!.setValue(n, { emitEvent: false }),
48
+ ));
49
+ componentRef.setInput('availableItems', availableItems);
50
+ componentRef.setInput('activeItems', activeItems);
51
+ componentRef.setInput('reorderOnDrop', true);
52
+
53
+ // Trigger the effect that copies inputs into _available / _active
54
+ fixture.detectChanges();
55
+ };
56
+
57
+ const dropEvent = (
58
+ from: 'available' | 'active',
59
+ to: 'available' | 'active',
60
+ previousIndex: number,
61
+ currentIndex: number,
62
+ ): CdkDragDrop<DragItem[]> => ({
63
+ previousContainer: { data: from === 'available' ? component._available : component._active },
64
+ container: { data: to === 'available' ? component._available : component._active },
65
+ previousIndex,
66
+ currentIndex,
67
+ } as unknown as CdkDragDrop<DragItem[]>);
68
+
69
+ it('transfers a control from active to available without losing it', () => {
70
+ setup([makeRow('A', 1)], [makeRow('B', 1)]);
71
+ const bCtrl = activeItems.at(0);
72
+
73
+ component.drop(dropEvent('active', 'available', 0, 1));
74
+
75
+ expect(availableItems.controls.length).toBe(2);
76
+ expect(activeItems.controls.length).toBe(0);
77
+ expect(availableItems.controls.indexOf(bCtrl)).not.toBe(-1);
78
+ expect(activeItems.controls.indexOf(bCtrl)).toBe(-1);
79
+ expect(bCtrl.parent).toBe(availableItems);
80
+ expect(bCtrl.get('order')!.value).toBe(2);
81
+ });
82
+
83
+ it('emits valueChanges only once per FormArray during a transfer', () => {
84
+ setup([makeRow('A', 1)], [makeRow('B', 1)]);
85
+
86
+ const availableEmits: unknown[] = [];
87
+ const activeEmits: unknown[] = [];
88
+ availableItems.valueChanges.subscribe(v => availableEmits.push(v));
89
+ activeItems.valueChanges.subscribe(v => activeEmits.push(v));
90
+
91
+ component.drop(dropEvent('active', 'available', 0, 1));
92
+
93
+ expect(availableEmits.length).toBe(1);
94
+ expect(activeEmits.length).toBe(1);
95
+ });
96
+
97
+ it('transfers a control from available to active without losing it (with sub-FormArray)', () => {
98
+ const makeRichRow = (id: string, order: number): FormGroup =>
99
+ new FormGroup({
100
+ id: new FormControl(id),
101
+ order: new FormControl(order),
102
+ children: new FormArray<any>([
103
+ new FormControl(`${id}-child-1`),
104
+ new FormControl(`${id}-child-2`),
105
+ ]),
106
+ });
107
+
108
+ availableItems = new FormArray<any>([
109
+ makeRichRow('A', 1),
110
+ makeRichRow('B', 2),
111
+ makeRichRow('C', 3),
112
+ makeRichRow('X', 4),
113
+ ]);
114
+ activeItems = new FormArray<any>([makeRichRow('Y', 1), makeRichRow('Z', 2)]);
115
+
116
+ const fixture = TestBed.createComponent(DragNDropListComponent);
117
+ component = fixture.componentInstance;
118
+ componentRef = fixture.componentRef;
119
+ componentRef.setInput('adapter', new FormArrayAdapter(
120
+ c => ({ id: c.get('id')!.value, label: c.get('id')!.value, order: c.get('order')!.value }),
121
+ (c, n) => c.get('order')!.setValue(n, { emitEvent: false }),
122
+ ));
123
+ componentRef.setInput('availableItems', availableItems);
124
+ componentRef.setInput('activeItems', activeItems);
125
+ componentRef.setInput('reorderOnDrop', true);
126
+ fixture.detectChanges();
127
+
128
+ const xCtrl = availableItems.at(3);
129
+ const xChildren = (xCtrl.get('children') as FormArray);
130
+
131
+ // Drag X from available (index 3) to active (end)
132
+ component.drop(dropEvent('available', 'active', 3, 2));
133
+
134
+ expect(availableItems.controls.length).toBe(3);
135
+ expect(activeItems.controls.length).toBe(3);
136
+ expect(activeItems.at(2)).toBe(xCtrl);
137
+ expect(xCtrl.parent).toBe(activeItems);
138
+ // The sub-FormArray must still be attached & populated — i.e. not garbage-collected
139
+ expect(xCtrl.get('children')).toBe(xChildren);
140
+ expect((xCtrl.get('children') as FormArray).length).toBe(2);
141
+ expect((xCtrl.get('children') as FormArray).at(0).value).toBe('X-child-1');
142
+ });
143
+
144
+ it('reorders intra-column without losing or duplicating controls', () => {
145
+ setup([makeRow('A', 1), makeRow('B', 2), makeRow('C', 3)], []);
146
+ const aCtrl = availableItems.at(0);
147
+ const bCtrl = availableItems.at(1);
148
+ const cCtrl = availableItems.at(2);
149
+
150
+ component.drop(dropEvent('available', 'available', 0, 2));
151
+
152
+ expect(availableItems.controls.length).toBe(3);
153
+ expect(availableItems.at(0)).toBe(bCtrl);
154
+ expect(availableItems.at(1)).toBe(cCtrl);
155
+ expect(availableItems.at(2)).toBe(aCtrl);
156
+ expect(bCtrl.get('order')!.value).toBe(1);
157
+ expect(cCtrl.get('order')!.value).toBe(2);
158
+ expect(aCtrl.get('order')!.value).toBe(3);
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,69 @@
1
+ import {
2
+ CdkDragDrop, DragDropModule,
3
+ moveItemInArray, transferArrayItem
4
+ } from '@angular/cdk/drag-drop';
5
+ import { NgClass, NgTemplateOutlet } from '@angular/common';
6
+ import { ChangeDetectionStrategy, Component, effect, input, output, TemplateRef } from '@angular/core';
7
+ import { TranslateModule } from '@ngx-translate/core';
8
+ import { ArrayAdapter, DragDropAdapter } from './drag-drop.adapter';
9
+ import { DragItem, DragListColumn } from '../../models/drag-item.model';
10
+ import { CardComponent } from '@cityway/basic-ui';
11
+
12
+ @Component({
13
+ selector: 'cw-drag-n-drop-list',
14
+ templateUrl: './drag-n-drop-list.component.html',
15
+ standalone: true,
16
+ changeDetection: ChangeDetectionStrategy.OnPush,
17
+ imports: [NgClass, NgTemplateOutlet, TranslateModule, DragDropModule, CardComponent]
18
+ })
19
+ export class DragNDropListComponent {
20
+
21
+ readonly availableItems = input<any>(undefined);
22
+ readonly activeItems = input<any>(undefined);
23
+ readonly adapter = input<DragDropAdapter>(new ArrayAdapter());
24
+ readonly itemTemplate = input<TemplateRef<any> | undefined>(undefined);
25
+
26
+ readonly availableColumn = input<DragListColumn>({ title: 'Disponibles' });
27
+ readonly activeColumn = input<DragListColumn>({ title: 'Activés' });
28
+ readonly reorderOnDrop = input(false);
29
+
30
+ readonly availableItemsChange = output<any>();
31
+ readonly activeItemsChange = output<any>();
32
+
33
+ // Copies internes sur lesquelles le CDK travaille (mutables — pas des signals)
34
+ _available: DragItem[] = [];
35
+ _active: DragItem[] = [];
36
+
37
+ constructor() {
38
+ effect(() => {
39
+ const adapter = this.adapter();
40
+ this._available = adapter.toDragItems(this.availableItems());
41
+ this._active = adapter.toDragItems(this.activeItems());
42
+ });
43
+ }
44
+
45
+ drop(event: CdkDragDrop<DragItem[]>): void {
46
+ if (event.previousContainer === event.container) {
47
+ moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
48
+ } else {
49
+ transferArrayItem(
50
+ event.previousContainer.data,
51
+ event.container.data,
52
+ event.previousIndex,
53
+ event.currentIndex
54
+ );
55
+ }
56
+
57
+ if (this.reorderOnDrop()) {
58
+ this._available.forEach((item, i) => item.order = i + 1);
59
+ this._active.forEach((item, i) => item.order = i + 1);
60
+ }
61
+
62
+ const adapter = this.adapter();
63
+ adapter.fromDragItems(this._available, this.availableItems(), [this.activeItems()]);
64
+ adapter.fromDragItems(this._active, this.activeItems(), [this.availableItems()]);
65
+
66
+ this.availableItemsChange.emit(this.availableItems());
67
+ this.activeItemsChange.emit(this.activeItems());
68
+ }
69
+ }