S16 postgresql opslag added
All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { test, expect, Page, APIRequestContext } from '@playwright/test';
|
||||
import type { School } from '../src/app/core/api-models';
|
||||
|
||||
const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080';
|
||||
@@ -6,11 +6,23 @@ let schoolToDelete: number | undefined;
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
if (schoolToDelete !== undefined) {
|
||||
await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`);
|
||||
await request.delete(`${backend}/api/dance-schools/${schoolToDelete}`, {
|
||||
headers: await revision(request, schoolToDelete),
|
||||
});
|
||||
schoolToDelete = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function revision(
|
||||
request: APIRequestContext,
|
||||
schoolId: number,
|
||||
): Promise<Record<string, string>> {
|
||||
const school = (await (
|
||||
await request.get(`${backend}/api/dance-schools/${schoolId}`)
|
||||
).json()) as School;
|
||||
return { 'X-School-Revision': String(school.revision) };
|
||||
}
|
||||
|
||||
async function tab(page: Page, label: string) {
|
||||
await page
|
||||
.getByRole('navigation', { name: 'Dansschool onderdelen' })
|
||||
@@ -113,6 +125,7 @@ test('manages a school, shared memberships and shows through the real REST API',
|
||||
const dancerResponse = await request.post(
|
||||
`${backend}/api/dance-schools/${schoolToDelete}/dancers`,
|
||||
{
|
||||
headers: await revision(request, schoolToDelete),
|
||||
data: { name, birthDate: '1990-01-01' },
|
||||
},
|
||||
);
|
||||
@@ -120,14 +133,17 @@ test('manages a school, shared memberships and shows through the real REST API',
|
||||
const groupResponse = await request.post(
|
||||
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups`,
|
||||
{
|
||||
headers: await revision(request, schoolToDelete),
|
||||
data: { category: name, type: 'REGULAR', startAge: 0, endAge: 99, styles: ['MODERN'] },
|
||||
},
|
||||
);
|
||||
const group = (await groupResponse.json()) as { id: number };
|
||||
await request.put(
|
||||
`${backend}/api/dance-schools/${schoolToDelete}/dance-groups/${group.id}/dancers/${dancer.id}`,
|
||||
{ headers: await revision(request, schoolToDelete) },
|
||||
);
|
||||
}
|
||||
await page.reload();
|
||||
await tab(page, 'Shows');
|
||||
await page.getByRole('button', { name: 'Show toevoegen' }).click();
|
||||
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Zomershow');
|
||||
@@ -214,12 +230,14 @@ test('links a coach and finalizes performances spread across multiple dates', as
|
||||
schoolToDelete = school.id;
|
||||
const base = `${backend}/api/dance-schools/${school.id}`;
|
||||
const dancerResponse = await request.post(`${base}/dancers`, {
|
||||
headers: await revision(request, school.id),
|
||||
data: { name: 'Dancing coach', birthDate: '1990-01-01' },
|
||||
});
|
||||
const dancer = (await dancerResponse.json()) as { id: number };
|
||||
const groupIds: number[] = [];
|
||||
for (const name of ['Preschool', 'Adults']) {
|
||||
const response = await request.post(`${base}/dance-groups`, {
|
||||
headers: await revision(request, school.id),
|
||||
data: {
|
||||
category: name,
|
||||
type: name === 'Preschool' ? 'PRE_SCHOOLER' : 'REGULAR',
|
||||
@@ -230,9 +248,12 @@ test('links a coach and finalizes performances spread across multiple dates', as
|
||||
});
|
||||
const group = (await response.json()) as { id: number };
|
||||
groupIds.push(group.id);
|
||||
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`);
|
||||
await request.put(`${base}/dance-groups/${group.id}/dancers/${dancer.id}`, {
|
||||
headers: await revision(request, school.id),
|
||||
});
|
||||
}
|
||||
const showResponse = await request.post(`${base}/shows`, {
|
||||
headers: await revision(request, school.id),
|
||||
data: {
|
||||
name: 'Coach across dates',
|
||||
preferredDayParts: 1,
|
||||
@@ -244,6 +265,7 @@ test('links a coach and finalizes performances spread across multiple dates', as
|
||||
});
|
||||
const show = (await showResponse.json()) as { id: number };
|
||||
await request.put(`${base}/shows/${show.id}/assignments`, {
|
||||
headers: await revision(request, school.id),
|
||||
data: { assignments: groupIds.map((id) => [id]) },
|
||||
});
|
||||
await page.goto(`/schools/${school.id}/shows`);
|
||||
@@ -271,3 +293,39 @@ test('shows a useful connection error and recovers on retry', async ({ page }) =
|
||||
await page.getByRole('button', { name: 'Opnieuw proberen' }).click();
|
||||
await expect(page.getByRole('heading', { name: /Mijn dansscholen/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('rejects a stale editor in another screen and allows reload and retry', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.post(`${backend}/api/dance-schools`, {
|
||||
data: { name: `Concurrent school ${Date.now()}` },
|
||||
});
|
||||
const school = (await response.json()) as School;
|
||||
schoolToDelete = school.id;
|
||||
const other = await page.context().newPage();
|
||||
try {
|
||||
for (const screen of [page, other]) {
|
||||
await screen.goto(`/schools/${school.id}/overview`);
|
||||
await screen.getByRole('button', { name: 'School bewerken' }).click();
|
||||
}
|
||||
await page.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('First confirmed edit');
|
||||
await other.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Stale edit');
|
||||
await save(page);
|
||||
await other.getByRole('dialog').getByRole('button', { name: 'Opslaan', exact: true }).click();
|
||||
await expect(other.getByRole('dialog').getByRole('alert')).toContainText('Herlaad');
|
||||
await expect(other.getByRole('dialog').getByLabel('Naam', { exact: true })).toHaveValue(
|
||||
'Stale edit',
|
||||
);
|
||||
await other.reload();
|
||||
await expect(
|
||||
other.getByRole('heading', { name: 'First confirmed edit', exact: true }),
|
||||
).toBeVisible();
|
||||
await other.getByRole('button', { name: 'School bewerken' }).click();
|
||||
await other.getByRole('dialog').getByLabel('Naam', { exact: true }).fill('Reloaded edit');
|
||||
await save(other);
|
||||
await expect(other.getByRole('heading', { name: 'Reloaded edit', exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface School {
|
||||
id: number;
|
||||
revision: number;
|
||||
name: string;
|
||||
dancerIds: number[];
|
||||
danceGroupIds: number[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DestroyRef, inject, Injectable, signal } from '@angular/core';
|
||||
|
||||
export type SchedulingState =
|
||||
| 'RUNNING'
|
||||
| 'INTERRUPTED'
|
||||
| 'OPTIMAL'
|
||||
| 'VALID'
|
||||
| 'STALE'
|
||||
|
||||
@@ -18,13 +18,17 @@ describe('SchoolApi contract', () => {
|
||||
it('loads all resources from the same school', () => {
|
||||
const received = vi.fn();
|
||||
api.schoolData(7).subscribe(received);
|
||||
const school = { id: 7, name: 'Studio', dancerIds: [], danceGroupIds: [], showIds: [] };
|
||||
http.expectOne('/api/dance-schools/7').flush(school);
|
||||
http.expectOne('/api/dance-schools/7/dancers').flush([]);
|
||||
http.expectOne('/api/dance-schools/7/dance-groups').flush([]);
|
||||
expect(received).not.toHaveBeenCalled();
|
||||
http.expectOne('/api/dance-schools/7/shows').flush([]);
|
||||
http.expectOne('/api/dance-schools/7/coaches').flush([]);
|
||||
const school = {
|
||||
id: 7,
|
||||
name: 'Studio',
|
||||
revision: 0,
|
||||
dancerIds: [],
|
||||
danceGroupIds: [],
|
||||
showIds: [],
|
||||
};
|
||||
http
|
||||
.expectOne('/api/dance-schools/7/data')
|
||||
.flush({ school, dancers: [], groups: [], shows: [], coaches: [] });
|
||||
expect(received).toHaveBeenCalledWith({
|
||||
school,
|
||||
dancers: [],
|
||||
@@ -34,12 +38,32 @@ describe('SchoolApi contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the loaded school revision without retrying a stale mutation', () => {
|
||||
api.schoolData(7).subscribe();
|
||||
http
|
||||
.expectOne('/api/dance-schools/7/data')
|
||||
.flush({ school: { id: 7, revision: 4 }, dancers: [], groups: [], shows: [], coaches: [] });
|
||||
const failure = vi.fn();
|
||||
api.saveDancer(7, { name: 'Sam', birthDate: '2010-05-12' }).subscribe({ error: failure });
|
||||
const request = http.expectOne('/api/dance-schools/7/dancers');
|
||||
expect(request.request.headers.get('X-School-Revision')).toBe('4');
|
||||
request.flush({ message: 'Herlaad de gegevens' }, { status: 409, statusText: 'Conflict' });
|
||||
expect(failure).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('updates an existing entity even when its id is zero', () => {
|
||||
api.saveSchool({ name: 'Renamed' }, 0).subscribe();
|
||||
const request = http.expectOne('/api/dance-schools/0');
|
||||
expect(request.request.method).toBe('PUT');
|
||||
expect(request.request.body).toEqual({ name: 'Renamed' });
|
||||
request.flush({ id: 0, name: 'Renamed', dancerIds: [], danceGroupIds: [], showIds: [] });
|
||||
request.flush({
|
||||
id: 0,
|
||||
name: 'Renamed',
|
||||
revision: 0,
|
||||
dancerIds: [],
|
||||
danceGroupIds: [],
|
||||
showIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps group details separate from memberships', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SchedulingRun } from './scheduling-status';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { forkJoin, Observable } from 'rxjs';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import {
|
||||
Coach,
|
||||
DanceGroup,
|
||||
@@ -20,64 +20,114 @@ import {
|
||||
export class SchoolApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = '/api/dance-schools';
|
||||
private readonly revisions = new Map<number, number>();
|
||||
|
||||
schools(): Observable<School[]> {
|
||||
return this.http.get<School[]>(this.base);
|
||||
return this.http.get<School[]>(this.base).pipe(
|
||||
tap((schools) => {
|
||||
for (const school of schools) this.revisions.set(school.id, school.revision);
|
||||
}),
|
||||
);
|
||||
}
|
||||
schoolData(schoolId: number): Observable<SchoolData> {
|
||||
const base = `${this.base}/${schoolId}`;
|
||||
return forkJoin({
|
||||
school: this.http.get<School>(base),
|
||||
dancers: this.http.get<Dancer[]>(`${base}/dancers`),
|
||||
groups: this.http.get<DanceGroup[]>(`${base}/dance-groups`),
|
||||
shows: this.http.get<Show[]>(`${base}/shows`),
|
||||
coaches: this.http.get<Coach[]>(`${base}/coaches`),
|
||||
});
|
||||
return this.http
|
||||
.get<SchoolData>(`${this.base}/${schoolId}/data`)
|
||||
.pipe(tap((data) => this.revisions.set(schoolId, data.school.revision)));
|
||||
}
|
||||
saveSchool(body: SchoolRequest, id?: number): Observable<School> {
|
||||
private options(schoolId: number, expected?: number): { headers: Record<string, string> } {
|
||||
const revision = expected ?? this.revisions.get(schoolId);
|
||||
return { headers: revision === undefined ? {} : { 'X-School-Revision': String(revision) } };
|
||||
}
|
||||
saveSchool(body: SchoolRequest, id?: number, revision?: number): Observable<School> {
|
||||
return id === undefined
|
||||
? this.http.post<School>(this.base, body)
|
||||
: this.http.put<School>(`${this.base}/${id}`, body);
|
||||
: this.http.put<School>(`${this.base}/${id}`, body, this.options(id, revision));
|
||||
}
|
||||
saveDancer(schoolId: number, body: DancerRequest, id?: number): Observable<Dancer> {
|
||||
return this.save(`${this.base}/${schoolId}/dancers`, body, id);
|
||||
saveDancer(
|
||||
schoolId: number,
|
||||
body: DancerRequest,
|
||||
id?: number,
|
||||
revision?: number,
|
||||
): Observable<Dancer> {
|
||||
return this.save(`${this.base}/${schoolId}/dancers`, body, schoolId, id, revision);
|
||||
}
|
||||
saveGroup(schoolId: number, body: GroupRequest, id?: number): Observable<DanceGroup> {
|
||||
return this.save(`${this.base}/${schoolId}/dance-groups`, body, id);
|
||||
saveGroup(
|
||||
schoolId: number,
|
||||
body: GroupRequest,
|
||||
id?: number,
|
||||
revision?: number,
|
||||
): Observable<DanceGroup> {
|
||||
return this.save(`${this.base}/${schoolId}/dance-groups`, body, schoolId, id, revision);
|
||||
}
|
||||
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
|
||||
return this.save(`${this.base}/${schoolId}/shows`, body, id);
|
||||
saveShow(schoolId: number, body: ShowRequest, id?: number, revision?: number): Observable<Show> {
|
||||
return this.save(`${this.base}/${schoolId}/shows`, body, schoolId, id, revision);
|
||||
}
|
||||
schedule(schoolId: number, showId: number): Observable<SchedulingRun> {
|
||||
return this.http.post<SchedulingRun>(`${this.base}/${schoolId}/shows/${showId}/schedule`, null);
|
||||
return this.http.post<SchedulingRun>(
|
||||
`${this.base}/${schoolId}/shows/${showId}/schedule`,
|
||||
null,
|
||||
this.options(schoolId),
|
||||
);
|
||||
}
|
||||
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
|
||||
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {
|
||||
assignments,
|
||||
});
|
||||
saveAssignments(
|
||||
schoolId: number,
|
||||
showId: number,
|
||||
assignments: number[][],
|
||||
revision?: number,
|
||||
): Observable<Show> {
|
||||
return this.http.put<Show>(
|
||||
`${this.base}/${schoolId}/shows/${showId}/assignments`,
|
||||
{
|
||||
assignments,
|
||||
},
|
||||
this.options(schoolId, revision),
|
||||
);
|
||||
}
|
||||
finalize(schoolId: number, showId: number): Observable<Show> {
|
||||
return this.http.post<Show>(`${this.base}/${schoolId}/shows/${showId}/finalize`, null);
|
||||
finalize(schoolId: number, showId: number, revision?: number): Observable<Show> {
|
||||
return this.http.post<Show>(
|
||||
`${this.base}/${schoolId}/shows/${showId}/finalize`,
|
||||
null,
|
||||
this.options(schoolId, revision),
|
||||
);
|
||||
}
|
||||
linkCoach(schoolId: number, dancerId: number, coachId?: number): Observable<Coach> {
|
||||
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, coachId);
|
||||
return this.save<Coach>(`${this.base}/${schoolId}/coaches`, { dancerId }, schoolId, coachId);
|
||||
}
|
||||
unlinkCoach(schoolId: number, coachId: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.base}/${schoolId}/coaches/${coachId}/dancer`);
|
||||
return this.http.delete<void>(
|
||||
`${this.base}/${schoolId}/coaches/${coachId}/dancer`,
|
||||
this.options(schoolId),
|
||||
);
|
||||
}
|
||||
delete(kind: ResourceKind, schoolId: number, id: number): Observable<void> {
|
||||
delete(kind: ResourceKind, schoolId: number, id: number, revision?: number): Observable<void> {
|
||||
const collection = { school: '', dancer: '/dancers', group: '/dance-groups', show: '/shows' }[
|
||||
kind
|
||||
];
|
||||
const url =
|
||||
kind === 'school' ? `${this.base}/${id}` : `${this.base}/${schoolId}${collection}/${id}`;
|
||||
return this.http.delete<void>(url);
|
||||
return this.http.delete<void>(url, this.options(schoolId, revision));
|
||||
}
|
||||
membership(schoolId: number, groupId: number, dancerId: number, add: boolean): Observable<void> {
|
||||
membership(
|
||||
schoolId: number,
|
||||
groupId: number,
|
||||
dancerId: number,
|
||||
add: boolean,
|
||||
revision?: number,
|
||||
): Observable<void> {
|
||||
const url = `${this.base}/${schoolId}/dance-groups/${groupId}/dancers/${dancerId}`;
|
||||
return add ? this.http.put<void>(url, null) : this.http.delete<void>(url);
|
||||
return add
|
||||
? this.http.put<void>(url, null, this.options(schoolId, revision))
|
||||
: this.http.delete<void>(url, this.options(schoolId, revision));
|
||||
}
|
||||
private save<T>(url: string, body: unknown, id?: number): Observable<T> {
|
||||
return id === undefined ? this.http.post<T>(url, body) : this.http.put<T>(`${url}/${id}`, body);
|
||||
private save<T>(
|
||||
url: string,
|
||||
body: unknown,
|
||||
schoolId: number,
|
||||
id?: number,
|
||||
revision?: number,
|
||||
): Observable<T> {
|
||||
return id === undefined
|
||||
? this.http.post<T>(url, body, this.options(schoolId, revision))
|
||||
: this.http.put<T>(`${url}/${id}`, body, this.options(schoolId, revision));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
<app-entity-editor
|
||||
[target]="target"
|
||||
[schoolId]="context.school.id"
|
||||
[revision]="context.school.revision"
|
||||
[shows]="context.shows"
|
||||
(closed)="editor.set(null)"
|
||||
(saved)="saved()"
|
||||
@@ -487,6 +488,7 @@
|
||||
[show]="show"
|
||||
[groups]="context.groups"
|
||||
[schoolId]="context.school.id"
|
||||
[revision]="context.school.revision"
|
||||
(closed)="scheduleEditing.set(null)"
|
||||
(saved)="reload()"
|
||||
/>
|
||||
@@ -495,6 +497,7 @@
|
||||
<app-group-members
|
||||
[group]="group"
|
||||
[schoolId]="context.school.id"
|
||||
[revision]="context.school.revision"
|
||||
[dancers]="context.dancers"
|
||||
(closed)="members.set(null)"
|
||||
(changed)="membershipChanged($event)"
|
||||
|
||||
@@ -219,7 +219,10 @@ export class SchoolPage implements OnInit {
|
||||
.join(', ') || 'Nog geen groep'
|
||||
);
|
||||
}
|
||||
private deletionRevision?: number;
|
||||
|
||||
askDelete(target: DeleteTarget): void {
|
||||
this.deletionRevision = this.data()?.school.revision;
|
||||
this.deleteError.set('');
|
||||
if (target.kind === 'group') {
|
||||
const names =
|
||||
@@ -255,7 +258,9 @@ export class SchoolPage implements OnInit {
|
||||
this.deleteBusy.set(true);
|
||||
this.deleteError.set('');
|
||||
try {
|
||||
await firstValueFrom(this.api.delete(target.kind, schoolId, target.id));
|
||||
await firstValueFrom(
|
||||
this.api.delete(target.kind, schoolId, target.id, this.deletionRevision),
|
||||
);
|
||||
this.deleting.set(null);
|
||||
if (target.kind === 'school') await this.router.navigate(['/schools']);
|
||||
else {
|
||||
|
||||
@@ -50,11 +50,33 @@ describe('EntityEditor', () => {
|
||||
const saved = vi.fn();
|
||||
component.saved.subscribe(saved);
|
||||
await component.save();
|
||||
expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined);
|
||||
expect(api.saveSchool).toHaveBeenCalledWith({ name: 'Studio' }, undefined, undefined);
|
||||
expect(saved).toHaveBeenCalledOnce();
|
||||
expect(component.busy()).toBe(false);
|
||||
});
|
||||
|
||||
it('retains the revision from opening the editor across background refreshes', async () => {
|
||||
api.saveDancer.mockReturnValue(of({ id: 3 }));
|
||||
const fixture = TestBed.createComponent(EntityEditor);
|
||||
fixture.componentRef.setInput('target', {
|
||||
kind: 'dancer',
|
||||
value: { id: 3, name: 'Sam', birthDate: '2010-01-01' },
|
||||
});
|
||||
fixture.componentRef.setInput('schoolId', 7);
|
||||
fixture.componentRef.setInput('revision', 4);
|
||||
const component = fixture.componentInstance;
|
||||
component.ngOnInit();
|
||||
fixture.componentRef.setInput('revision', 5);
|
||||
component.name = 'Sam edited';
|
||||
await component.save();
|
||||
expect(api.saveDancer).toHaveBeenCalledWith(
|
||||
7,
|
||||
{ name: 'Sam edited', birthDate: '2010-01-01' },
|
||||
3,
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the editor open and preserves input when saving fails', async () => {
|
||||
api.saveSchool.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
|
||||
const component = editor({ kind: 'school' });
|
||||
@@ -116,6 +138,7 @@ describe('EntityEditor', () => {
|
||||
7,
|
||||
expect.objectContaining({ preferredDayParts: 1 }),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -174,6 +174,8 @@ import { DialogFrame } from './dialog-frame';
|
||||
})
|
||||
export class EntityEditor implements OnInit {
|
||||
readonly target = input.required<EditorTarget>();
|
||||
readonly revision = input<number>();
|
||||
private originalRevision?: number;
|
||||
readonly schoolId = input<number>();
|
||||
readonly shows = input<Show[]>([]);
|
||||
readonly closed = output<void>();
|
||||
@@ -207,6 +209,7 @@ export class EntityEditor implements OnInit {
|
||||
return `${labels[this.target().kind]} ${this.target().value ? 'bewerken' : 'toevoegen'}`;
|
||||
}
|
||||
ngOnInit(): void {
|
||||
this.originalRevision = this.revision();
|
||||
const target = this.target();
|
||||
if (!target.value) return;
|
||||
if (target.kind === 'group') {
|
||||
@@ -298,13 +301,14 @@ export class EntityEditor implements OnInit {
|
||||
let request: Observable<unknown>;
|
||||
switch (target.kind) {
|
||||
case 'school':
|
||||
request = this.api.saveSchool({ name: this.name.trim() }, id);
|
||||
request = this.api.saveSchool({ name: this.name.trim() }, id, this.originalRevision);
|
||||
break;
|
||||
case 'dancer':
|
||||
request = this.api.saveDancer(
|
||||
schoolId!,
|
||||
{ name: this.name.trim(), birthDate: this.date },
|
||||
id,
|
||||
this.originalRevision,
|
||||
);
|
||||
break;
|
||||
case 'group':
|
||||
@@ -318,6 +322,7 @@ export class EntityEditor implements OnInit {
|
||||
styles: this.selectedStyles,
|
||||
},
|
||||
id,
|
||||
this.originalRevision,
|
||||
);
|
||||
break;
|
||||
case 'show':
|
||||
@@ -329,6 +334,7 @@ export class EntityEditor implements OnInit {
|
||||
preferredDayParts: this.preferredDayParts,
|
||||
},
|
||||
id,
|
||||
this.originalRevision,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ import { DialogFrame } from './dialog-frame';
|
||||
`,
|
||||
})
|
||||
export class GroupMembers implements OnInit {
|
||||
readonly revision = input<number>();
|
||||
private originalRevision?: number;
|
||||
readonly schoolId = input.required<number>();
|
||||
readonly group = input.required<DanceGroup>();
|
||||
readonly dancers = input.required<Dancer[]>();
|
||||
@@ -70,6 +72,7 @@ export class GroupMembers implements OnInit {
|
||||
);
|
||||
private readonly api = inject(SchoolApi);
|
||||
ngOnInit(): void {
|
||||
this.originalRevision = this.revision();
|
||||
this.memberIds.set([...this.group().dancerIds]);
|
||||
}
|
||||
async toggle(dancerId: number, event: Event): Promise<void> {
|
||||
@@ -82,7 +85,10 @@ export class GroupMembers implements OnInit {
|
||||
this.busy.set(true);
|
||||
this.error.set('');
|
||||
try {
|
||||
await firstValueFrom(this.api.membership(this.schoolId(), this.group().id, dancerId, add));
|
||||
await firstValueFrom(
|
||||
this.api.membership(this.schoolId(), this.group().id, dancerId, add, this.originalRevision),
|
||||
);
|
||||
if (this.originalRevision !== undefined) this.originalRevision++;
|
||||
this.memberIds.update((ids) =>
|
||||
add ? [...ids, dancerId] : ids.filter((id) => id !== dancerId),
|
||||
);
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('ScheduleEditor', () => {
|
||||
const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] };
|
||||
api.saveAssignments.mockReturnValue(of(draft));
|
||||
await component.save();
|
||||
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]);
|
||||
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]], undefined);
|
||||
expect(component.current()).toEqual(draft);
|
||||
expect(component.dirty()).toBe(false);
|
||||
expect(api.finalize).not.toHaveBeenCalled();
|
||||
|
||||
@@ -113,6 +113,8 @@ import { DialogFrame } from './dialog-frame';
|
||||
export class ScheduleEditor implements OnInit {
|
||||
readonly show = input.required<Show>();
|
||||
readonly groups = input.required<DanceGroup[]>();
|
||||
readonly revision = input<number>();
|
||||
private originalRevision?: number;
|
||||
readonly schoolId = input.required<number>();
|
||||
readonly closed = output<void>();
|
||||
readonly saved = output<void>();
|
||||
@@ -124,6 +126,7 @@ export class ScheduleEditor implements OnInit {
|
||||
assignments: number[][] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.originalRevision = this.revision();
|
||||
this.current.set(this.show());
|
||||
this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]);
|
||||
}
|
||||
@@ -162,9 +165,15 @@ export class ScheduleEditor implements OnInit {
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
finalize
|
||||
? this.api.finalize(this.schoolId(), this.show().id)
|
||||
: this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments),
|
||||
? this.api.finalize(this.schoolId(), this.show().id, this.originalRevision)
|
||||
: this.api.saveAssignments(
|
||||
this.schoolId(),
|
||||
this.show().id,
|
||||
this.assignments,
|
||||
this.originalRevision,
|
||||
),
|
||||
);
|
||||
if (this.originalRevision !== undefined) this.originalRevision++;
|
||||
this.current.set(result);
|
||||
this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]);
|
||||
this.dirty.set(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ interface Preview {
|
||||
shows: number;
|
||||
existingId: number | null;
|
||||
existingName: string | null;
|
||||
existingRevision: number | null;
|
||||
}
|
||||
@Component({
|
||||
selector: 'app-school-transfer',
|
||||
@@ -117,7 +118,12 @@ export class SchoolTransfer {
|
||||
const query = p.existingId === null ? '' : `?replaceId=${p.existingId}`;
|
||||
await firstValueFrom(
|
||||
this.http.post(`${this.base}/import${query}`, this.json, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(p.existingRevision === null
|
||||
? {}
|
||||
: { 'X-School-Revision': String(p.existingRevision) }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.cancel();
|
||||
|
||||
Reference in New Issue
Block a user