MME-8 Back to observables for edit-image-dialog so that testing works.

This commit is contained in:
Jesse Brault 2026-02-07 17:06:10 -06:00
parent 7d56c6e17c
commit 352110a372
5 changed files with 103 additions and 75 deletions

View File

@ -1,7 +1,7 @@
<app-dialog-container title="Edit Image">
@if (imageViewQuery.isLoading()) {
@if (loading()) {
<app-spinner></app-spinner>
} @else if (imageViewQuery.isError()) {
} @else if (loadError()) {
<p>There was an error.</p>
} @else {
<form (submit)="onSubmit($event)">
@ -18,6 +18,9 @@
<input matInput [formControl]="imageForm.controls.caption" />
</mat-form-field>
<button type="submit" matButton="filled">Submit</button>
@if (submitting()) {
<app-spinner size="24px"></app-spinner>
}
</form>
}
</app-dialog-container>

View File

@ -1,22 +1,68 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { EditImageDialog } from './edit-image-dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Mocked } from 'vitest';
import { ImageService } from '../../../../../../shared/services/ImageService';
import { ImageView } from '../../../../../../shared/models/ImageView.model';
import { of } from 'rxjs';
describe('EditImageDialog', () => {
let component: EditImageDialog;
let fixture: ComponentFixture<EditImageDialog>;
let matDialogRef: Partial<Mocked<MatDialogRef<any>>>;
const username = 'test-user';
const filename = 'test-file.jpg';
beforeEach(async () => {
matDialogRef = {
close: vi.fn(),
} as Partial<Mocked<MatDialogRef<any>>>;
await TestBed.configureTestingModule({
imports: [EditImageDialog],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: { username, filename },
},
{
provide: MatDialogRef,
useValue: matDialogRef,
},
{
provide: ImageService,
useValue: {
getImageView2: vi.fn((username, filename) =>
of({
filename,
owner: {
username,
},
} as ImageView),
),
updateImage: vi.fn(() => of({} as ImageView)),
} as Partial<Mocked<ImageService>>,
},
],
}).compileComponents();
fixture = TestBed.createComponent(EditImageDialog);
component = fixture.componentInstance;
await fixture.whenStable();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should close dialog after successful submit', async () => {
const formDebug = fixture.debugElement.query(By.css('form'));
expect(formDebug).toBeTruthy();
const formElement = formDebug.nativeElement as HTMLFormElement;
formElement.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
expect(matDialogRef.close).toHaveBeenCalled();
});
});

View File

@ -1,11 +1,10 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject, OnInit, signal } from '@angular/core';
import { DialogContainer } from '../../../../../../shared/components/dialog-container/dialog-container';
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ImageView } from '../../../../../../shared/models/ImageView.model';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../../../../../shared/services/ImageService';
import { notNullOrUndefined } from '../../../../../../shared/util';
import { Spinner } from '../../../../../../shared/components/spinner/spinner';
@ -21,11 +20,12 @@ export class EditImageDialog implements OnInit {
private readonly dialogRef = inject(MatDialogRef);
private readonly imageService = inject(ImageService);
protected readonly imageViewQuery = injectQuery(() =>
this.imageService.getImageView(this.usernameFilename[0], this.usernameFilename[1]),
);
protected readonly loading = signal(false);
protected readonly loadError = signal<Error | null>(null);
protected readonly imageView = signal<ImageView | null>(null);
private readonly imageViewMutation = injectMutation(() => this.imageService.updateImage2());
protected readonly submitting = signal(false);
protected readonly submitError = signal<Error | null>(null);
protected readonly imageForm = new FormGroup({
filename: new FormControl(
@ -40,18 +40,27 @@ export class EditImageDialog implements OnInit {
});
public ngOnInit(): void {
this.imageViewQuery.promise().then((imageView) => {
this.imageForm.patchValue({
filename: imageView.filename,
alt: imageView.alt,
caption: imageView.caption,
});
this.loading.set(true);
this.imageService.getImageView2(this.usernameFilename[0], this.usernameFilename[1]).subscribe({
next: (imageView) => {
this.loading.set(false); // shouldn't need this
this.imageView.set(imageView);
this.imageForm.patchValue({
filename: imageView.filename,
alt: imageView.alt,
caption: imageView.caption,
});
},
error: (e) => {
this.loading.set(false);
this.loadError.set(e);
},
});
}
public async onSubmit(event: SubmitEvent): Promise<void> {
protected async onSubmit(event: SubmitEvent): Promise<void> {
event.preventDefault();
const imageView: ImageView = this.imageViewQuery.data()!;
const imageView = this.imageView()!;
const formValue = this.imageForm.value;
if (notNullOrUndefined(formValue.alt)) {
imageView.alt = formValue.alt;
@ -59,10 +68,17 @@ export class EditImageDialog implements OnInit {
if (notNullOrUndefined(formValue.caption)) {
imageView.caption = formValue.caption;
}
this.imageViewMutation.mutate(imageView, {
onSuccess: () => {
this.submitting.set(true);
this.imageService.updateImage(imageView.owner.username, imageView.filename, imageView).subscribe({
next: (imageView) => {
this.submitting.set(false);
this.imageView.set(imageView);
this.dialogRef.close();
},
error: (e) => {
this.submitting.set(false);
this.submitError.set(e);
},
});
}
}

View File

@ -0,0 +1,8 @@
export interface ImageUpdateBody {
alt?: string | null;
caption?: string | null;
isPublic?: boolean | null;
viewersToAdd?: string[] | null;
viewersToRemove?: string[] | null;
clearAllViewers?: boolean | null;
}

View File

@ -1,13 +1,14 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom, map, tap } from 'rxjs';
import { firstValueFrom, map, Observable, tap } from 'rxjs';
import { EndpointService } from './EndpointService';
import { ImageView } from '../models/ImageView.model';
import { SliceView } from '../models/SliceView.model';
import { QueryParams } from '../models/Query.model';
import { mutationOptions, QueryClient, QueryOptions, queryOptions } from '@tanstack/angular-query-experimental';
import { QueryClient, QueryOptions, queryOptions } from '@tanstack/angular-query-experimental';
import { ImageViewWithBlobUrl } from '../client-models/ImageViewWithBlobUrl';
import { WithStringDates } from '../util';
import { ImageUpdateBody } from '../bodies/ImageUpdateBody';
@Injectable({
providedIn: 'root',
@ -74,18 +75,10 @@ export class ImageService {
});
}
public getImageView(username: string, filename: string) {
return queryOptions({
queryKey: ['image-views', username, filename],
queryFn: () =>
firstValueFrom(
this.httpClient
.get<
WithStringDates<ImageView>
>(this.endpointService.getUrl('images', [username, filename, 'view']))
.pipe(map((rawImageView) => this.hydrateImageView(rawImageView))),
),
});
public getImageView2(username: string, filename: string): Observable<ImageView> {
return this.httpClient
.get<WithStringDates<ImageView>>(this.endpointService.getUrl('images', [username, filename, 'view']))
.pipe(map((withStringDates) => this.hydrateImageView(withStringDates)));
}
public uploadImage(
@ -142,47 +135,9 @@ export class ImageService {
);
}
public updateImage(
username: string,
filename: string,
data: {
alt?: string | null;
caption?: string | null;
isPublic?: boolean | null;
viewersToAdd?: string[] | null;
viewersToRemove?: string[] | null;
clearAllViewers?: boolean | null;
},
): Promise<ImageView> {
return firstValueFrom(
this.httpClient.put<ImageView>(this.endpointService.getUrl('images', [username, filename]), data).pipe(
tap(async () => {
await this.queryClient.refetchQueries({
queryKey: ['image-views', username, filename],
});
await this.queryClient.refetchQueries({
queryKey: ['image-views-with-blob-urls', username, filename],
});
}),
),
);
}
public updateImage2() {
return mutationOptions({
mutationKey: ['image-views'],
mutationFn: (imageView: ImageView) =>
firstValueFrom(
this.httpClient.put(
this.endpointService.getUrl('images', [imageView.owner.username, imageView.filename]),
imageView,
),
),
onSuccess: async () => {
await this.queryClient.invalidateQueries({
queryKey: ['image-views'],
});
},
});
public updateImage(username: string, filename: string, data: ImageUpdateBody): Observable<ImageView> {
return this.httpClient
.put<WithStringDates<ImageView>>(this.endpointService.getUrl('images', [username, filename]), data)
.pipe(map((withStringDates) => this.hydrateImageView(withStringDates)));
}
}