diff --git a/src/@seed/api/column/column.service.ts b/src/@seed/api/column/column.service.ts index 61a1ae00..d456c5f7 100644 --- a/src/@seed/api/column/column.service.ts +++ b/src/@seed/api/column/column.service.ts @@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http' import { inject, Injectable } from '@angular/core' import type { Observable } from 'rxjs' import { catchError, map, ReplaySubject, Subject, takeUntil } from 'rxjs' +import type { ProgressResponse } from '@seed/api/progress' import { ErrorService } from '@seed/services/error/error.service' import { UserService } from '../user' import type { Column, ColumnsResponse } from './column.types' @@ -57,4 +58,22 @@ export class ColumnService { }), ) } + + updateMultipleColumns(organization_id: number, table_name: string, changes: object): Observable { + const url = '/api/v3/columns/update_multiple/' + return this._httpClient.post(url, { organization_id, table_name, changes }).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error updating columns') + }), + ) + } + + deleteColumn(column: Column): Observable { + const url = `/api/v3/columns/${column.id}/?organization_id=${column.organization_id}` + return this._httpClient.delete(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting column') + }), + ) + } } diff --git a/src/@seed/services/uploader/index.ts b/src/@seed/services/uploader/index.ts new file mode 100644 index 00000000..b57e7fd5 --- /dev/null +++ b/src/@seed/services/uploader/index.ts @@ -0,0 +1,2 @@ +export * from './uploader.service' +export * from './uploader.types' diff --git a/src/app/core/navigation/navigation.service.ts b/src/app/core/navigation/navigation.service.ts index d30066f9..70e2a1b6 100644 --- a/src/app/core/navigation/navigation.service.ts +++ b/src/app/core/navigation/navigation.service.ts @@ -47,20 +47,12 @@ export class NavigationService { type: 'basic', }, { - id: 'organizations/column-mappings', - link: '/organizations/column-mappings/properties', - title: 'Column mappings', - icon: 'fa-solid:right-left', - type: 'basic', - regexMatch: /^\/organizations\/column-mappings\/(properties|taxlots)/, - }, - { - id: 'organizations/column-settings', - link: '/organizations/column-settings/properties', - title: 'Column Settings', + id: 'organizations/columns', + link: '/organizations/columns/list/properties', + title: 'Columns', icon: 'fa-solid:sliders', type: 'basic', - regexMatch: /^\/organizations\/column-settings\/(properties|taxlots)/, + regexMatch: /^\/organizations\/columns\/list\/(properties|taxlots)/, }, { id: 'organizations/cycles', diff --git a/src/app/modules/organizations/columns/columns.component.html b/src/app/modules/organizations/columns/columns.component.html new file mode 100644 index 00000000..37bf8ee0 --- /dev/null +++ b/src/app/modules/organizations/columns/columns.component.html @@ -0,0 +1,35 @@ + + + + + +
+

Columns

+
+
+ + + +
+
+ + + + + + + + +
diff --git a/src/app/modules/organizations/columns/columns.component.ts b/src/app/modules/organizations/columns/columns.component.ts new file mode 100644 index 00000000..7c12e410 --- /dev/null +++ b/src/app/modules/organizations/columns/columns.component.ts @@ -0,0 +1,112 @@ +import { CommonModule, Location } from '@angular/common' +import { Component, inject, type OnInit } from '@angular/core' +import { MatIconModule } from '@angular/material/icon' +import { MatSidenavModule } from '@angular/material/sidenav' +import { MatTabsModule } from '@angular/material/tabs' +import { Title } from '@angular/platform-browser' +import { Router, RouterOutlet } from '@angular/router' +import { type NavigationItem, VerticalNavigationComponent } from '@seed/components' +import { PageComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' + +@Component({ + selector: 'seed-organizations-columns', + templateUrl: './columns.component.html', + imports: [ + CommonModule, + SharedImports, + MatIconModule, + MatSidenavModule, + MatTabsModule, + PageComponent, + VerticalNavigationComponent, + RouterOutlet, + ], +}) +export class ColumnsComponent implements OnInit { + private _title = inject(Title) + private _router = inject(Router) + private _location = inject(Location) + tabs = [ + { + label: 'Properties', + route: 'properties', + }, + { + label: 'TaxLots', + route: 'taxlots', + }, + ] + pageTitle: string + columnsNavigationMenu: NavigationItem[] = [ + { + id: 'organizations/columns/list', + exactMatch: false, + title: 'Column List', + link: '/organizations/columns/list', + type: 'basic', + fn: (n: NavigationItem) => { + this.setNavTitle(n) + }, + }, + { + id: 'organizations/columns/geocoding', + link: '/organizations/columns/geocoding', + title: 'Geocoding', + type: 'basic', + fn: (n: NavigationItem) => { + this.setNavTitle(n) + }, + }, + { + id: 'organization/columns/data-type', + link: '/organizations/columns/data-types', + title: 'Data Types', + type: 'basic', + fn: (n: NavigationItem) => { + this.setNavTitle(n) + }, + }, + { + id: 'organizations/columns/import-settings', + link: '/organizations/columns/import-settings', + title: 'Import Settings', + type: 'basic', + fn: (n: NavigationItem) => { + this.setNavTitle(n) + }, + }, + ] + + ngOnInit(): void { + this.setTitle() + } + + currentType(): string { + return this._location.path().split('/').pop() + } + + async navigateTo(type: string) { + const loc = this._location.path() + if (loc.includes(type)) { + return + } + const newPath = loc.replace(this.inverseType(type), type) + await this._router.navigateByUrl(newPath).then(() => { + this.setTitle() + }) + } + + inverseType(type: string): string { + return type === 'properties' ? 'taxlots' : 'properties' + } + + setNavTitle(n: NavigationItem) { + this.pageTitle = n.title + } + + setTitle() { + const basePath = `${this._location.path().split('/').slice(0, -1).join('/')}/properties` + this.pageTitle = this.columnsNavigationMenu.find((n) => basePath.includes(n.link)).title + } +} diff --git a/src/app/modules/organizations/columns/columns.routes.ts b/src/app/modules/organizations/columns/columns.routes.ts new file mode 100644 index 00000000..91808eb8 --- /dev/null +++ b/src/app/modules/organizations/columns/columns.routes.ts @@ -0,0 +1,72 @@ +import type { Routes } from '@angular/router' +import { DataTypesPropertiesComponent } from './data-types/data-types-properties.component' +import { DataTypesTaxLotsComponent } from './data-types/data-types-taxlots.component' +import { GeocodingPropertiesComponent } from './geocoding/geocoding-properties.component' +import { GeocodingTaxlotsComponent } from './geocoding/geocoding-taxlots.component' +import { ImportSettingsPropertiesComponent } from './import-settings/import-settings-properties.component' +import { ImportSettingsTaxLotsComponent } from './import-settings/import-settings-taxlots.component' +import { ListPropertiesComponent } from './list/list-properties.component' +import { ListTaxLotComponent } from './list/list-taxlots.component' + +export default [ + { + path: 'list', + pathMatch: 'full', + redirectTo: 'list/properties', + }, + { + path: 'list/properties', + title: 'Column List', + component: ListPropertiesComponent, + }, + { + path: 'list/taxlots', + title: 'Column List', + component: ListTaxLotComponent, + }, + { + path: 'geocoding', + pathMatch: 'full', + redirectTo: 'geocoding/properties', + }, + { + path: 'geocoding/properties', + title: 'Geocoding Order', + component: GeocodingPropertiesComponent, + }, + { + path: 'geocoding/taxlots', + title: 'Geocoding Order', + component: GeocodingTaxlotsComponent, + }, + { + path: 'data-types', + pathMatch: 'full', + redirectTo: 'data-types/properties', + }, + { + path: 'data-types/properties', + title: 'Data Types', + component: DataTypesPropertiesComponent, + }, + { + path: 'data-types/taxlots', + title: 'Data Types', + component: DataTypesTaxLotsComponent, + }, + { + path: 'import-settings', + pathMatch: 'full', + redirectTo: 'import-settings/properties', + }, + { + path: 'import-settings/properties', + title: 'Import Settings', + component: ImportSettingsPropertiesComponent, + }, + { + path: 'import-settings/taxlots', + title: 'Import Settings', + component: ImportSettingsTaxLotsComponent, + }, +] satisfies Routes diff --git a/src/app/modules/organizations/columns/data-types/data-types-properties.component.ts b/src/app/modules/organizations/columns/data-types/data-types-properties.component.ts new file mode 100644 index 00000000..5ca5c97f --- /dev/null +++ b/src/app/modules/organizations/columns/data-types/data-types-properties.component.ts @@ -0,0 +1,57 @@ +import { CommonModule } from '@angular/common' +import { type AfterViewInit, Component, type OnInit, ViewChild, ViewEncapsulation } from '@angular/core' +import { ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInputModule } from '@angular/material/input' +import { MatPaginator } from '@angular/material/paginator' +import { MatSelectModule } from '@angular/material/select' +import { MatTableModule } from '@angular/material/table' +import { map, takeUntil } from 'rxjs' +import { TableContainerComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { naturalSort } from '@seed/utils' +import { DataTypesComponent } from './data-types.component' + +@Component({ + selector: 'seed-organizations-column-data-types-properties', + templateUrl: './data-types.component.html', + encapsulation: ViewEncapsulation.None, + imports: [ + CommonModule, + SharedImports, + TableContainerComponent, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatPaginator, + MatSelectModule, + MatTableModule, + ReactiveFormsModule, + ], +}) +export class DataTypesPropertiesComponent extends DataTypesComponent implements AfterViewInit, OnInit { + type = 'PropertyState' + + @ViewChild(MatPaginator) paginator: MatPaginator + + ngOnInit(): void { + this._columnService.propertyColumns$ + .pipe(takeUntil(this._unsubscribeAll$)) + .pipe( + map((columns) => { + this.columns = columns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.initializeFormControls() + if (this.columns.length > 0) { + this.isLoading = false + } + this.columnTableDataSource.data = this.columns + }), + ) + .subscribe() + } + + ngAfterViewInit(): void { + this.columnTableDataSource.paginator = this.paginator + } +} diff --git a/src/app/modules/organizations/columns/data-types/data-types-taxlots.component.ts b/src/app/modules/organizations/columns/data-types/data-types-taxlots.component.ts new file mode 100644 index 00000000..1531650e --- /dev/null +++ b/src/app/modules/organizations/columns/data-types/data-types-taxlots.component.ts @@ -0,0 +1,53 @@ +import { CommonModule } from '@angular/common' +import { type AfterViewInit, Component, type OnInit, ViewChild, ViewEncapsulation } from '@angular/core' +import { ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInputModule } from '@angular/material/input' +import { MatPaginator } from '@angular/material/paginator' +import { MatSelectModule } from '@angular/material/select' +import { MatTableModule } from '@angular/material/table' +import { map, takeUntil } from 'rxjs' +import { TableContainerComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { naturalSort } from '@seed/utils' +import { DataTypesComponent } from './data-types.component' + +@Component({ + selector: 'seed-organizations-column-data-types-taxlots', + templateUrl: './data-types.component.html', + encapsulation: ViewEncapsulation.None, + imports: [ + CommonModule, + SharedImports, + TableContainerComponent, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatPaginator, + MatSelectModule, + MatTableModule, + ReactiveFormsModule, + ], +}) +export class DataTypesTaxLotsComponent extends DataTypesComponent implements AfterViewInit, OnInit { + type = 'TaxLotState' + @ViewChild(MatPaginator) paginator: MatPaginator + + ngOnInit(): void { + this._columnService.taxLotColumns$ + .pipe(takeUntil(this._unsubscribeAll$)) + .pipe( + map((columns) => { + this.columns = columns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.initializeFormControls() + this.columnTableDataSource.data = this.columns + }), + ) + .subscribe() + } + + ngAfterViewInit() { + this.columnTableDataSource.paginator = this.paginator + } +} diff --git a/src/app/modules/organizations/columns/data-types/data-types.component.html b/src/app/modules/organizations/columns/data-types/data-types.component.html new file mode 100644 index 00000000..842b7d04 --- /dev/null +++ b/src/app/modules/organizations/columns/data-types/data-types.component.html @@ -0,0 +1,41 @@ +
This allows the user to set the type, such as Text, Number, Date for Extra Data Fields.
+@if (columns && columns.length === 0) { +
No extra data columns have been created yet.
+} +
+ + + Filter + + +
+ + + + + + + + + + + + + +
Display Name{{ c.display_name }}Data Type + + @for (type of dataTypes; track type.id) { + {{ type.label }} + } + +
+ + +
+ +
+
+
+
diff --git a/src/app/modules/organizations/columns/data-types/data-types.component.ts b/src/app/modules/organizations/columns/data-types/data-types.component.ts new file mode 100644 index 00000000..86baea15 --- /dev/null +++ b/src/app/modules/organizations/columns/data-types/data-types.component.ts @@ -0,0 +1,70 @@ +import { Component, inject, type OnDestroy, ViewEncapsulation } from '@angular/core' +import { FormControl, FormGroup, Validators } from '@angular/forms' +import { MatDialog } from '@angular/material/dialog' +import { MatTableDataSource } from '@angular/material/table' +import { Subject, takeUntil } from 'rxjs' +import { type Column, ColumnService } from '@seed/api/column' +import type { ProgressResponse } from '@seed/api/progress' +import { SharedImports } from '@seed/directives' +import { UpdateModalComponent } from '../modal/update-modal.component' +import { DataTypes } from './data-types.constants' + +@Component({ + selector: 'seed-organizations-column-data-types-properties', + template: '', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports], +}) +export class DataTypesComponent implements OnDestroy { + protected _columnService = inject(ColumnService) + protected readonly _unsubscribeAll$ = new Subject() + private _dialog = inject(MatDialog) + columns: Column[] + columnTableDataSource = new MatTableDataSource([]) + columnTableColumns = ['display_name', 'data_type'] + type: string + dataTypesForm = new FormGroup({}) + dataTypes = DataTypes + isLoading = true + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + applyFilter(event: Event): void { + const filterValue = (event.target as HTMLInputElement).value + this.columnTableDataSource.filter = filterValue.trim().toLowerCase() + } + + save(): void { + const changes = {} + for (const column of this.columns.filter((c) => c.is_extra_data)) { + const setting = this.dataTypesForm.get(`${column.id}`).value + if (setting !== column.data_type) { + changes[column.id] = { data_type: setting } + } + } + if (Object.keys(changes).length > 0) { + this._columnService + .updateMultipleColumns(this.columns[0].organization_id, this.type, changes) + .subscribe((response: ProgressResponse) => { + const dialogRef = this._dialog.open(UpdateModalComponent, { + width: '40rem', + data: { progressResponse: response }, + }) + dialogRef.afterClosed().pipe(takeUntil(this._unsubscribeAll$)).subscribe() + }) + } + } + + initializeFormControls() { + for (const c of this.columns) { + const stringId = `${c.id}` + this.dataTypesForm.addControl(stringId, new FormControl(c.data_type, [Validators.required])) + if (!c.is_extra_data) { + this.dataTypesForm.get(stringId)?.disable() + } + } + } +} diff --git a/src/app/modules/organizations/columns/data-types/data-types.constants.ts b/src/app/modules/organizations/columns/data-types/data-types.constants.ts new file mode 100644 index 00000000..b91956bb --- /dev/null +++ b/src/app/modules/organizations/columns/data-types/data-types.constants.ts @@ -0,0 +1,17 @@ +export const DataTypes = [ + { id: 'None', label: '' }, + { id: 'number', label: 'Number' }, + { id: 'float', label: 'Float' }, + { id: 'integer', label: 'Integer' }, + { id: 'string', label: 'Text' }, + { id: 'datetime', label: 'Datetime' }, + { id: 'date', label: 'Date' }, + { id: 'boolean', label: 'Boolean' }, + { id: 'area', label: 'Area' }, + { id: 'eui', label: 'EUI' }, + { id: 'geometry', label: 'Geometry' }, + { id: 'ghg', label: 'GHG' }, + { id: 'ghg_intensity', label: 'GHG Intensity' }, + { id: 'wui', label: 'WUI' }, + { id: 'water_use', label: 'Water Use' }, +] diff --git a/src/app/modules/organizations/columns/geocoding/geocoding-properties.component.ts b/src/app/modules/organizations/columns/geocoding/geocoding-properties.component.ts new file mode 100644 index 00000000..8329a6d9 --- /dev/null +++ b/src/app/modules/organizations/columns/geocoding/geocoding-properties.component.ts @@ -0,0 +1,28 @@ +import { CdkDrag, CdkDropList } from '@angular/cdk/drag-drop' +import { Component, type OnInit, ViewEncapsulation } from '@angular/core' +import { ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatIcon } from '@angular/material/icon' +import { MatSelectModule } from '@angular/material/select' +import { MatTooltip } from '@angular/material/tooltip' +import { takeUntil } from 'rxjs' +import { SharedImports } from '@seed/directives' +import { naturalSort } from '@seed/utils' +import { GeocodingComponent } from './geocoding.component' + +@Component({ + selector: 'seed-organizations-column-geocoding-properties', + templateUrl: './geocoding.component.html', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports, CdkDropList, CdkDrag, MatButtonModule, MatIcon, MatSelectModule, MatTooltip, ReactiveFormsModule], +}) +export class GeocodingPropertiesComponent extends GeocodingComponent implements OnInit { + type = 'PropertyState' + + ngOnInit(): void { + this._columnService.propertyColumns$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((columns) => { + this.columns = columns.sort((a, b) => a.geocoding_order - b.geocoding_order).filter((c) => c.geocoding_order != 0) + this.availableColumns = columns.sort((a, b) => naturalSort(a.display_name, b.display_name)).filter((c) => c.geocoding_order === 0) + }) + } +} diff --git a/src/app/modules/organizations/columns/geocoding/geocoding-taxlots.component.ts b/src/app/modules/organizations/columns/geocoding/geocoding-taxlots.component.ts new file mode 100644 index 00000000..e1c2991e --- /dev/null +++ b/src/app/modules/organizations/columns/geocoding/geocoding-taxlots.component.ts @@ -0,0 +1,28 @@ +import { CdkDrag, CdkDropList } from '@angular/cdk/drag-drop' +import { Component, type OnInit, ViewEncapsulation } from '@angular/core' +import { ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatIcon } from '@angular/material/icon' +import { MatSelectModule } from '@angular/material/select' +import { MatTooltip } from '@angular/material/tooltip' +import { takeUntil } from 'rxjs' +import { SharedImports } from '@seed/directives' +import { naturalSort } from '@seed/utils' +import { GeocodingComponent } from './geocoding.component' + +@Component({ + selector: 'seed-organizations-column-geocoding-taxlots', + templateUrl: './geocoding.component.html', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports, CdkDropList, CdkDrag, MatButtonModule, MatIcon, MatSelectModule, MatTooltip, ReactiveFormsModule], +}) +export class GeocodingTaxlotsComponent extends GeocodingComponent implements OnInit { + type = 'TaxLotState' + + ngOnInit(): void { + this._columnService.taxLotColumns$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((columns) => { + this.columns = columns.sort((a, b) => a.geocoding_order - b.geocoding_order).filter((c) => c.geocoding_order != 0) + this.availableColumns = columns.sort((a, b) => naturalSort(a.display_name, b.display_name)).filter((c) => c.geocoding_order === 0) + }) + } +} diff --git a/src/app/modules/organizations/columns/geocoding/geocoding.component.html b/src/app/modules/organizations/columns/geocoding/geocoding.component.html new file mode 100644 index 00000000..0694becd --- /dev/null +++ b/src/app/modules/organizations/columns/geocoding/geocoding.component.html @@ -0,0 +1,50 @@ +
Drag columns to reorder them. Add columns with the selector below.
+
When all your changes have been made, make sure to save them.
+
+ +
+ +
+ @for (column of columns; track column.id) { +
+
+
{{ column.geocoding_order }}
+
{{ column.display_name }}
+
+
+ +
+
+ } +
+ +
Add A GeoCoding Column
+
+ + Add Column + + @for (c of availableColumns; track c.id) { + {{ c.display_name }} + } + + +
+ +
+
+ +
When all your changes have been made, make sure to save them.
+
+ +
diff --git a/src/app/modules/organizations/columns/geocoding/geocoding.component.ts b/src/app/modules/organizations/columns/geocoding/geocoding.component.ts new file mode 100644 index 00000000..bf8c3c41 --- /dev/null +++ b/src/app/modules/organizations/columns/geocoding/geocoding.component.ts @@ -0,0 +1,79 @@ +import { CdkDrag, type CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop' +import { Component, inject, type OnDestroy, ViewEncapsulation } from '@angular/core' +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatDialog } from '@angular/material/dialog' +import { MatIcon } from '@angular/material/icon' +import { MatSelectModule } from '@angular/material/select' +import { MatTooltip } from '@angular/material/tooltip' +import { Subject, takeUntil } from 'rxjs' +import { type Column, ColumnService } from '@seed/api/column' +import type { ProgressResponse } from '@seed/api/progress' +import { SharedImports } from '@seed/directives' +import { naturalSort } from '@seed/utils' +import { UpdateModalComponent } from '../modal/update-modal.component' + +@Component({ + selector: 'seed-organizations-column-geocoding-properties', + templateUrl: './geocoding.component.html', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports, CdkDropList, CdkDrag, MatButtonModule, MatIcon, MatSelectModule, MatTooltip, ReactiveFormsModule], +}) +export class GeocodingComponent implements OnDestroy { + protected _columnService = inject(ColumnService) + protected readonly _unsubscribeAll$ = new Subject() + private _dialog = inject(MatDialog) + columns: Column[] + availableColumns: Column[] + removedColumns: Column[] = [] + type: string + dirty = false + addForm = new FormGroup({ + addGeocoder: new FormControl(null, [Validators.required]), + }) + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + drop(event: CdkDragDrop) { + moveItemInArray(this.columns, event.previousIndex, event.currentIndex) + this.dirty = true + } + + delete(column: Column) { + this.columns = this.columns.filter((c) => c.id !== column.id) + this.availableColumns.push(column) + this.availableColumns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.removedColumns.push(column) + this.dirty = true + } + + add() { + const columnToAdd = this.availableColumns.find((c) => c.id === this.addForm.get('addGeocoder').value) + this.availableColumns = this.availableColumns.filter((c) => c.id !== columnToAdd.id) + columnToAdd.geocoding_order = this.columns.length + 1 + this.columns.push(columnToAdd) + this.dirty = true + } + + save() { + const changes = {} + for (const [i, column] of this.columns.entries()) { + changes[column.id] = { geocoding_order: i + 1 } + } + for (const c of this.removedColumns) { + changes[c.id] = { geocoding_order: 0 } + } + this._columnService + .updateMultipleColumns(this.columns[0].organization_id, this.type, changes) + .subscribe((response: ProgressResponse) => { + const dialogRef = this._dialog.open(UpdateModalComponent, { + width: '40rem', + data: { progressResponse: response }, + }) + dialogRef.afterClosed().pipe(takeUntil(this._unsubscribeAll$)).subscribe() + }) + } +} diff --git a/src/app/modules/organizations/columns/import-settings/import-settings-properties.component.ts b/src/app/modules/organizations/columns/import-settings/import-settings-properties.component.ts new file mode 100644 index 00000000..c72148d9 --- /dev/null +++ b/src/app/modules/organizations/columns/import-settings/import-settings-properties.component.ts @@ -0,0 +1,29 @@ +import { Component, type OnInit, ViewEncapsulation } from '@angular/core' +import { ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatIconModule } from '@angular/material/icon' +import { MatSelectModule } from '@angular/material/select' +import { map, takeUntil } from 'rxjs' +import { SharedImports } from '@seed/directives' +import { ImportSettingsComponent } from './import-settings.component' + +@Component({ + selector: 'seed-organizations-column-import-settings-properties', + templateUrl: './import-settings.component.html', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports, MatButtonModule, MatIconModule, MatSelectModule, ReactiveFormsModule], +}) +export class ImportSettingsPropertiesComponent extends ImportSettingsComponent implements OnInit { + type = 'PropertyState' + + ngOnInit(): void { + this._columnService.propertyColumns$ + .pipe(takeUntil(this._unsubscribeAll$)) + .pipe( + map((columns) => { + this.prepareColumns(columns) + }), + ) + .subscribe() + } +} diff --git a/src/app/modules/organizations/columns/import-settings/import-settings-taxlots.component.ts b/src/app/modules/organizations/columns/import-settings/import-settings-taxlots.component.ts new file mode 100644 index 00000000..b24e72b8 --- /dev/null +++ b/src/app/modules/organizations/columns/import-settings/import-settings-taxlots.component.ts @@ -0,0 +1,29 @@ +import { Component, type OnInit, ViewEncapsulation } from '@angular/core' +import { ReactiveFormsModule } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MatIconModule } from '@angular/material/icon' +import { MatSelectModule } from '@angular/material/select' +import { map, takeUntil } from 'rxjs' +import { SharedImports } from '@seed/directives' +import { ImportSettingsComponent } from './import-settings.component' + +@Component({ + selector: 'seed-organizations-column-import-settings-taxlots', + templateUrl: './import-settings.component.html', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports, MatButtonModule, MatIconModule, MatSelectModule, ReactiveFormsModule], +}) +export class ImportSettingsTaxLotsComponent extends ImportSettingsComponent implements OnInit { + type = 'TaxLotState' + + ngOnInit(): void { + this._columnService.taxLotColumns$ + .pipe(takeUntil(this._unsubscribeAll$)) + .pipe( + map((columns) => { + this.prepareColumns(columns) + }), + ) + .subscribe() + } +} diff --git a/src/app/modules/organizations/columns/import-settings/import-settings.component.html b/src/app/modules/organizations/columns/import-settings/import-settings.component.html new file mode 100644 index 00000000..935e11ba --- /dev/null +++ b/src/app/modules/organizations/columns/import-settings/import-settings.component.html @@ -0,0 +1,123 @@ +
+
+
Exclude Columns From Uniqueness
+
+ Adding a field here will remove the field from the hash that uniquely represents each record. Incoming data will not be imported into + SEED if the only fields changed are marked as excluded from the uniqueness calculations. The incoming data will instead be calculated + to be a duplicate of the existing data and will therefore be ignored. +
+
    + @for (c of excludedColumns; track c.id) { +
  • +
    {{ c.display_name }}
    +
    + + + +
    +
  • + } +
+ @if (columns && availableExcludedColumns) { +
+ + Add Column + + @for (c of availableExcludedColumns(); track c.id) { + {{ c.display_name }} + } + + +
+ +
+
+ } +
+ +
+
Recognize Empty
+
+ Adding a column here will affect how empty or blank values are treated during merges. Specifically, empty values will be able to + replace non-empty values per the "Merge Protection" setting. +
+
    + @for (c of emptyColumns; track c.id) { +
  • +
    {{ c.display_name }}
    +
    + + + +
    +
  • + } +
+ @if (columns && availableEmptyColumns) { +
+ + Add Column + + @for (c of availableEmptyColumns(); track c.id) { + {{ c.display_name }} + } + + +
+ +
+
+ } +
+ +
+
Merge Protection
+
+ Normally when an imported record is merged into another record the newest value overwrites an older one. Merge protection prevents + this, and is particularly useful for columns where you have manually edited values that you want to persist even after importing and + merging new data. +
+
    + @for (c of mergeProtectedColumns; track c.id) { +
  • +
    {{ c.display_name }}
    +
    + + + +
    +
  • + } +
+ @if (columns && removeMergeProtected) { +
+ + Add Column + + @for (c of availableMergeProtectionColumns(); track c.id) { + {{ c.display_name }} + } + + +
+ +
+
+ } +
+
+ +
+ + +
diff --git a/src/app/modules/organizations/columns/import-settings/import-settings.component.ts b/src/app/modules/organizations/columns/import-settings/import-settings.component.ts new file mode 100644 index 00000000..36e72687 --- /dev/null +++ b/src/app/modules/organizations/columns/import-settings/import-settings.component.ts @@ -0,0 +1,193 @@ +import { Component, inject, type OnDestroy, ViewEncapsulation } from '@angular/core' +import { FormControl, FormGroup, Validators } from '@angular/forms' +import { MatDialog } from '@angular/material/dialog' +import { Router } from '@angular/router' +import { Subject, takeUntil } from 'rxjs' +import { type Column, ColumnService } from '@seed/api/column' +import type { ProgressResponse } from '@seed/api/progress' +import { SharedImports } from '@seed/directives' +import { naturalSort } from '@seed/utils' +import { UpdateModalComponent } from '../modal/update-modal.component' + +type ColumnChangeSet = { + is_excluded_from_hash: boolean; + recognize_empty: boolean; + merge_protection?: 0 | 1; +} +@Component({ + selector: 'seed-organizations-column-import-settings', + template: '', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports], +}) +export class ImportSettingsComponent implements OnDestroy { + protected _columnService = inject(ColumnService) + protected readonly _unsubscribeAll$ = new Subject() + private _dialog = inject(MatDialog) + private _router = inject(Router) + columns: Column[] + excludedColumns: Column[] + emptyColumns: Column[] + mergeProtectedColumns: Column[] + removedExcludedColumns: Column[] = [] + removedEmptyColumns: Column[] = [] + removedMergeProtectedColumns: Column[] = [] + dirty = false + type: string + addEmptyForm = new FormGroup({ + column: new FormControl(null, [Validators.required]), + }) + addExcludeForm = new FormGroup({ + column: new FormControl(null, [Validators.required]), + }) + addMergeProtectedForm = new FormGroup({ + column: new FormControl(null, [Validators.required]), + }) + + ngOnDestroy(): void { + if (this.dirty) { + this.save() + } + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + async cancel() { + this.dirty = false + await this._router.navigate(['/organizations/columns/list']) + } + + save(): void { + const changes = {} + for (const column of this.columns.filter((c) => this.columnChanged(c))) { + changes[column.id] = this.columnChangeset(column) + } + if (Object.keys(changes).length > 0) { + this._columnService + .updateMultipleColumns(this.columns[0].organization_id, this.type, changes) + .subscribe((response: ProgressResponse) => { + const dialogRef = this._dialog.open(UpdateModalComponent, { + width: '40rem', + data: { progressResponse: response }, + }) + dialogRef.afterClosed().pipe(takeUntil(this._unsubscribeAll$)).subscribe() + }) + } + } + + columnChanged(column: Column): boolean { + if ( + this.removedEmptyColumns.includes(column) + || this.removedExcludedColumns.includes(column) + || this.removedMergeProtectedColumns.includes(column) + ) { + return true + } + if (this.excludedColumns.includes(column) && !column.is_excluded_from_hash) { + return true + } + if (this.emptyColumns.includes(column) && !column.recognize_empty) { + return true + } + if (this.mergeProtectedColumns.includes(column) && column.merge_protection === 'Favor New') { + return true + } + return false + } + + columnChangeset(column: Column): ColumnChangeSet { + const changeset: ColumnChangeSet = { + is_excluded_from_hash: column.is_excluded_from_hash, + recognize_empty: column.recognize_empty, + } + if (this.removedEmptyColumns.includes(column)) { + changeset.recognize_empty = false + } else if (this.emptyColumns.includes(column)) { + changeset.recognize_empty = true + } + if (this.removedExcludedColumns.includes(column)) { + changeset.is_excluded_from_hash = false + } else if (this.excludedColumns.includes(column)) { + changeset.is_excluded_from_hash = true + } + if (this.removedMergeProtectedColumns.includes(column)) { + changeset.merge_protection = 0 // 'Favor New' + } else if (this.mergeProtectedColumns.includes(column)) { + changeset.merge_protection = 1 // 'Favor Existing' + } + + return changeset + } + + prepareColumns(columns: Column[]) { + this.columns = columns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.excludedColumns = columns.filter((c) => c.is_excluded_from_hash) + this.emptyColumns = columns.filter((c) => c.recognize_empty) + this.mergeProtectedColumns = columns.filter((c) => c.merge_protection === 'Favor Existing') + } + + availableExcludedColumns(): Column[] { + return this.columns.filter((c) => !c.is_excluded_from_hash) + } + + availableEmptyColumns(): Column[] { + return this.columns.filter((c) => !c.recognize_empty) + } + + availableMergeProtectionColumns(): Column[] { + return this.columns.filter((c) => c.merge_protection !== 'Favor Existing') + } + + addExcluded() { + this.excludedColumns.push(this.columns.find((c) => c.id === this.addExcludeForm.get('column').value)) + this.excludedColumns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.dirty = true + this.addExcludeForm.reset() + } + + addEmpty() { + this.emptyColumns.push(this.columns.find((c) => c.id === this.addEmptyForm.get('column').value)) + this.emptyColumns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.dirty = true + this.addEmptyForm.reset() + } + + addMerge() { + this.mergeProtectedColumns.push(this.columns.find((c) => c.id === this.addMergeProtectedForm.get('column').value)) + this.mergeProtectedColumns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.dirty = true + this.addMergeProtectedForm.reset() + } + + removeExcluded(column: Column) { + this.excludedColumns = this.excludedColumns.filter((c) => c.id !== column.id) + this.removedExcludedColumns.push(column) + this.excludedColumns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.dirty = true + } + + removeEmpty(column: Column) { + this.emptyColumns = this.emptyColumns.filter((c) => c.id !== column.id) + this.removedEmptyColumns.push(column) + this.emptyColumns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.dirty = true + } + + removeMergeProtected(column: Column) { + this.mergeProtectedColumns = this.mergeProtectedColumns.filter((c) => c.id !== column.id) + this.removedMergeProtectedColumns.push(column) + this.mergeProtectedColumns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + this.dirty = true + } + + includeColumn(column: Column): boolean { + if (column.is_excluded_from_hash) { + return true + } else if (column.recognize_empty) { + return true + } else if (column.merge_protection === 'Favor Existing') { + return true + } + return false + } +} diff --git a/src/app/modules/organizations/columns/list/list-properties.component.ts b/src/app/modules/organizations/columns/list/list-properties.component.ts new file mode 100644 index 00000000..64c3435f --- /dev/null +++ b/src/app/modules/organizations/columns/list/list-properties.component.ts @@ -0,0 +1,38 @@ +import { type AfterViewInit, Component, type OnDestroy, type OnInit, ViewChild, ViewEncapsulation } from '@angular/core' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIcon } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { MatPaginator } from '@angular/material/paginator' +import { MatTableModule } from '@angular/material/table' +import { MatTooltip } from '@angular/material/tooltip' +import { map, takeUntil } from 'rxjs' +import { TableContainerComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { naturalSort } from '@seed/utils' +import { ListComponent } from './list.component' + +@Component({ + selector: 'seed-organizations-columns-list-properties', + templateUrl: './list.component.html', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports, MatFormFieldModule, MatIcon, MatInputModule, MatPaginator, MatTableModule, MatTooltip, TableContainerComponent], +}) +export class ListPropertiesComponent extends ListComponent implements AfterViewInit, OnDestroy, OnInit { + type = 'properties' + @ViewChild(MatPaginator) paginator: MatPaginator + + ngOnInit(): void { + this._columnService.propertyColumns$ + .pipe(takeUntil(this._unsubscribeAll$)) + .pipe( + map((columns) => { + this.columnTableDataSource.data = columns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + }), + ) + .subscribe() + } + + ngAfterViewInit(): void { + this.columnTableDataSource.paginator = this.paginator + } +} diff --git a/src/app/modules/organizations/columns/list/list-taxlots.component.ts b/src/app/modules/organizations/columns/list/list-taxlots.component.ts new file mode 100644 index 00000000..9e21a0e4 --- /dev/null +++ b/src/app/modules/organizations/columns/list/list-taxlots.component.ts @@ -0,0 +1,38 @@ +import { type AfterViewInit, Component, type OnDestroy, type OnInit, ViewChild, ViewEncapsulation } from '@angular/core' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatIcon } from '@angular/material/icon' +import { MatInputModule } from '@angular/material/input' +import { MatPaginator } from '@angular/material/paginator' +import { MatTableModule } from '@angular/material/table' +import { MatTooltip } from '@angular/material/tooltip' +import { map, takeUntil } from 'rxjs' +import { TableContainerComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { naturalSort } from '@seed/utils' +import { ListComponent } from './list.component' + +@Component({ + selector: 'seed-organizations-columns-list-taxlots', + templateUrl: './list.component.html', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports, MatFormFieldModule, MatIcon, MatInputModule, MatPaginator, MatTableModule, MatTooltip, TableContainerComponent], +}) +export class ListTaxLotComponent extends ListComponent implements AfterViewInit, OnDestroy, OnInit { + type = 'taxlots' + @ViewChild(MatPaginator) paginator: MatPaginator + + ngOnInit(): void { + this._columnService.taxLotColumns$ + .pipe(takeUntil(this._unsubscribeAll$)) + .pipe( + map((columns) => { + this.columnTableDataSource.data = columns.sort((a, b) => naturalSort(a.display_name, b.display_name)) + }), + ) + .subscribe() + } + + ngAfterViewInit() { + this.columnTableDataSource.paginator = this.paginator + } +} diff --git a/src/app/modules/organizations/columns/list/list.component.html b/src/app/modules/organizations/columns/list/list.component.html new file mode 100644 index 00000000..1568955a --- /dev/null +++ b/src/app/modules/organizations/columns/list/list.component.html @@ -0,0 +1,51 @@ + + + Filter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Canonical? + @if (!c.is_extra_data && !c.derived_column) { + + } + Display Name{{ c.display_name }}Column Name{{ c.column_name }}Description{{ c.column_description }}Actions + + + + + + + @if (c.is_extra_data) { + + + + } +
+
+ diff --git a/src/app/modules/organizations/columns/list/list.component.ts b/src/app/modules/organizations/columns/list/list.component.ts new file mode 100644 index 00000000..cc2cd33f --- /dev/null +++ b/src/app/modules/organizations/columns/list/list.component.ts @@ -0,0 +1,67 @@ +import { Component, inject, type OnDestroy, ViewEncapsulation } from '@angular/core' +import { MatDialog } from '@angular/material/dialog' +import { MatTableDataSource } from '@angular/material/table' +import { Subject, takeUntil, tap } from 'rxjs' +import { type Column, ColumnService } from '@seed/api/column' +import { SharedImports } from '@seed/directives' +import { DeleteModalComponent } from './modal/delete-modal.component' +import { FormModalComponent } from './modal/form-modal.component' + +@Component({ + selector: 'seed-organizations-columns-list-properties', + template: '', + encapsulation: ViewEncapsulation.None, + imports: [SharedImports], +}) +export class ListComponent implements OnDestroy { + protected _columnService = inject(ColumnService) + protected readonly _unsubscribeAll$ = new Subject() + private _dialog = inject(MatDialog) + columnTableDataSource = new MatTableDataSource([]) + columnTableColumns = ['canonical', 'display_name', 'column_name', 'column_description', 'actions'] + type: string + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + delete(column: Column): void { + const dialogRef = this._dialog.open(DeleteModalComponent, { + width: '40rem', + data: { column }, + }) + + dialogRef + .afterClosed() + .pipe( + takeUntil(this._unsubscribeAll$), + tap(() => { + if (column.table_name === 'PropertyState') { + this._columnService.getPropertyColumns(column.organization_id).subscribe() + } else if (column.table_name === 'TaxLotState') { + this._columnService.getTaxLotColumns(column.organization_id).subscribe() + } + }), + ) + .subscribe() + } + + rename(column: Column) { + console.log('Rename called for column: ', column) + } + + edit(column: Column) { + const dialogRef = this._dialog.open(FormModalComponent, { + width: '40rem', + data: { column }, + }) + + dialogRef.afterClosed().pipe(takeUntil(this._unsubscribeAll$)).subscribe() + } + + applyFilter(event: Event): void { + const filterValue = (event.target as HTMLInputElement).value + this.columnTableDataSource.filter = filterValue.trim().toLowerCase() + } +} diff --git a/src/app/modules/organizations/columns/list/modal/delete-modal.component.html b/src/app/modules/organizations/columns/list/modal/delete-modal.component.html new file mode 100644 index 00000000..20350829 --- /dev/null +++ b/src/app/modules/organizations/columns/list/modal/delete-modal.component.html @@ -0,0 +1,23 @@ +

+ Delete Column {{ data.column.display_name }}? +

+ +
+ @if (inProgress) { + + } + @if (errorMessage) { + {{ errorMessage }} + } +
+ +@if (!inProgress) { +
+ + + + + + +
+} diff --git a/src/app/modules/organizations/columns/list/modal/delete-modal.component.ts b/src/app/modules/organizations/columns/list/modal/delete-modal.component.ts new file mode 100644 index 00000000..ee4b14dc --- /dev/null +++ b/src/app/modules/organizations/columns/list/modal/delete-modal.component.ts @@ -0,0 +1,86 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatProgressBarModule } from '@angular/material/progress-bar' +import { Subject, switchMap, takeUntil, tap } from 'rxjs' +import { type Column, ColumnService } from '@seed/api/column' +import type { ProgressResponse } from '@seed/api/progress' +import { AlertComponent } from '@seed/components' +import { UploaderService } from '@seed/services/uploader/uploader.service' +import type { ProgressBarObj } from '@seed/services/uploader/uploader.types' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' + +@Component({ + selector: 'seed-cycles-delete-modal', + templateUrl: './delete-modal.component.html', + imports: [AlertComponent, CommonModule, MatButtonModule, MatDialogModule, MatProgressBarModule], +}) +export class DeleteModalComponent implements OnDestroy { + private _columnService = inject(ColumnService) + private _uploaderService = inject(UploaderService) + private _dialogRef = inject(MatDialogRef) + private _snackBar = inject(SnackBarService) + private readonly _unsubscribeAll$ = new Subject() + errorMessage: string + inProgress = false + progressBarObj: ProgressBarObj = { + message: '', + progress: 0, + complete: false, + statusMessage: '', + progressLastUpdated: null, + progressLastChecked: null, + } + + data = inject(MAT_DIALOG_DATA) as { column: Column } + + onSubmit() { + this.inProgress = true + const successFn = () => { + setTimeout(() => { + this._snackBar.success('Column deleted') + this.close() + }, 300) + } + const failureFn = () => { + this._snackBar.alert('Failed to delete column') + this.close() + } + + // initiate delete cycle task + this._columnService + .deleteColumn(this.data.column) + .pipe( + takeUntil(this._unsubscribeAll$), + tap((response: ProgressResponse) => { + this.progressBarObj.progress = response.progress + }), + switchMap(({ progress_key }) => { + return this._uploaderService.checkProgressLoop({ + progressKey: progress_key, + offset: 0, + multiplier: 1, + successFn, + failureFn, + progressBarObj: this.progressBarObj, + }) + }), + ) + .subscribe() + } + + close() { + this._dialogRef.close() + } + + dismiss() { + this._dialogRef.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/organizations/columns/list/modal/form-modal.component.html b/src/app/modules/organizations/columns/list/modal/form-modal.component.html new file mode 100644 index 00000000..ea56576a --- /dev/null +++ b/src/app/modules/organizations/columns/list/modal/form-modal.component.html @@ -0,0 +1,31 @@ +

Edit Column

+
+ + Display Name + + @if (form.controls.display_name?.hasError('required')) { + Display Name is a required field + } + + + + Column Description + + @if (form.controls.display_name?.hasError('required')) { + Column Description is a required field + } + +
+@if (inProgress) { + +} +@if (!inProgress) { +
+ + + + + + +
+} diff --git a/src/app/modules/organizations/columns/list/modal/form-modal.component.ts b/src/app/modules/organizations/columns/list/modal/form-modal.component.ts new file mode 100644 index 00000000..f3489f1f --- /dev/null +++ b/src/app/modules/organizations/columns/list/modal/form-modal.component.ts @@ -0,0 +1,111 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' +import { MatButtonModule } from '@angular/material/button' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatFormFieldModule } from '@angular/material/form-field' +import { MatInputModule } from '@angular/material/input' +import { MatProgressBarModule } from '@angular/material/progress-bar' +import { type Observable, Subject, switchMap, takeUntil, tap } from 'rxjs' +import { type Column, ColumnService } from '@seed/api/column' +import type { ProgressResponse } from '@seed/api/progress' +import { UploaderService } from '@seed/services/uploader/uploader.service' +import type { ProgressBarObj } from '@seed/services/uploader/uploader.types' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' + +@Component({ + selector: 'seed-labels-form-modal', + templateUrl: './form-modal.component.html', + imports: [ + CommonModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + FormsModule, + MatInputModule, + MatProgressBarModule, + ReactiveFormsModule, + ], +}) +export class FormModalComponent implements OnDestroy, OnInit { + private _dialogRef = inject(MatDialogRef) + private _columnService = inject(ColumnService) + private _snackBar = inject(SnackBarService) + private _uploaderService = inject(UploaderService) + private readonly _unsubscribeAll$ = new Subject() + column: Column + refreshFn: (organization_id: number) => Observable + data = inject(MAT_DIALOG_DATA) as { column: Column } + inProgress = false + progressBarObj: ProgressBarObj = { + message: '', + progress: 0, + complete: false, + statusMessage: '', + progressLastUpdated: null, + progressLastChecked: null, + } + form = new FormGroup({ + display_name: new FormControl('', [Validators.required]), + column_description: new FormControl(null, [Validators.required]), + organization_id: new FormControl(null, [Validators.required]), + table_name: new FormControl('', [Validators.required]), + id: new FormControl(null), + }) + + ngOnInit(): void { + this.form.patchValue(this.data.column) + if (this.data.column.table_name === 'PropertyState') { + this.refreshFn = (org_id) => this._columnService.getPropertyColumns(org_id) + } else if (this.data.column.table_name === 'TaxLotState') { + this.refreshFn = (org_id) => this._columnService.getTaxLotColumns(org_id) + } + } + + onSubmit() { + const successFn = () => { + setTimeout(() => { + this.refreshFn(this.data.column.organization_id).subscribe() + this._snackBar.success('Column Updated') + this.close() + }, 300) + } + const failureFn = () => { + this._snackBar.alert('Failed to update column') + this.close() + } + const c = this.form.value as Column + const changes = {} + this.inProgress = true + changes[`${c.id}`] = { display_name: c.display_name, column_description: c.column_description } + this._columnService + .updateMultipleColumns(c.organization_id, c.table_name, changes) + .pipe( + takeUntil(this._unsubscribeAll$), + tap((response: ProgressResponse) => { + this.progressBarObj.progress = response.progress + }), + switchMap(({ progress_key }) => { + return this._uploaderService.checkProgressLoop({ + progressKey: progress_key, + offset: 0, + multiplier: 1, + successFn, + failureFn, + progressBarObj: this.progressBarObj, + }) + }), + ) + .subscribe() + } + + close() { + this._dialogRef.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/organizations/columns/list/modal/rename-modal.component.ts b/src/app/modules/organizations/columns/list/modal/rename-modal.component.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/app/modules/organizations/columns/modal/update-modal.component.html b/src/app/modules/organizations/columns/modal/update-modal.component.html new file mode 100644 index 00000000..2b803e3d --- /dev/null +++ b/src/app/modules/organizations/columns/modal/update-modal.component.html @@ -0,0 +1,10 @@ +

Update Columns

+ +
+ @if (inProgress) { + + } + @if (errorMessage) { + {{ errorMessage }} + } +
diff --git a/src/app/modules/organizations/columns/modal/update-modal.component.ts b/src/app/modules/organizations/columns/modal/update-modal.component.ts new file mode 100644 index 00000000..7dbb2c82 --- /dev/null +++ b/src/app/modules/organizations/columns/modal/update-modal.component.ts @@ -0,0 +1,75 @@ +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatProgressBarModule } from '@angular/material/progress-bar' +import { Subject, takeUntil } from 'rxjs' +import type { ProgressResponse } from '@seed/api/progress' +import { AlertComponent } from '@seed/components' +import { UploaderService } from '@seed/services/uploader/uploader.service' +import type { ProgressBarObj } from '@seed/services/uploader/uploader.types' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' + +@Component({ + selector: 'seed-columns-update-modal', + templateUrl: './update-modal.component.html', + imports: [AlertComponent, CommonModule, MatButtonModule, MatDialogModule, MatProgressBarModule], +}) +export class UpdateModalComponent implements OnDestroy, OnInit { + private _uploaderService = inject(UploaderService) + private _dialogRef = inject(MatDialogRef) + private _snackBar = inject(SnackBarService) + private readonly _unsubscribeAll$ = new Subject() + errorMessage: string + inProgress = false + progressBarObj: ProgressBarObj = { + message: '', + progress: 0, + complete: false, + statusMessage: '', + progressLastUpdated: null, + progressLastChecked: null, + } + + data = inject(MAT_DIALOG_DATA) as { progressResponse: ProgressResponse } + + ngOnInit(): void { + this.inProgress = true + const successFn = () => { + setTimeout(() => { + this._snackBar.success('Columns Updated') + this.close() + }, 300) + } + const failureFn = () => { + this._snackBar.alert('Failed to update columns') + this.close() + } + + this._uploaderService + .checkProgressLoop({ + progressKey: this.data.progressResponse.progress_key, + offset: 0, + multiplier: 1, + successFn, + failureFn, + progressBarObj: this.progressBarObj, + }) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe() + } + + close() { + this._dialogRef.close() + } + + dismiss() { + this._dialogRef.close() + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } +} diff --git a/src/app/modules/organizations/index.ts b/src/app/modules/organizations/index.ts index bfb9a120..c18a2be7 100644 --- a/src/app/modules/organizations/index.ts +++ b/src/app/modules/organizations/index.ts @@ -1,6 +1,6 @@ export * from './access-level-tree/access-level-tree.component' export * from './column-mappings/column-mappings.component' -export * from './column-settings/column-settings.component' +export * from './columns/columns.component' export * from './cycles/cycles.component' export * from './data-quality/data-quality.component' export * from './derived-columns/derived-columns.component' diff --git a/src/app/modules/organizations/organizations.routes.ts b/src/app/modules/organizations/organizations.routes.ts index ecb6a285..1c41329b 100644 --- a/src/app/modules/organizations/organizations.routes.ts +++ b/src/app/modules/organizations/organizations.routes.ts @@ -6,8 +6,7 @@ import { UserService } from '@seed/api/user' import type { OrganizationGenericTypeMatcher } from './organizations.types' import { AccessLevelTreeComponent, - ColumnMappingsComponent, - ColumnSettingsComponent, + ColumnsComponent, CyclesComponent, DataQualityComponent, DerivedColumnsComponent, @@ -23,21 +22,11 @@ const genericTypeMatcher = (args: OrganizationGenericTypeMatcher) => (segments: } } -const columnMappingTypeMatcher = (segments: UrlSegment[]) => { - const args = { segments, validTypes: ['goal', 'properties', 'taxlots'], validPage: 'column-mappings' } - return genericTypeMatcher(args)(segments) -} - const dataQualityTypeMatcher = (segments: UrlSegment[]) => { const args = { segments, validTypes: ['goal', 'properties', 'taxlots'], validPage: 'data-quality' } return genericTypeMatcher(args)(segments) } -const columnSettingsTypeMatcher = (segments: UrlSegment[]) => { - const args = { segments, validTypes: ['properties', 'taxlots'], validPage: 'column-settings' } - return genericTypeMatcher(args)(segments) -} - const derivedColumnsTypeMatcher = (segments: UrlSegment[]) => { const args = { segments, validTypes: ['properties', 'taxlots'], validPage: 'derived-columns' } return genericTypeMatcher(args)(segments) @@ -63,14 +52,9 @@ export default [ }, }, { - matcher: columnMappingTypeMatcher, - title: 'Column Mappings', - component: ColumnMappingsComponent, - }, - { - matcher: columnSettingsTypeMatcher, - title: 'Column Settings', - component: ColumnSettingsComponent, + path: 'columns', + component: ColumnsComponent, + loadChildren: () => import('app/modules/organizations/columns/columns.routes'), }, { matcher: dataQualityTypeMatcher,