MME-8 Refactoring queries/mutations to solve stale data in image-edit dialog. TODO: clean up/refactoring.

This commit is contained in:
Jesse Brault 2026-02-06 23:26:10 -06:00
parent 7c483ba5f6
commit 7d56c6e17c
8 changed files with 120 additions and 33 deletions

View File

@ -12,6 +12,15 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])), provideHttpClient(withInterceptors([authInterceptor])),
provideTanStackQuery(new QueryClient(), withDevtools()), provideTanStackQuery(
new QueryClient({
defaultOptions: {
queries: {
experimental_prefetchInRender: true,
},
},
}),
withDevtools(),
),
], ],
}; };

View File

@ -1,17 +1,23 @@
<app-dialog-container title="Edit Image"> <app-dialog-container title="Edit Image">
<form (submit)="onSubmit($event)"> @if (imageViewQuery.isLoading()) {
<mat-form-field> <app-spinner></app-spinner>
<mat-label>Filename</mat-label> } @else if (imageViewQuery.isError()) {
<input matInput [formControl]="imageForm.controls.filename" /> <p>There was an error.</p>
</mat-form-field> } @else {
<mat-form-field> <form (submit)="onSubmit($event)">
<mat-label>Alt</mat-label> <mat-form-field>
<input matInput [formControl]="imageForm.controls.alt" /> <mat-label>Filename</mat-label>
</mat-form-field> <input matInput [formControl]="imageForm.controls.filename" />
<mat-form-field> </mat-form-field>
<mat-label>Caption</mat-label> <mat-form-field>
<input matInput [formControl]="imageForm.controls.caption" /> <mat-label>Alt</mat-label>
</mat-form-field> <input matInput [formControl]="imageForm.controls.alt" />
<button type="submit" matButton="filled">Submit</button> </mat-form-field>
</form> <mat-form-field>
<mat-label>Caption</mat-label>
<input matInput [formControl]="imageForm.controls.caption" />
</mat-form-field>
<button type="submit" matButton="filled">Submit</button>
</form>
}
</app-dialog-container> </app-dialog-container>

View File

@ -5,18 +5,27 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula
import { ImageView } from '../../../../../../shared/models/ImageView.model'; import { ImageView } from '../../../../../../shared/models/ImageView.model';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../../../../../shared/services/ImageService'; import { ImageService } from '../../../../../../shared/services/ImageService';
import { notNullOrUndefined } from '../../../../../../shared/util';
import { Spinner } from '../../../../../../shared/components/spinner/spinner';
@Component({ @Component({
selector: 'app-edit-image-dialog', selector: 'app-edit-image-dialog',
imports: [DialogContainer, MatFormField, MatLabel, MatInput, ReactiveFormsModule, MatButton], imports: [DialogContainer, MatFormField, MatLabel, MatInput, ReactiveFormsModule, MatButton, Spinner],
templateUrl: './edit-image-dialog.html', templateUrl: './edit-image-dialog.html',
styleUrl: './edit-image-dialog.css', styleUrl: './edit-image-dialog.css',
}) })
export class EditImageDialog implements OnInit { export class EditImageDialog implements OnInit {
protected readonly imageView: ImageView = inject(MAT_DIALOG_DATA); private readonly usernameFilename: [username: string, filename: string] = inject(MAT_DIALOG_DATA);
private readonly imageService = inject(ImageService);
private readonly dialogRef = inject(MatDialogRef); private readonly dialogRef = inject(MatDialogRef);
private readonly imageService = inject(ImageService);
protected readonly imageViewQuery = injectQuery(() =>
this.imageService.getImageView(this.usernameFilename[0], this.usernameFilename[1]),
);
private readonly imageViewMutation = injectMutation(() => this.imageService.updateImage2());
protected readonly imageForm = new FormGroup({ protected readonly imageForm = new FormGroup({
filename: new FormControl( filename: new FormControl(
@ -31,19 +40,29 @@ export class EditImageDialog implements OnInit {
}); });
public ngOnInit(): void { public ngOnInit(): void {
this.imageForm.patchValue({ this.imageViewQuery.promise().then((imageView) => {
filename: this.imageView.filename, this.imageForm.patchValue({
alt: this.imageView.alt, filename: imageView.filename,
caption: this.imageView.alt, alt: imageView.alt,
caption: imageView.caption,
});
}); });
} }
public async onSubmit(event: SubmitEvent): Promise<void> { public async onSubmit(event: SubmitEvent): Promise<void> {
event.preventDefault(); event.preventDefault();
await this.imageService.updateImage(this.imageView.owner.username, this.imageView.filename, { const imageView: ImageView = this.imageViewQuery.data()!;
alt: this.imageForm.value.alt, const formValue = this.imageForm.value;
caption: this.imageForm.value.caption, if (notNullOrUndefined(formValue.alt)) {
imageView.alt = formValue.alt;
}
if (notNullOrUndefined(formValue.caption)) {
imageView.caption = formValue.caption;
}
this.imageViewMutation.mutate(imageView, {
onSuccess: () => {
this.dialogRef.close();
},
}); });
this.dialogRef.close();
} }
} }

View File

@ -105,7 +105,7 @@ export class ImageSelect {
protected editImage(imageView: ImageView): void { protected editImage(imageView: ImageView): void {
this.dialog.open(EditImageDialog, { this.dialog.open(EditImageDialog, {
data: imageView, data: [imageView.owner.username, imageView.filename],
}); });
} }

View File

@ -1,10 +1,15 @@
import { ResourceOwner } from './ResourceOwner.model'; import { ResourceOwner } from './ResourceOwner.model';
export interface ImageView { export interface ImageView {
alt: string;
filename: string;
height: number | null;
owner: ResourceOwner;
url: string; url: string;
created: Date;
modified?: Date | null;
filename: string;
mimeType: string;
alt?: string | null;
caption?: string | null;
owner: ResourceOwner;
isPublic?: boolean;
height: number | null;
width: number | null; width: number | null;
} }

View File

@ -5,8 +5,9 @@ import { EndpointService } from './EndpointService';
import { ImageView } from '../models/ImageView.model'; import { ImageView } from '../models/ImageView.model';
import { SliceView } from '../models/SliceView.model'; import { SliceView } from '../models/SliceView.model';
import { QueryParams } from '../models/Query.model'; import { QueryParams } from '../models/Query.model';
import { QueryClient, QueryOptions, queryOptions } from '@tanstack/angular-query-experimental'; import { mutationOptions, QueryClient, QueryOptions, queryOptions } from '@tanstack/angular-query-experimental';
import { ImageViewWithBlobUrl } from '../client-models/ImageViewWithBlobUrl'; import { ImageViewWithBlobUrl } from '../client-models/ImageViewWithBlobUrl';
import { WithStringDates } from '../util';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -31,6 +32,14 @@ export class ImageService {
private readonly endpointService = inject(EndpointService); private readonly endpointService = inject(EndpointService);
private readonly queryClient = inject(QueryClient); private readonly queryClient = inject(QueryClient);
public hydrateImageView(rawImageView: WithStringDates<ImageView>): ImageView {
return {
...rawImageView,
created: new Date(rawImageView.created),
modified: rawImageView.modified ? new Date(rawImageView.modified) : undefined,
};
}
public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> { public getOwnedImages(queryParams?: QueryParams<typeof ImageService.ImageProps>): Promise<SliceView<ImageView>> {
return firstValueFrom( return firstValueFrom(
this.httpClient.get<SliceView<ImageView>>(this.endpointService.getUrl('images', [], queryParams)), this.httpClient.get<SliceView<ImageView>>(this.endpointService.getUrl('images', [], queryParams)),
@ -65,6 +74,20 @@ 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 uploadImage( public uploadImage(
image: File, image: File,
filename?: string, filename?: string,
@ -144,4 +167,22 @@ export class ImageService {
), ),
); );
} }
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'],
});
},
});
}
} }

View File

@ -9,6 +9,7 @@ import { WithStringDates } from '../util';
import { Recipe } from '../models/Recipe.model'; import { Recipe } from '../models/Recipe.model';
import { ImageView } from '../models/ImageView.model'; import { ImageView } from '../models/ImageView.model';
import { SetImageBody } from '../models/SetImageBody'; import { SetImageBody } from '../models/SetImageBody';
import { ImageService } from './ImageService';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -16,12 +17,14 @@ import { SetImageBody } from '../models/SetImageBody';
export class RecipeDraftService { export class RecipeDraftService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly endpointService = inject(EndpointService); private readonly endpointService = inject(EndpointService);
private readonly imageService = inject(ImageService);
private hydrateView(rawView: WithStringDates<RecipeDraftViewModel>): RecipeDraftViewModel { private hydrateView(rawView: WithStringDates<RecipeDraftViewModel>): RecipeDraftViewModel {
return { return {
...rawView, ...rawView,
created: new Date(rawView.created), created: new Date(rawView.created),
modified: rawView.modified ? new Date(rawView.modified) : undefined, modified: rawView.modified ? new Date(rawView.modified) : undefined,
mainImage: rawView.mainImage ? this.imageService.hydrateImageView(rawView.mainImage) : undefined,
lastInference: rawView.lastInference lastInference: rawView.lastInference
? { ? {
...rawView.lastInference, ...rawView.lastInference,

View File

@ -33,3 +33,7 @@ export type WithStringDates<T> = {
? WithStringDates<T[K]> | null | undefined ? WithStringDates<T[K]> | null | undefined
: T[K]; : T[K];
}; };
export const notNullOrUndefined = <T>(t: T | null | undefined): t is T => {
return t !== null && t !== undefined;
};