diff --git a/AMW_angular/io/src/app/resources/contexts-list/contexts-list.component.ts b/AMW_angular/io/src/app/resources/contexts-list/contexts-list.component.ts
index 39e08b4f3..f322c650c 100644
--- a/AMW_angular/io/src/app/resources/contexts-list/contexts-list.component.ts
+++ b/AMW_angular/io/src/app/resources/contexts-list/contexts-list.component.ts
@@ -9,6 +9,7 @@ import { map } from 'rxjs/operators';
@Component({
selector: 'app-contexts-list',
+ standalone: true,
imports: [NgClass, UpperCasePipe],
templateUrl: './contexts-list.component.html',
styleUrl: './contexts-list.component.scss',
diff --git a/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.html b/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.html
index 6504d9b59..98bab997e 100644
--- a/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.html
+++ b/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.html
@@ -37,6 +37,12 @@
+
diff --git a/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.ts b/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.ts
index f943e6a04..5b41692de 100644
--- a/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.ts
+++ b/AMW_angular/io/src/app/resources/resource-edit/resource-edit.component.ts
@@ -14,6 +14,7 @@ import { ButtonComponent } from '../../shared/button/button.component';
import { NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle } from '@ng-bootstrap/ng-bootstrap';
import { ContextsListComponent } from '../contexts-list/contexts-list.component';
import { ResourcePropertiesComponent } from './resource-properties/resource-properties.component';
+import { ResourceReleasesComponent } from './resource-releases/resource-releases.component';
@Component({
selector: 'app-resource-edit',
@@ -30,6 +31,7 @@ import { ResourcePropertiesComponent } from './resource-properties/resource-prop
NgbDropdownItem,
ContextsListComponent,
ResourcePropertiesComponent,
+ ResourceReleasesComponent,
],
templateUrl: './resource-edit.component.html',
styleUrl: './resource-edit.component.scss',
diff --git a/AMW_angular/io/src/app/resources/resource-edit/resource-properties/resource-properties.component.ts b/AMW_angular/io/src/app/resources/resource-edit/resource-properties/resource-properties.component.ts
index 46981a3ee..4f4cbc97d 100644
--- a/AMW_angular/io/src/app/resources/resource-edit/resource-properties/resource-properties.component.ts
+++ b/AMW_angular/io/src/app/resources/resource-edit/resource-properties/resource-properties.component.ts
@@ -69,7 +69,7 @@ export class ResourcePropertiesComponent {
permissions = computed(() => {
if (this.authService.restrictions().length > 0) {
return {
- canAddProperty: this.authService.hasPermission('RESOURCE', 'UPDATE', null, null, this.context().name),
+ canAddProperty: this.authService.hasPermission('RESOURCE', 'UPDATE', null, null, this.context()?.name),
};
} else {
return { canAddProperty: false };
diff --git a/AMW_angular/io/src/app/resources/resource-edit/resource-releases/resource-releases.component.html b/AMW_angular/io/src/app/resources/resource-edit/resource-releases/resource-releases.component.html
new file mode 100644
index 000000000..8ea197f00
--- /dev/null
+++ b/AMW_angular/io/src/app/resources/resource-edit/resource-releases/resource-releases.component.html
@@ -0,0 +1,157 @@
+
+
+ @if (!releases() || releases().length < 1) {
+
+ No Release for this resource
+
+ } @else {
+
+
+ @for (release of releases(); track release) {
+
+ |
+ {{ release.name }}
+ |
+
+ @if (release.id === id() && permissions().canChangeRelease) {
+
+
+
+ }
+ @if (permissions().canDeleteRelease) {
+
+
+
+ }
+ |
+
+ }
+
+
+ }
+
+
+
+
+
+
Are you sure that you want to delete this release?
+
+
+
+
+
+
+
+ @if (availableReleases().length === 0) {
+
No available releases to create.
+ } @else {
+
+
+
+
+ }
+
+
+
+
+
+
+
+ @if (availableReleases().length === 0) {
+
No available releases to change to.
+ } @else {
+
+
+
+
+
+ This will move the current resource to the selected release. The resource will keep its properties and
+ relations.
+
+ }
+
+
+
diff --git a/AMW_angular/io/src/app/resources/resource-edit/resource-releases/resource-releases.component.ts b/AMW_angular/io/src/app/resources/resource-edit/resource-releases/resource-releases.component.ts
new file mode 100644
index 000000000..e0490736b
--- /dev/null
+++ b/AMW_angular/io/src/app/resources/resource-edit/resource-releases/resource-releases.component.ts
@@ -0,0 +1,242 @@
+import { Component, computed, inject, input, signal, ViewChild, TemplateRef } from '@angular/core';
+import { LoadingIndicatorComponent } from '../../../shared/elements/loading-indicator.component';
+import { AuthService } from '../../../auth/auth.service';
+import { TileComponent } from '../../../shared/tile/tile.component';
+import { Release } from '../../models/release';
+import { RouterLink, Router, ActivatedRoute } from '@angular/router';
+import { IconComponent } from '../../../shared/icon/icon.component';
+import { ButtonComponent } from '../../../shared/button/button.component';
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
+import { ResourceService } from '../../services/resource.service';
+import { Resource } from '../../models/resource';
+import { FormsModule } from '@angular/forms';
+import { ModalHeaderComponent } from '../../../shared/modal-header/modal-header.component';
+import { ToastService } from '../../../shared/elements/toast/toast.service';
+
+@Component({
+ selector: 'app-resource-releases',
+ standalone: true,
+ imports: [
+ LoadingIndicatorComponent,
+ TileComponent,
+ RouterLink,
+ IconComponent,
+ ButtonComponent,
+ FormsModule,
+ ModalHeaderComponent,
+ ],
+ templateUrl: './resource-releases.component.html',
+})
+export class ResourceReleasesComponent {
+ private authService = inject(AuthService);
+ private modalService = inject(NgbModal);
+ private resourceService = inject(ResourceService);
+ private router = inject(Router);
+ private route = inject(ActivatedRoute);
+ private toastService = inject(ToastService);
+
+ isLoading = signal(false);
+ releaseToDelete = signal
(null);
+ availableReleases = signal([]);
+ selectedReleaseId = signal(null);
+ isCreatingRelease = signal(false);
+ releaseToChange = signal(null);
+ isChangingRelease = signal(false);
+
+ @ViewChild('createReleaseModal') createReleaseModal!: TemplateRef;
+ @ViewChild('changeReleaseModal') changeReleaseModal!: TemplateRef;
+
+ id = input.required();
+ releases = input.required();
+ contextId = input.required();
+ resource = input.required();
+
+ permissions = computed(() => {
+ if (this.authService.restrictions().length > 0) {
+ const resourceTypeName = this.resource()?.type ?? null;
+ const resourceGroupId = this.resource()?.resourceGroupId ?? null;
+ return {
+ canAddRelease: this.authService.hasPermission('RESOURCE', 'CREATE', resourceTypeName, resourceGroupId),
+ canChangeRelease: this.authService.hasPermission('RELEASE', 'UPDATE', resourceTypeName, resourceGroupId),
+ canDeleteRelease: this.authService.hasPermission('RESOURCE', 'DELETE', resourceTypeName, resourceGroupId),
+ };
+ }
+ return { canAddRelease: false, canChangeRelease: false, canDeleteRelease: false };
+ });
+
+ addRelease() {
+ this.isLoading.set(true);
+ this.resourceService.getAvailableReleasesForResource(this.id()).subscribe({
+ next: (releases) => {
+ this.availableReleases.set(releases);
+ this.selectedReleaseId.set(null);
+ this.isLoading.set(false);
+ this.showCreateReleaseModal();
+ },
+ error: (error) => {
+ console.error('Failed to load available releases:', error);
+ this.toastService.error('Failed to load available releases.');
+ this.isLoading.set(false);
+ },
+ });
+ }
+
+ showCreateReleaseModal() {
+ const modalRef = this.modalService.open(this.createReleaseModal);
+ modalRef.result.then(
+ () => {
+ if (this.selectedReleaseId()) {
+ this.createRelease(this.selectedReleaseId()!);
+ }
+ },
+ () => {
+ this.selectedReleaseId.set(null);
+ },
+ );
+ }
+
+ createRelease(releaseId: number) {
+ const selectedRelease = this.availableReleases().find((r) => r.id === releaseId);
+ if (!selectedRelease || !selectedRelease.name) {
+ console.error('Selected release not found');
+ return;
+ }
+
+ const currentResource = this.resource();
+ if (!currentResource || !currentResource.name) {
+ console.error('Current resource not found');
+ return;
+ }
+
+ const currentReleaseName = this.releases().find((r) => r.id === this.id())?.name;
+ if (!currentReleaseName) {
+ console.error('Current release name not found');
+ return;
+ }
+
+ this.isCreatingRelease.set(true);
+ this.resourceService
+ .createResourceRelease(currentResource.name, selectedRelease.name, currentReleaseName)
+ .subscribe({
+ next: () => {
+ this.isCreatingRelease.set(false);
+ this.selectedReleaseId.set(null);
+ this.resourceService.setIdForResource(this.id());
+ },
+ error: (error) => {
+ console.error('Failed to create release:', error);
+ this.toastService.error('Failed to create release.');
+ this.isCreatingRelease.set(false);
+ this.selectedReleaseId.set(null);
+ },
+ });
+ }
+
+ showChangeReleaseModal(release: Release) {
+ this.releaseToChange.set(release);
+ this.isLoading.set(true);
+ this.resourceService.getAvailableReleasesForResource(release.id).subscribe({
+ next: (releases) => {
+ this.availableReleases.set(releases);
+ this.selectedReleaseId.set(null);
+ this.isLoading.set(false);
+ this.openChangeReleaseModal();
+ },
+ error: (error) => {
+ console.error('Failed to load available releases:', error);
+ this.toastService.error('Failed to load available releases.');
+ this.isLoading.set(false);
+ this.releaseToChange.set(null);
+ },
+ });
+ }
+
+ openChangeReleaseModal() {
+ const modalRef = this.modalService.open(this.changeReleaseModal);
+ modalRef.result.then(
+ () => {
+ if (this.selectedReleaseId() && this.releaseToChange()) {
+ this.changeRelease(this.releaseToChange()!.id, this.selectedReleaseId()!);
+ }
+ },
+ () => {
+ this.selectedReleaseId.set(null);
+ this.releaseToChange.set(null);
+ },
+ );
+ }
+
+ changeRelease(resourceId: number, releaseId: number) {
+ this.isChangingRelease.set(true);
+ this.resourceService.changeResourceRelease(resourceId, releaseId).subscribe({
+ next: () => {
+ this.isChangingRelease.set(false);
+ this.selectedReleaseId.set(null);
+ this.releaseToChange.set(null);
+ // Reload the resource and releases data to reflect the change
+ this.resourceService.setIdForResource(resourceId);
+ // Navigate to the changed resource (stays on the same resource, now in new release)
+ void this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams: { id: resourceId },
+ queryParamsHandling: 'merge',
+ });
+ },
+ error: (error) => {
+ console.error('Failed to change release:', error);
+ this.toastService.error('Failed to change release.');
+ this.isChangingRelease.set(false);
+ this.selectedReleaseId.set(null);
+ this.releaseToChange.set(null);
+ },
+ });
+ }
+
+ showDeleteConfirmation(content: unknown, release: Release) {
+ this.releaseToDelete.set(release);
+ this.modalService.open(content).result.then(
+ () => {
+ this.deleteRelease(release.id);
+ },
+ () => {
+ this.releaseToDelete.set(null);
+ },
+ );
+ }
+
+ deleteRelease(resourceIdToDelete: number) {
+ // Note: release.id is actually the Resource ID, not the Release ID
+ // This is how the backend constructs the ReleaseDTO
+ this.isLoading.set(true);
+ this.resourceService.deleteResourceByResourceId(resourceIdToDelete).subscribe({
+ next: () => {
+ this.isLoading.set(false);
+ this.releaseToDelete.set(null);
+ // Only navigate if we deleted the currently selected release
+ if (resourceIdToDelete === this.id()) {
+ const remainingReleases = this.releases().filter((r) => r.id !== resourceIdToDelete);
+ if (remainingReleases.length > 0) {
+ // Navigate to the first remaining release
+ void this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams: { id: remainingReleases[0].id },
+ queryParamsHandling: 'merge',
+ });
+ } else {
+ // No releases left, navigate to resources list
+ void this.router.navigate(['/resources']);
+ }
+ } else {
+ // Reload the releases list to remove the deleted release from the UI
+ this.resourceService.setIdForResource(this.id());
+ }
+ },
+ error: (error) => {
+ console.error('Failed to delete release:', error);
+ this.toastService.error('Failed to delete release.');
+ this.isLoading.set(false);
+ this.releaseToDelete.set(null);
+ },
+ });
+ }
+}
diff --git a/AMW_angular/io/src/app/resources/services/resource.service.ts b/AMW_angular/io/src/app/resources/services/resource.service.ts
index 88c70c62d..353265eff 100644
--- a/AMW_angular/io/src/app/resources/services/resource.service.ts
+++ b/AMW_angular/io/src/app/resources/services/resource.service.ts
@@ -5,7 +5,6 @@ import { map, catchError, switchMap, shareReplay } from 'rxjs/operators';
import { Resource } from '../models/resource';
import { Release } from '../models/release';
import { Relation } from '../models/relation';
-import { Property } from '../models/property';
import { AppWithVersion } from '../../deployment/app-with-version';
import { BaseService } from '../../base/base.service';
import { ResourceType } from '../models/resource-type';
@@ -202,6 +201,40 @@ export class ResourceService extends BaseService {
.get(`${this.getBaseUrl()}/resources/resourceGroups/releases/${resourceId}`)
.pipe(catchError(this.handleError));
}
+
+ deleteResourceByResourceId(resourceId: number): Observable {
+ return this.http
+ .delete(`${this.getBaseUrl()}/resources/${resourceId}`, {
+ headers: this.getHeaders(),
+ })
+ .pipe(catchError(this.handleError));
+ }
+
+ getAvailableReleasesForResource(resourceId: number): Observable {
+ return this.http
+ .get(`${this.getBaseUrl()}/resources/${resourceId}/availableReleases`, {
+ headers: this.getHeaders(),
+ })
+ .pipe(catchError(this.handleError));
+ }
+
+ createResourceRelease(resourceGroupName: string, releaseName: string, sourceReleaseName: string): Observable {
+ return this.http
+ .post(
+ `${this.getBaseUrl()}/resources/${resourceGroupName}`,
+ { releaseName, sourceReleaseName },
+ { headers: this.getHeaders() },
+ )
+ .pipe(catchError(this.handleError));
+ }
+
+ changeResourceRelease(resourceId: number, releaseId: number): Observable {
+ return this.http
+ .put(`${this.getBaseUrl()}/resources/${resourceId}/release/${releaseId}`, null, {
+ headers: this.getHeaders(),
+ })
+ .pipe(catchError(this.handleError));
+ }
}
function toAppWithVersion(r: any): AppWithVersion {
diff --git a/AMW_angular/io/src/app/shared/modal-header/modal-header.component.ts b/AMW_angular/io/src/app/shared/modal-header/modal-header.component.ts
index 0da2c7f9c..9d2d1dd4e 100644
--- a/AMW_angular/io/src/app/shared/modal-header/modal-header.component.ts
+++ b/AMW_angular/io/src/app/shared/modal-header/modal-header.component.ts
@@ -2,6 +2,7 @@ import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-modal-header',
+ standalone: true,
imports: [],
template: `