diff --git a/web-app/admin/src/app/admin/admin-layers/admin-layers.module.ts b/web-app/admin/src/app/admin/admin-layers/admin-layers.module.ts new file mode 100644 index 000000000..ca95b3952 --- /dev/null +++ b/web-app/admin/src/app/admin/admin-layers/admin-layers.module.ts @@ -0,0 +1,45 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatTableModule } from '@angular/material/table'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { CoreModule } from '../../core/core.module'; +import { LayerDashboardComponent } from './dashboard/layer-dashboard.component'; +import { CreateLayerDialogComponent } from './create-layer/create-layer.component'; +import { LayersService } from './layers.service'; +import { AdminBreadcrumbModule } from '../admin-breadcrumb/admin-breadcrumb.module'; + +@NgModule({ + declarations: [ + LayerDashboardComponent, + CreateLayerDialogComponent + ], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatPaginatorModule, + MatTableModule, + MatIconModule, + MatSelectModule, + MatFormFieldModule, + MatTooltipModule, + CoreModule, + AdminBreadcrumbModule + ], + providers: [ + LayersService + ], + exports: [ + LayerDashboardComponent + ] +}) +export class AdminLayersModule { } diff --git a/web-app/admin/src/app/admin/admin-layers/create-layer/create-layer.component.html b/web-app/admin/src/app/admin/admin-layers/create-layer/create-layer.component.html new file mode 100644 index 000000000..68ae97931 --- /dev/null +++ b/web-app/admin/src/app/admin/admin-layers/create-layer/create-layer.component.html @@ -0,0 +1,141 @@ +
+
+

Create a New Layer

+
+ +
+
+ + {{ errorMessage }} +
+ +
+
+ + +
+ + Name is required + + + A layer with this name already exists + +
+
+ +
+ + +
+ + + WMS, XYZ or TMS Layers + + + + Static layers (KML/KMZ) + + + + Downloadable GeoPackage layers + +
+
+ + Type is required + +
+
+ + +
+
+ + +
+ + URL is required for Imagery layers + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+
+ + +
+
+ +
+ + +
+
+ + Select a .gpkg file to upload +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
\ No newline at end of file diff --git a/web-app/admin/src/app/admin/admin-layers/create-layer/create-layer.component.scss b/web-app/admin/src/app/admin/admin-layers/create-layer/create-layer.component.scss new file mode 100644 index 000000000..182a9eba0 --- /dev/null +++ b/web-app/admin/src/app/admin/admin-layers/create-layer/create-layer.component.scss @@ -0,0 +1,341 @@ +.dialog-modal { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.dialog-header { + padding: 24px 24px 16px; + border-bottom: 1px solid #e0e0e0; + flex-shrink: 0; +} + +.dialog-title { + margin: 0; + font-size: 20px; + font-weight: 500; + color: #333; +} + +.dialog-content { + padding: 24px; + flex: 1; + overflow-y: auto; + min-height: 0; + position: relative; +} + +.error-popover { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + background-color: #f8d7da; + color: #721c24; + padding: 10px 20px; + border-radius: 4px; + border: 1px solid #f5c6cb; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + display: flex; + align-items: center; + gap: 8px; + z-index: 1000; + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translate(-50%, -10px); + } + + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +.layer-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .field-label { + font-size: 0.875rem; + font-weight: 600; + color: #374151; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .form-input { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.875rem; + background-color: #f9fafb; + transition: all 0.2s ease; + resize: vertical; + font-family: inherit; + + &:focus { + outline: none; + border-color: #3b82f6; + background-color: white; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } + + &::placeholder { + color: #9ca3af; + } + + &:disabled { + background-color: #f3f4f6; + color: #9ca3af; + cursor: not-allowed; + } + } + + // Select-specific styles + select.form-input { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 1rem center; + padding-right: 2.5rem; + } + + .field-error { + min-height: 1.2em; + display: flex; + align-items: center; + gap: 4px; + } + + .field-error .error-text { + opacity: 0; + transition: opacity 0.2s ease; + color: #d9534f; + font-size: 0.875rem; + } + + .field-error .error-text.visible { + opacity: 1; + } + + .type-description { + margin-top: 0.25rem; + min-height: 0; + } + + .description-text { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: #6b7280; + font-style: italic; + + i { + color: #3b82f6; + font-size: 0.875rem; + } + } +} + +.conditional-fields { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0.75rem; + background-color: #f9fafb; + border-radius: 8px; + border: 1px solid #e5e7eb; +} + +.radio-group { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; +} + +.radio-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.875rem; + color: #374151; + + input[type="radio"] { + width: 1rem; + height: 1rem; + cursor: pointer; + } + + &:hover { + color: #1976d2; + } +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.875rem; + color: #374151; + font-weight: 500; + + input[type="checkbox"] { + width: 1.125rem; + height: 1.125rem; + cursor: pointer; + } + + &:hover { + color: #1976d2; + } +} + +.file-upload-container { + display: flex; + flex-direction: column; +} + +.file-input { + display: none; +} + +.file-label { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.875rem 1.5rem; + background-color: white; + border: 2px dashed #d1d5db; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; + color: #6b7280; + font-weight: 500; + + i { + font-size: 1rem; + color: #3b82f6; + } + + .file-name { + color: #374151; + font-weight: 600; + } + + &:hover { + border-color: #3b82f6; + background-color: #f0f9ff; + } +} + +.field-hint { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + font-size: 0.8125rem; + color: #6b7280; + font-style: italic; + + i { + color: #3b82f6; + font-size: 0.8125rem; + } +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + border-top: 1px solid #e0e0e0; + flex-shrink: 0; + background-color: transparent; +} + +.action-button { + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; + display: inline-flex; + align-items: center; + gap: 8px; + + &:hover:not(:disabled) { + opacity: 0.9; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:not(.btn-primary) { + background-color: transparent; + color: #1976d2; + + &:hover:not(:disabled) { + background-color: rgba(25, 118, 210, 0.08); + } + } + + &.btn-primary { + background-color: #1976d2; + color: white; + + &:hover:not(:disabled) { + background-color: #1565c0; + } + } +} + +::ng-deep .mat-dialog-container { + padding: 0; + border-radius: 12px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important; +} + +::ng-deep mat-dialog-container { + min-height: 460px; + max-height: 90vh; + width: 700px; +} + +::ng-deep .mat-dialog-actions { + padding: 0; + margin: 0; +} + +::ng-deep .mat-dialog-content { + padding: 0; + margin: 0; +} + +::ng-deep .mat-dialog-title { + padding: 0; + margin: 0; +} \ No newline at end of file diff --git a/web-app/admin/src/app/admin/admin-layers/create-layer/create-layer.component.ts b/web-app/admin/src/app/admin/admin-layers/create-layer/create-layer.component.ts new file mode 100644 index 000000000..2a0b2facd --- /dev/null +++ b/web-app/admin/src/app/admin/admin-layers/create-layer/create-layer.component.ts @@ -0,0 +1,174 @@ +import { Component, Inject } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators, AbstractControl, ValidationErrors, AsyncValidatorFn } from '@angular/forms'; +import { LayersService, Layer } from '../layers.service'; +import { Observable, of } from 'rxjs'; +import { map, catchError, debounceTime, first } from 'rxjs/operators'; + +/** + * Dialog component for creating new layers. + * Provides a form interface with validation for layer name (required) and description (optional). + */ +@Component({ + selector: 'mage-admin-layer-create', + templateUrl: './create-layer.component.html', + styleUrls: ['./create-layer.component.scss'] +}) +export class CreateLayerDialogComponent { + layerForm: FormGroup; + errorMessage: string = ''; + geopackageFile: File | null = null; + geopackageFileName: string = ''; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { layer: Partial }, + private fb: FormBuilder, + private layersService: LayersService + ) { + this.layerForm = this.fb.group({ + name: [ + data.layer?.name ?? '', + [Validators.required], + [this.duplicateLayerNameValidator()] + ], + type: [data.layer?.type ?? '', [Validators.required]], + description: [data.layer?.description ?? ''], + url: [''], + format: ['XYZ'], + base: [false] + }); + } + + /** + * Async validator to check if a layer name already exists + */ + private duplicateLayerNameValidator(): AsyncValidatorFn { + return (control: AbstractControl): Observable => { + if (!control.value) { + return of(null); + } + + return this.layersService.getLayers().pipe( + debounceTime(300), + map(layers => { + const nameExists = layers.some( + layer => layer.name?.toLowerCase() === control.value.toLowerCase() + ); + return nameExists ? { duplicateName: true } : null; + }), + catchError(() => of(null)), + first() + ); + }; + } + + /** + * Handles layer type change to add/remove validators and reset fields + */ + onTypeChange(): void { + const type = this.layerForm.get('type')?.value; + const urlControl = this.layerForm.get('url'); + const formatControl = this.layerForm.get('format'); + const baseControl = this.layerForm.get('base'); + + // Reset conditional fields + urlControl?.clearValidators(); + urlControl?.setValue(''); + formatControl?.setValue('XYZ'); + baseControl?.setValue(false); + this.geopackageFile = null; + this.geopackageFileName = ''; + + // Add validators based on layer type + if (type === 'Imagery') { + urlControl?.setValidators([Validators.required]); + } + + // Update validity + urlControl?.updateValueAndValidity(); + } + + /** + * Handles GeoPackage file selection + */ + onGeoPackageFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.geopackageFile = input.files[0]; + this.geopackageFileName = this.geopackageFile.name; + } + } + + /** + * Handles form submission for creating a new layer. + * Validates the form, creates the layer via the layers service, and closes the dialog on success. + */ + save(): void { + if (this.layerForm.invalid) { + this.errorMessage = 'Please fill in all required fields.'; + return; + } + + if (this.layerForm.get('type')?.value === 'GeoPackage' && !this.geopackageFile) { + this.errorMessage = 'Please select a GeoPackage file.'; + return; + } + + this.errorMessage = ''; + const formValue = this.layerForm.value; + + let layerData: any; + + if (formValue.type === 'GeoPackage' && this.geopackageFile) { + const formData = new FormData(); + formData.append('name', formValue.name); + formData.append('type', formValue.type); + if (formValue.description) { + formData.append('description', formValue.description); + } + formData.append('geopackage', this.geopackageFile); + layerData = formData; + } else { + // Use regular JSON for other layer types + layerData = { + name: formValue.name, + type: formValue.type, + description: formValue.description + }; + + if (formValue.type === 'Imagery') { + layerData.url = formValue.url; + layerData.format = formValue.format; + layerData.base = formValue.base; + } + } + + this.layersService.createLayer(layerData).subscribe({ + next: (newLayer) => { + this.dialogRef.close(newLayer); + }, + error: (err) => { + if (err.status === 400 && err.error?.errors) { + const fieldErrors = err.error.errors; + if (fieldErrors.name?.type === 'unique') { + this.errorMessage = fieldErrors.name.message; + } else { + this.errorMessage = err.error.message ?? 'Validation failed'; + } + } else if (err.status === 409) { + this.errorMessage = err.error; + } else { + this.errorMessage = 'Failed to create layer. Please try again.'; + } + } + }); + } + + /** + * Closes the dialog without saving any data or making any changes. + */ + cancel(): void { + this.dialogRef.close(); + } +} diff --git a/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.html b/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.html new file mode 100644 index 000000000..9e1bcdbf7 --- /dev/null +++ b/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.html @@ -0,0 +1,70 @@ + + +
+ + +
+
+
+ + + + Type + + All Layers + Online Layers + Downloadable Layers + + +
+ +
+ +
+
+ +
+ + + + + + + + + +
Layer +
+ +
+
{{ layer.name }}
+
+

+ {{ layer.description || 'No description' }} +

+ {{ layer.type }} +
+
+
+
+
+ + +
No layers found.
+
+ + +
+
\ No newline at end of file diff --git a/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.scss b/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.scss new file mode 100644 index 000000000..d43eb3db2 --- /dev/null +++ b/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.scss @@ -0,0 +1,341 @@ +::ng-deep .admin-main-content { + min-width: 520px; + + &:has(admin-users) { + height: 92vh; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + + @media (max-height: 500px) { + overflow-y: scroll; + } + } + + admin-users { + .container { + height: 90vh; + } + } +} + +$min-height: 40.8vh; +$max-height: 72.8vh; + +.container.bottom-gap-l { + margin-bottom: 2rem; +} + +.admin-nav-gap { + margin-top: -15px; + margin-bottom: -15px; + + .breadcrumbs-toolbar { + min-height: 36px !important; + height: 36px; + background-color: #f0f0f0; + color: #555; + font-size: 13px; + padding: 0 16px; + border-radius: 4px; + + mat-icon { + font-size: 18px; + margin-right: 4px; + vertical-align: middle; + color: #777; + } + + span { + display: flex; + align-items: center; + gap: 4px; + } + } +} + +.page-header { + margin-bottom: 20px; + margin-top: 5px; + padding: 0; + + .page-title { + font-size: 2rem; + font-weight: 700; + color: #1a1a1a; + margin: 0 0 0.5rem; + line-height: 1.2; + letter-spacing: -0.025em; + } + + .page-subtitle { + font-size: 1rem; + color: #666; + margin: 0; + line-height: 1.5; + font-weight: 400; + } +} + +.btn-primary { + background-color: #1e88e5; + color: #fff; +} + +.integrated-navbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + padding: 1rem 1.5rem; + border-bottom: 2px solid #e5e7eb; + height: 85px; + + mage-card-navbar { + flex: 1; + margin-right: 1rem; + min-width: 200px; + background: transparent; + box-shadow: none; + border-radius: 0; + margin-bottom: 0; + } + + @media (max-width: 768px) { + flex-direction: column; + align-items: stretch; + margin: 0; + padding: 5px; + + .button-group { + width: auto; + margin: 0 auto; + } + } +} + +.table-wrapper { + border-radius: 12px; + background: #fff; + border: 1px solid #e5e7eb; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + height: 75vh; + overflow: hidden; + + @media (max-height: 1100px) { + height: 73vh; + } + + @media (max-height: 1000px) { + height: 71vh; + } + + @media (max-height: 900px) { + height: 69vh; + } + + @media (max-height: 800px) { + height: 64vh; + } + + @media (max-height: 700px) { + height: 57vh; + } + + @media (max-height: 600px) { + height: 285px; + } +} + +.table-scroll { + height: 62.2vh; + + @media (max-width: 768px) { + height: 60.2vh; + } + + overflow-y: auto; + max-height: 100%; + + @media (max-height: 1100px) { + height: 59vh; + } + + @media (max-height: 1000px) { + height: 55vh; + } + + @media (max-height: 900px) { + height: 52vh; + } + + @media (max-height: 800px) { + height: 44vh; + } + + @media (max-height: 700px) { + height: 34vh; + } + + @media (max-height: 600px) { + height: 142.5px; + } + + table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + + th, + td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .mat-header-row { + position: sticky; + top: 0; + z-index: 2; + background: #f9fafb; + } + + .mat-row, + .mat-cell, + .mat-header-cell { + height: 3.5rem; + padding: 0.5rem; + + @media (max-height: 768px) { + height: 3.2rem; + } + + @media (max-height: 600px) { + height: 3rem; + padding: 0.4rem; + } + + @media (max-height: 480px) { + height: 2.8rem; + padding: 0.3rem; + font-size: 0.85rem; + } + + @media (max-height: 400px) { + height: 2.5rem; + font-size: 0.75rem; + } + + @media (max-height: 350px) { + height: 2.3rem; + font-size: 0.7rem; + } + } +} + +.pagination { + display: contents; +} + +.mat-column-actions { + text-align: right; + + .mat-cell, + .mat-header-cell { + justify-content: flex-end; + text-align: right; + } + + .d-flex { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + } +} + +.button-group { + margin-top: -20px; + + button { + height: 43px; + white-space: nowrap; + } + + button:not(:last-child) { + margin-right: 40px; + } +} + +.filters { + margin-top: -10px; + align-items: center !important; + display: flex !important; +} + +:host { + .type-filter { + width: 150px; + margin-left: 1rem; + margin-top: 10px; + font-size: 14px; + + ::ng-deep .mat-form-field-appearance-outline .mat-form-field-infix { + padding: 10px; + } + + ::ng-deep .mat-form-field-infix { + padding: 10px; + } + + ::ng-deep .mat-form-field-label-wrapper { + top: -12px; + left: -1px; + } + } + + ::ng-deep input.search-input.ng-untouched.ng-pristine.ng-valid, + ::ng-deep input.search-input.ng-valid.ng-dirty.ng-touched, + ::ng-deep input.search-input.ng-valid.ng-dirty.ng-untouched { + height: 43px; + margin-top: -10px; + } +} + +.d-flex { + display: flex; + gap: 25px; +} + +.align-items-center { + align-items: center !important; +} + +.icon { + font-size: 25px; +} + +.ellipsis { + text-overflow: ellipsis; + overflow: hidden; +} + +::ng-deep .mat-tooltip-event-desc { + position: relative; + left: 0 !important; + font-size: 20px; + min-width: 75vw; +} + +.w-100 { + width: 100% !important; +} + +.w-90 { + width: 90% !important; +} + +.description-text { + max-width: 40%; + margin-bottom: 0; +} \ No newline at end of file diff --git a/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.spec.ts b/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.spec.ts new file mode 100644 index 000000000..a6454bb89 --- /dev/null +++ b/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.spec.ts @@ -0,0 +1,407 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { StateService } from '@uirouter/angular'; +import { of, throwError } from 'rxjs'; + +import { LayerDashboardComponent } from './layer-dashboard.component'; +import { LayersService, Layer } from '../layers.service'; +import { UserService } from 'admin/src/app/upgrade/ajs-upgraded-providers'; +import { PageEvent } from '@angular/material/paginator'; + +describe('LayerDashboardComponent', () => { + let component: LayerDashboardComponent; + let fixture: ComponentFixture; + let mockLayersService: jasmine.SpyObj; + let mockDialog: jasmine.SpyObj; + let mockStateService: jasmine.SpyObj; + let mockUserService: any; + + const mockLayers: Layer[] = [ + { + id: 1, + name: 'Test Imagery Layer', + description: 'Test imagery description', + type: 'Imagery', + url: 'http://example.com/imagery', + state: 'available' + }, + { + id: 2, + name: 'Test Feature Layer', + description: 'Test feature description', + type: 'Feature', + url: 'http://example.com/feature', + state: 'available' + }, + { + id: 3, + name: 'Test GeoPackage Layer', + description: 'Test geopackage description', + type: 'GeoPackage', + state: 'available' + } + ]; + + beforeEach(async () => { + mockLayersService = jasmine.createSpyObj('LayersService', ['getLayers', 'deleteLayer']); + mockDialog = jasmine.createSpyObj('MatDialog', ['open']); + mockStateService = jasmine.createSpyObj('StateService', ['go']); + mockUserService = { + myself: { + role: { + permissions: ['CREATE_LAYER', 'UPDATE_LAYER', 'DELETE_LAYER'] + } + } + }; + + mockLayersService.getLayers.and.returnValue(of(mockLayers)); + + await TestBed.configureTestingModule({ + declarations: [LayerDashboardComponent], + providers: [ + { provide: LayersService, useValue: mockLayersService }, + { provide: MatDialog, useValue: mockDialog }, + { provide: StateService, useValue: mockStateService }, + { provide: UserService, useValue: mockUserService } + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LayerDashboardComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should initialize permissions and load layers', () => { + fixture.detectChanges(); + + expect(component.hasLayerCreatePermission).toBe(true); + expect(component.hasLayerEditPermission).toBe(true); + expect(component.hasLayerDeletePermission).toBe(true); + expect(mockLayersService.getLayers).toHaveBeenCalledWith({ includeUnavailable: true }); + expect(component.layers.length).toBe(3); + expect(component.filteredLayers.length).toBe(3); + }); + + it('should set permissions to false when user has no permissions', () => { + mockUserService.myself.role.permissions = []; + fixture.detectChanges(); + + expect(component.hasLayerCreatePermission).toBe(false); + expect(component.hasLayerEditPermission).toBe(false); + expect(component.hasLayerDeletePermission).toBe(false); + }); + + it('should handle missing user permissions gracefully', () => { + mockUserService.myself = null; + fixture.detectChanges(); + + expect(component.hasLayerCreatePermission).toBe(false); + expect(component.hasLayerEditPermission).toBe(false); + expect(component.hasLayerDeletePermission).toBe(false); + }); + }); + + describe('refreshLayers', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should fetch and update layers', () => { + const newLayers: Layer[] = [ + { id: 4, name: 'New Layer', type: 'Imagery', state: 'available' } + ]; + mockLayersService.getLayers.and.returnValue(of(newLayers)); + + component.refreshLayers(); + + expect(mockLayersService.getLayers).toHaveBeenCalled(); + expect(component.layers).toEqual(newLayers); + expect(component.filteredLayers).toEqual(newLayers); + }); + + it('should handle error when fetching layers', () => { + spyOn(console, 'error'); + mockLayersService.getLayers.and.returnValue(throwError(() => new Error('Fetch error'))); + + component.refreshLayers(); + + expect(console.error).toHaveBeenCalledWith('Error fetching layers:', jasmine.any(Error)); + }); + }); + + describe('filtering', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should filter layers by search term in name', () => { + component.layerSearch = 'imagery'; + component.onSearchChanged(); + + expect(component.filteredLayers.length).toBe(1); + expect(component.filteredLayers[0].name).toBe('Test Imagery Layer'); + expect(component.page).toBe(0); + }); + + it('should filter layers by search term in description', () => { + component.layerSearch = 'feature description'; + component.onSearchChanged(); + + expect(component.filteredLayers.length).toBe(1); + expect(component.filteredLayers[0].type).toBe('Feature'); + }); + + it('should filter layers by search term in URL', () => { + component.layerSearch = 'example.com/imagery'; + component.onSearchChanged(); + + expect(component.filteredLayers.length).toBe(1); + expect(component.filteredLayers[0].id).toBe(1); + }); + + it('should be case-insensitive when searching', () => { + component.layerSearch = 'IMAGERY'; + component.onSearchChanged(); + + expect(component.filteredLayers.length).toBe(1); + }); + + it('should return all layers when search is empty', () => { + component.layerSearch = ''; + component.onSearchChanged(); + + expect(component.filteredLayers.length).toBe(3); + }); + + it('should filter by type - online (Imagery)', () => { + component.onTypeFilterChange('online'); + + expect(component.filteredLayers.length).toBe(1); + expect(component.filteredLayers[0].type).toBe('Imagery'); + }); + + it('should filter by type - offline (non-Imagery)', () => { + component.onTypeFilterChange('offline'); + + expect(component.filteredLayers.length).toBe(2); + expect(component.filteredLayers.every(l => l.type !== 'Imagery')).toBe(true); + }); + + it('should show all layers when filter is "all"', () => { + component.onTypeFilterChange('all'); + + expect(component.filteredLayers.length).toBe(3); + }); + + it('should combine search and type filters', () => { + component.layerSearch = 'test'; + component.onTypeFilterChange('online'); + + expect(component.filteredLayers.length).toBe(1); + expect(component.filteredLayers[0].type).toBe('Imagery'); + }); + }); + + describe('pagination', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should get paginated layers correctly', () => { + component.page = 0; + component.itemsPerPage = 2; + + const paginated = component.getPaginatedLayers(); + + expect(paginated.length).toBe(2); + expect(paginated[0].id).toBe(1); + expect(paginated[1].id).toBe(2); + }); + + it('should get second page of layers', () => { + component.page = 1; + component.itemsPerPage = 2; + + const paginated = component.getPaginatedLayers(); + + expect(paginated.length).toBe(1); + expect(paginated[0].id).toBe(3); + }); + + it('should handle page change event', () => { + const event: PageEvent = { + pageIndex: 1, + pageSize: 5, + length: 10 + }; + + component.onPageChange(event); + + expect(component.page).toBe(1); + expect(component.itemsPerPage).toBe(5); + }); + + it('should update total layers count', () => { + expect(component.totalLayers).toBe(3); + + component.layerSearch = 'imagery'; + component.onSearchChanged(); + + expect(component.totalLayers).toBe(1); + }); + }); + + describe('type counts', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should return correct count for all layers', () => { + expect(component.getTypeCount('all')).toBe(3); + }); + + it('should return correct count for online layers', () => { + expect(component.getTypeCount('online')).toBe(1); + }); + + it('should return correct count for offline layers', () => { + expect(component.getTypeCount('offline')).toBe(2); + }); + }); + + describe('search handlers', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should handle search term changed from card navbar', () => { + component.page = 1; + component.onSearchTermChanged('new search'); + + expect(component.layerSearch).toBe('new search'); + expect(component.page).toBe(0); + }); + + it('should handle search cleared', () => { + component.layerSearch = 'some search'; + component.page = 2; + component.onSearchCleared(); + + expect(component.layerSearch).toBe(''); + expect(component.page).toBe(0); + }); + }); + + describe('reset', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should reset all filters and pagination', () => { + component.layerSearch = 'search term'; + component.page = 2; + component.typeFilter = 'online'; + + component.reset(); + + expect(component.layerSearch).toBe(''); + expect(component.page).toBe(0); + expect(component.typeFilter).toBe('all'); + expect(component.filteredLayers.length).toBe(3); + }); + }); + + describe('layer creation', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should open create layer dialog', () => { + const mockDialogRef = { + afterClosed: () => of(null) + }; + mockDialog.open.and.returnValue(mockDialogRef as any); + + component.newLayer(); + + expect(mockDialog.open).toHaveBeenCalled(); + }); + + it('should refresh and navigate after creating layer', () => { + const newLayer: Layer = { id: 5, name: 'New Layer', type: 'Imagery', state: 'available' }; + const mockDialogRef = { + afterClosed: () => of(newLayer) + }; + mockDialog.open.and.returnValue(mockDialogRef as any); + spyOn(component, 'refreshLayers'); + + component.newLayer(); + + expect(component.refreshLayers).toHaveBeenCalled(); + expect(mockStateService.go).toHaveBeenCalledWith('admin.layer', { layerId: 5 }); + }); + + it('should not navigate if dialog is cancelled', () => { + const mockDialogRef = { + afterClosed: () => of(null) + }; + mockDialog.open.and.returnValue(mockDialogRef as any); + spyOn(component, 'refreshLayers'); + + component.newLayer(); + + expect(component.refreshLayers).not.toHaveBeenCalled(); + expect(mockStateService.go).not.toHaveBeenCalled(); + }); + }); + + describe('responsive layout', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should update layout values on window resize', () => { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1000 + }); + + component.onResize(); + + expect(component.numChars).toBe(Math.ceil(1000 / 8.5)); + expect(component.toolTipWidth).toBe('750px'); + }); + + it('should set initial responsive values', () => { + expect(component.numChars).toBeGreaterThan(0); + expect(component.toolTipWidth).toContain('px'); + }); + }); + + describe('breadcrumbs', () => { + it('should have correct breadcrumb configuration', () => { + expect(component.breadcrumbs.length).toBe(1); + expect(component.breadcrumbs[0].title).toBe('Layers'); + expect(component.breadcrumbs[0].iconClass).toBe('fa fa-map'); + }); + }); + + describe('initial state', () => { + it('should have correct default values', () => { + expect(component.layers).toEqual([]); + expect(component.filteredLayers).toEqual([]); + expect(component.layerSearch).toBe(''); + expect(component.page).toBe(0); + expect(component.itemsPerPage).toBe(10); + expect(component.typeFilter).toBe('all'); + expect(component.displayedColumns).toEqual(['layer']); + expect(component.pageSizeOptions).toEqual([5, 10, 25, 50]); + }); + }); +}); diff --git a/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.ts b/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.ts new file mode 100644 index 000000000..8c01e252d --- /dev/null +++ b/web-app/admin/src/app/admin/admin-layers/dashboard/layer-dashboard.component.ts @@ -0,0 +1,194 @@ +import { Component, OnInit, Inject, HostListener } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { PageEvent } from '@angular/material/paginator'; +import { StateService } from '@uirouter/angular'; +import { UserService } from 'admin/src/app/upgrade/ajs-upgraded-providers'; +import { LayersService, Layer } from '../layers.service'; +import { AdminBreadcrumb } from '../../admin-breadcrumb/admin-breadcrumb.model'; +import { CreateLayerDialogComponent } from '../create-layer/create-layer.component'; +import _ from 'underscore'; + +@Component({ + selector: 'mage-layer-dashboard', + templateUrl: './layer-dashboard.component.html', + styleUrls: ['./layer-dashboard.component.scss'] +}) +export class LayerDashboardComponent implements OnInit { + layers: Layer[] = []; + filteredLayers: Layer[] = []; + displayedColumns: string[] = ['layer']; + + layerSearch = ''; + page = 0; + itemsPerPage = 10; + totalLayers = 0; + pageSizeOptions = [5, 10, 25, 50]; + + numChars = 180; + toolTipWidth = '1000px'; + + typeFilter: 'all' | 'online' | 'offline' = 'all'; + + hasLayerCreatePermission = false; + hasLayerEditPermission = false; + hasLayerDeletePermission = false; + + breadcrumbs: AdminBreadcrumb[] = [ + { title: 'Layers', iconClass: 'fa fa-map' } + ]; + + constructor( + private modal: MatDialog, + private stateService: StateService, + private layersService: LayersService, + @Inject(UserService) private userService: any + ) { } + + ngOnInit(): void { + this.initPermissions(); + this.refreshLayers(); + this.updateResponsiveLayout(); + } + + /** Initialize permission flags */ + private initPermissions(): void { + const permissions = this.userService.myself?.role?.permissions || []; + this.hasLayerCreatePermission = _.contains(permissions, 'CREATE_LAYER'); + this.hasLayerEditPermission = _.contains(permissions, 'UPDATE_LAYER'); + this.hasLayerDeletePermission = _.contains(permissions, 'DELETE_LAYER'); + } + + /** Fetch and apply filters to the layer list */ + refreshLayers(): void { + this.layersService.getLayers({ includeUnavailable: true }).subscribe({ + next: (layers) => { + this.layers = layers; + this.applyFilters(); + }, + error: (err) => console.error('Error fetching layers:', err) + }); + } + + /** Apply search and type filters */ + private applyFilters(): void { + if (!this.layers) return; + + const term = this.layerSearch.trim().toLowerCase(); + + this.filteredLayers = this.layers.filter(layer => { + const matchesSearch = !term || + layer.name?.toLowerCase().includes(term) || + layer.description?.toLowerCase().includes(term) || + layer.url?.toLowerCase().includes(term); + + const matchesType = this.filterByType(layer); + + return matchesSearch && matchesType; + }); + + this.totalLayers = this.filteredLayers.length; + } + + /** Filter layers by type */ + private filterByType(layer: Layer): boolean { + switch (this.typeFilter) { + case 'all': + return true; + case 'online': + return layer.type === 'Imagery'; + case 'offline': + return layer.type !== 'Imagery'; + default: + return true; + } + } + + /** Get paginated layers for display */ + getPaginatedLayers(): Layer[] { + const startIndex = this.page * this.itemsPerPage; + const endIndex = startIndex + this.itemsPerPage; + return this.filteredLayers.slice(startIndex, endIndex); + } + + /** Get count for specific type filter */ + getTypeCount(type: 'all' | 'online' | 'offline'): number { + if (type === 'all') return this.filteredLayers.length; + if (type === 'online') { + return this.filteredLayers.filter(l => l.type === 'Imagery').length; + } + return this.filteredLayers.filter(l => l.type !== 'Imagery').length; + } + + /** Handle search term change */ + onSearchChanged(): void { + this.page = 0; + this.applyFilters(); + } + + /** Handle search term change from card navbar */ + onSearchTermChanged(term: string): void { + this.layerSearch = term; + this.page = 0; + this.applyFilters(); + } + + /** Handle search cleared from card navbar */ + onSearchCleared(): void { + this.layerSearch = ''; + this.page = 0; + this.applyFilters(); + } + + /** Reset all filters and pagination */ + reset(): void { + this.layerSearch = ''; + this.page = 0; + this.typeFilter = 'all'; + this.applyFilters(); + } + + /** Handle type filter change */ + onTypeFilterChange(type: 'all' | 'online' | 'offline'): void { + this.typeFilter = type; + this.page = 0; + this.applyFilters(); + } + + /** Handle pagination change */ + onPageChange(event: PageEvent): void { + this.page = event.pageIndex; + this.itemsPerPage = event.pageSize; + } + + /** Open create layer dialog */ + newLayer(): void { + const dialogRef = this.modal.open(CreateLayerDialogComponent, { + data: { layer: {} } + }); + + dialogRef.afterClosed().subscribe((newLayer) => { + if (newLayer) { + this.refreshLayers(); + this.stateService.go('admin.layer', { layerId: newLayer.id }); + } + }); + } + + /** Navigate to layer detail */ + gotoLayer(layer: Layer): void { + this.stateService.go('admin.layer', { layerId: layer.id }); + } + + /** Update layout-related values on resize */ + @HostListener('window:resize') + onResize(): void { + this.updateResponsiveLayout(); + } + + /** Calculates responsive values */ + private updateResponsiveLayout(): void { + this.numChars = Math.ceil(window.innerWidth / 8.5); + this.toolTipWidth = `${window.innerWidth * 0.75}px`; + } +} + diff --git a/web-app/admin/src/app/admin/admin-layers/layers.service.ts b/web-app/admin/src/app/admin/admin-layers/layers.service.ts new file mode 100644 index 000000000..c64568e35 --- /dev/null +++ b/web-app/admin/src/app/admin/admin-layers/layers.service.ts @@ -0,0 +1,71 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +export interface Layer { + id?: number; + name?: string; + description?: string; + type?: 'Imagery' | 'Feature' | 'GeoPackage'; + url?: string; + file?: { + name: string; + relativePath: string; + contentType: string; + size: number; + }; + format?: string; + base?: string; + wms?: any; + state?: 'available' | 'processing' | 'unavailable'; +} + +export interface LayersResponse { + items: Layer[]; + totalCount: number; +} + +export interface SearchOptions { + includeUnavailable?: boolean; + type?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class LayersService { + constructor(private http: HttpClient) { } + + getLayers(options?: SearchOptions): Observable { + let params = new HttpParams(); + + if (options?.includeUnavailable) { + params = params.set('includeUnavailable', 'true'); + } + if (options?.type) { + params = params.set('type', options.type); + } + + return this.http.get('/api/layers', { params }); + } + + getLayerById(id: string): Observable { + return this.http.get(`/api/layers/${id}`); + } + + deleteLayer(layer: Layer): Observable { + return this.http.delete(`/api/layers/${layer.id}`); + } + + updateLayer(id: string, layer: Partial): Observable { + return this.http.put(`/api/layers/${id}`, layer); + } + + createLayer(layer: Partial | FormData): Observable { + return this.http.post('/api/layers', layer); + } + + getLayerCount(): Observable<{ count: number }> { + return this.http.get<{ count: number }>('/api/layers/count'); + } +} diff --git a/web-app/admin/src/app/admin/admin.module.ts b/web-app/admin/src/app/admin/admin.module.ts index 1883ad78f..cea1da710 100644 --- a/web-app/admin/src/app/admin/admin.module.ts +++ b/web-app/admin/src/app/admin/admin.module.ts @@ -11,4 +11,4 @@ import { AdminBreadcrumbModule } from './admin-breadcrumb/admin-breadcrumb.modul declarations: [AdminPluginTabContentComponent, AdminNavComponent], exports: [AdminPluginTabContentComponent, AdminUsersModule, AdminNavComponent] }) -export class AdminModule {} +export class AdminModule { } diff --git a/web-app/admin/src/app/app.module.ts b/web-app/admin/src/app/app.module.ts index a09842ed3..c003c32d3 100644 --- a/web-app/admin/src/app/app.module.ts +++ b/web-app/admin/src/app/app.module.ts @@ -146,6 +146,7 @@ import { AdminEventFormModule } from './admin/admin-event/admin-event-form/admin import { AdminMapComponent } from './admin/admin-map/admin-map.component'; import { AdminTeamsModule } from './admin/admin-teams/admin-teams.module'; import { AdminEventsModule } from './admin/admin-event/admin-events.module'; +import { AdminLayersModule } from './admin/admin-layers/admin-layers.module'; import { AdminDashboardModule } from './admin/admin-dashboard/admin-dashboard.module'; import { MatMenuModule } from '@angular/material/menu'; import { AdminUsersModule } from './admin/admin-users/admin-users.module'; @@ -245,6 +246,7 @@ import { ObservationModule } from './observation/observation.module'; AdminTeamsModule, AdminUsersModule, AdminEventsModule, + AdminLayersModule, AdminEventFormModule, AdminFeedsModule, FeedItemSummaryModule, @@ -253,8 +255,7 @@ import { ObservationModule } from './observation/observation.module'; MatSlideToggleModule, MatStepperModule, InputMaskModule.forRoot(), - AdminDashboardModule, - AdminEventsModule + AdminDashboardModule ], providers: [ mapServiceProvider, diff --git a/web-app/admin/src/ng1/app.js b/web-app/admin/src/ng1/app.js index 768f92955..06d8718d8 100644 --- a/web-app/admin/src/ng1/app.js +++ b/web-app/admin/src/ng1/app.js @@ -34,6 +34,7 @@ import { EventDetailsComponent } from '../app/admin/admin-event/event-details/ev import { UserDetailsComponent } from '../app/admin/admin-users/user-details/user-details.component'; import { UserDashboardComponent } from '../app/admin/admin-users/dashboard/user-dashboard.component'; import { EventDashboardComponent } from '../app/admin/admin-event/dashboard/event-dashboard.component'; +import { LayerDashboardComponent } from '../app/admin/admin-layers/dashboard/layer-dashboard.component'; require('angular-minicolors'); require('select2'); @@ -126,6 +127,10 @@ app .directive( 'adminEvents', downgradeComponent({ component: EventDashboardComponent }) + ) + .directive( + 'layerDashboard', + downgradeComponent({ component: LayerDashboardComponent }) ); app @@ -351,7 +356,7 @@ function config( // Admin layer routes $stateProvider.state('admin.layers', { url: '/layers', - component: 'adminLayers', + component: 'layerDashboard', resolve: resolveAdmin() });