Skip to content

Commit 5496b7a

Browse files
renemadsenclaude
andcommitted
feat(calendar): reconstruct CalendarRepeatMeta from backend-loaded task
Layer 3 (frontend) of the calendar custom-repeat reconstruction feature. With Layers 1 (base v10.0.30 with RepeatWeekdaysCsv column) and 2 (plugin DTO + iterator + integration tests) shipped, the frontend can now hydrate the modal's customRepeatMeta from a backend load — so re-editing a custom-repeat task lands on the readable 'customCurrent' summary instead of auto-popping the modal with defaults. Changes: - CalendarTaskModel + CalendarTaskCreateModel: add the new repeat surface fields (repeatType, repeatEvery, repeatEndMode, etc., repeatWeekdaysCsv). - CalendarRepeatService.reconstructMetaFromTask: switch on task.repeatRule, then on task.repeatType for 'custom'. Defensive null guards on dayOfWeek/dayOfMonth, getUTCMonth() for yearly (timezone-safe), endMode clamp, repeatEvery>0 guard, csvDays range-validated 0..6, sorted/deduped via filter+map. - Critical: 'weeklyOne' and 'weeklyAll' branches consult repeatWeekdaysCsv first and promote to weeklyMulti/everyNWeekMulti when CSV has multiple days. Without this, a multi-day weekly rule saved at step=1 would silently degrade to single-day on edit (mapRepeatType(2, 1) returns 'weeklyOne' regardless of CSV). - task-create-edit-modal.ngOnInit: when reconstruction succeeds, set customRepeatMeta + rebuild repeatOptions + setValue('customCurrent', {emitEvent: false}). Else falls back to the original setValue path. Same hydration applied for copy-mode (sourceTask). - task-create-edit-modal.onSave: payload.repeatWeekdaysCsv populated from customRepeatMeta.weekdays.join(',') when isCustomRule, else null — so switching off a custom rule clears the stale CSV. Tests: - 28 new Karma unit tests covering round-trip per kind, per-built-in rule, edge cases (6 null returns), legacy fallback, weekdays mapping, end-mode handling. Plus 4 explicit promotion-path tests added after code-review caught the weeklyOne+CSV degradation. - Playwright I1 e2e: create multi-day recurring event (Mon+Wed+Fri, step=2, after 6), reload, edit, assert dropdown shows the formatted summary and the custom modal hydrates correctly. Out of scope: backfilling existing rows; modal UI for selecting a yearly RepeatMonth (column was dropped from v1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 805dfe5 commit 5496b7a

6 files changed

Lines changed: 785 additions & 4 deletions

File tree

eform-client/playwright/e2e/plugins/backend-configuration-pn/r/calendar-ui-enhancements.spec.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,4 +640,192 @@ test.describe.serial('Calendar UI enhancements', () => {
640640
expect(after).toBe(formatLongDate(addDays(mondayOfThisWeekLocal(), 7)));
641641
});
642642
});
643+
644+
// =======================================================================
645+
// I. Edit-mode reconstructs custom multi-day weekly rules. Regression
646+
// coverage for Layer 3 of the calendar custom-repeat reconstruction
647+
// feature: Layer 1 added RepeatWeekdaysCsv to AreaRulePlanning;
648+
// Layer 2 surfaced it on the response DTO + accepted it on
649+
// create/update; Layer 3 (this test) verifies the modal reconstructs
650+
// a CalendarRepeatMeta from the persisted fields and lands on the
651+
// synthesized 'customCurrent' option with the readable summary.
652+
// =======================================================================
653+
test.describe('Edit-mode meta reconstruction (custom multi-day weekly)', () => {
654+
test('I1: edit-mode reconstructs custom multi-day weekly', async ({ page }) => {
655+
const calendarPage = new CalendarUiEnhancementsPage(page);
656+
const eventTitle = `I1-${generateRandmString(5)}`;
657+
658+
// ----- Step 1: open create-modal at Monday slot -----------------
659+
// openCreateModalAt9AM advances one week then clicks Monday@9. The
660+
// create-modal's Repeat dropdown defaults to 'none' which is what
661+
// we want to override.
662+
await calendarPage.openCreateModalAt9AM();
663+
await page.locator('#calendarEventTitle').fill(eventTitle);
664+
// Defaults from the modal: first eForm and a board are auto-picked;
665+
// we don't set planningTag/assignee here because the suite's seeded
666+
// worker isn't strictly required to validate reconstruction.
667+
668+
// ----- Step 2: open repeat dropdown → Tilpasset… ----------------
669+
// The repeat select is [searchable]="false", so click .ng-select-container.
670+
const repeatRow = page
671+
.locator('.gcal-row')
672+
.filter({ has: page.locator('mat-icon.gcal-icon:has-text("sync")') });
673+
await repeatRow.locator('.ng-select-container').first().click();
674+
await page.locator('.ng-dropdown-panel').waitFor({ state: 'visible', timeout: 5000 });
675+
// Custom is always the LAST repeat option per
676+
// calendar-repeat.service.buildRepeatSelectOptions ordering.
677+
await page.locator('.ng-dropdown-panel .ng-option').last().click();
678+
679+
// ----- Step 3: configure custom rule -----------------------------
680+
// Custom-repeat dialog opens. Set step=2, unit defaults to 'week'.
681+
await page
682+
.locator('.custom-repeat-dialog')
683+
.waitFor({ state: 'visible', timeout: 10000 });
684+
const stepInput = page.locator('.custom-repeat-dialog .step-input input');
685+
await stepInput.fill('2');
686+
687+
// Pick Mon+Wed+Fri. Monday is auto-active when opening fresh against
688+
// a Monday slot date — toggle Wednesday and Friday on, leave Monday on,
689+
// turn the rest off (Tuesday/Thursday should already be off, but be
690+
// defensive in case the modal reseeds them in the future).
691+
const dayCircles = page.locator('.custom-repeat-dialog .day-circle');
692+
// Order in the modal is Mon, Tue, Wed, Thu, Fri, Sat, Sun.
693+
// Ensure each circle's active state matches the desired set.
694+
const expectedActive = [true, false, true, false, true, false, false];
695+
for (let i = 0; i < 7; i++) {
696+
const circle = dayCircles.nth(i);
697+
const cls = (await circle.getAttribute('class')) ?? '';
698+
const isActive = cls.split(/\s+/).includes('active');
699+
if (isActive !== expectedActive[i]) {
700+
await circle.click();
701+
}
702+
}
703+
704+
// End mode "Efter" + afterCount = 6.
705+
await page
706+
.locator('.custom-repeat-dialog .end-option')
707+
.filter({ has: page.locator('mat-radio-button[value="after"]') })
708+
.locator('mat-radio-button')
709+
.click();
710+
await page.waitForTimeout(200);
711+
await page.locator('.custom-repeat-dialog .count-input input').fill('6');
712+
713+
// Færdig (Done) — closes the modal, syncs meta back to parent.
714+
await page.locator('.custom-repeat-dialog .btn-done-gcal').click();
715+
await page
716+
.locator('.custom-repeat-dialog')
717+
.waitFor({ state: 'detached', timeout: 5000 });
718+
719+
// ----- Step 4: verify dropdown collapsed value -------------------
720+
// The `customCurrent` option should now render the formatted Danish
721+
// label "Hver 2. uge: mandag, onsdag og fredag".
722+
const dropdownValue = page
723+
.locator('.gcal-row')
724+
.filter({ has: page.locator('mat-icon.gcal-icon:has-text("sync")') })
725+
.locator('.ng-value-label')
726+
.first();
727+
await expect(dropdownValue).toHaveText('Hver 2. uge: mandag, onsdag og fredag');
728+
729+
// ----- Step 5: save -----------------------------------------------
730+
const createWait = page.waitForResponse(
731+
r => r.url().includes('/api/backend-configuration-pn/calendar/tasks')
732+
&& !r.url().includes('/tasks/week')
733+
&& !r.url().includes('/tasks/move')
734+
&& !r.url().includes('/tasks/resize')
735+
&& r.request().method() === 'POST',
736+
{ timeout: 30000 }
737+
);
738+
await page.locator('#calendarEventSaveBtn').click();
739+
await createWait;
740+
await page.waitForTimeout(1500);
741+
742+
// ----- Step 6: full page reload ----------------------------------
743+
// Reload the calendar route directly so we exercise the GET-back path
744+
// (week tasks → mapper → DTO → frontend reconstruction).
745+
await calendarPage.goToCalendar();
746+
await calendarPage.ensureSidebarOpen();
747+
const folderResponsePromise = page.waitForResponse(
748+
r => r.url().includes('/api/backend-configuration-pn/properties/get-folder-dtos'),
749+
{ timeout: 60000 }
750+
);
751+
await calendarPage.selectProperty(property.name);
752+
await folderResponsePromise.catch(() => undefined);
753+
await page.waitForTimeout(1500);
754+
// The event was created on next-week's Monday; advance the view so
755+
// the seeded event is visible.
756+
await calendarPage.navigateToNextWeek();
757+
758+
// ----- Step 7: open the seeded event in edit mode ----------------
759+
const block = page.locator('.task-block').filter({ hasText: eventTitle }).first();
760+
await block.waitFor({ state: 'visible', timeout: 10000 });
761+
await block.click();
762+
await calendarPage.getPreviewEditButton().waitFor({ state: 'visible', timeout: 10000 });
763+
await calendarPage.getPreviewEditButton().click();
764+
await page.locator('#calendarEventTitle').waitFor({ state: 'visible', timeout: 10000 });
765+
766+
// ----- Step 8: assert reconstructed dropdown summary --------------
767+
const reopenedDropdownValue = page
768+
.locator('.gcal-row')
769+
.filter({ has: page.locator('mat-icon.gcal-icon:has-text("sync")') })
770+
.locator('.ng-value-label')
771+
.first();
772+
await expect(reopenedDropdownValue)
773+
.toHaveText('Hver 2. uge: mandag, onsdag og fredag');
774+
775+
// ----- Step 9: open Tilpasset… and verify modal pre-population ---
776+
const repeatRow2 = page
777+
.locator('.gcal-row')
778+
.filter({ has: page.locator('mat-icon.gcal-icon:has-text("sync")') });
779+
await repeatRow2.locator('.ng-select-container').first().click();
780+
await page.locator('.ng-dropdown-panel').waitFor({ state: 'visible', timeout: 5000 });
781+
// Pick the actual 'custom' option (Tilpasset…) — last in the list.
782+
// Its meta is undefined in the option list; the click triggers
783+
// valueChanges → onRepeatChange → opens the dialog hydrated from
784+
// customRepeatMeta (which was set by reconstructMetaFromTask in ngOnInit).
785+
await page.locator('.ng-dropdown-panel .ng-option').last().click();
786+
787+
await page
788+
.locator('.custom-repeat-dialog')
789+
.waitFor({ state: 'visible', timeout: 10000 });
790+
791+
// Frequency should be 2.
792+
const stepInput2 = page.locator('.custom-repeat-dialog .step-input input');
793+
expect(await stepInput2.inputValue()).toBe('2');
794+
795+
// Unit shows "uge" (Danish for week).
796+
const unitLabel = page
797+
.locator('.custom-repeat-dialog .unit-select .ng-value-label')
798+
.first();
799+
await expect(unitLabel).toHaveText('uge');
800+
801+
// Mon (idx 0), Wed (idx 2), Fri (idx 4) active; others inactive.
802+
const dayCircles2 = page.locator('.custom-repeat-dialog .day-circle');
803+
const expectedActive2 = [true, false, true, false, true, false, false];
804+
for (let i = 0; i < 7; i++) {
805+
const cls = (await dayCircles2.nth(i).getAttribute('class')) ?? '';
806+
const isActive = cls.split(/\s+/).includes('active');
807+
expect(isActive, `weekday circle index=${i} expected active=${expectedActive2[i]}, got=${isActive}`)
808+
.toBe(expectedActive2[i]);
809+
}
810+
811+
// End mode "Efter" radio is checked (mat-radio uses .mat-mdc-radio-checked
812+
// on the matching <mat-radio-button>).
813+
const afterOption = page
814+
.locator('.custom-repeat-dialog .end-option')
815+
.filter({ has: page.locator('mat-radio-button[value="after"]') });
816+
await expect(afterOption.locator('mat-radio-button.mat-mdc-radio-checked'))
817+
.toHaveCount(1);
818+
819+
// afterCount field shows 6.
820+
const countInput = page.locator('.custom-repeat-dialog .count-input input');
821+
expect(await countInput.inputValue()).toBe('6');
822+
823+
// Cancel out so we don't disturb the row on close.
824+
await page.locator('.custom-repeat-dialog .btn-cancel-gcal').click();
825+
await page
826+
.locator('.custom-repeat-dialog')
827+
.waitFor({ state: 'detached', timeout: 5000 });
828+
await calendarPage.closeEventModal();
829+
});
830+
});
643831
});

eform-client/src/app/plugins/modules/backend-configuration-pn/models/calendar/calendar-task-request.model.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export interface CalendarTaskCreateModel {
1414
taskDate: string;
1515
driveLink?: string;
1616
propertyId: number;
17+
// CSV of JS getDay() weekday indices ("1,3,5") — only populated for
18+
// multi-day weekly custom rules. Cleared (sent as null) for any non-custom
19+
// rule so the backend column is wiped on rule change.
20+
repeatWeekdaysCsv?: string | null;
1721
}
1822

1923
export interface CalendarTaskUpdateModel extends CalendarTaskCreateModel {

eform-client/src/app/plugins/modules/backend-configuration-pn/models/calendar/calendar-task.model.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ export interface CalendarTaskModel {
3333
planningId?: number;
3434
isAllDay?: boolean;
3535
exceptionId?: number;
36+
37+
// Persisted custom-repeat fields surfaced from AreaRulePlanning so the
38+
// edit-modal can reconstruct a full CalendarRepeatMeta for an existing row.
39+
// All optional/nullable — older backends and rows without a custom rule
40+
// simply omit them.
41+
repeatType?: number | null;
42+
repeatEvery?: number | null;
43+
repeatEndMode?: number | null;
44+
repeatOccurrences?: number | null;
45+
repeatUntilDate?: string | null;
46+
dayOfWeek?: number | null;
47+
dayOfMonth?: number | null;
48+
repeatWeekdaysCsv?: string | null;
3649
}
3750

3851
export interface CalendarRepeatMeta {

eform-client/src/app/plugins/modules/backend-configuration-pn/modules/calendar/modals/task-create-edit-modal/task-create-edit-modal.component.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,21 @@ export class TaskCreateEditModalComponent implements OnInit {
176176
this.titleControl.setValue(task.title);
177177
this.startTimeControl.setValue(this.hourToTimeStr(task.startHour));
178178
this.endTimeControl.setValue(this.hourToTimeStr(task.startHour + task.duration));
179-
this.repeatControl.setValue(task.repeatRule ?? 'none');
179+
// Reconstruct a CalendarRepeatMeta from the persisted fields so a saved
180+
// custom rule (incl. multi-day weekly via repeatWeekdaysCsv) lands back
181+
// on the synthesized 'customCurrent' option with the readable summary,
182+
// and re-opening the custom modal pre-populates from the existing rule.
183+
// Reconstruction returns null for legacy rows we can't fully recover —
184+
// fall through to the unmodified `repeatRule` string in that case.
185+
const reconstructed = task.repeatRule && task.repeatRule !== 'none'
186+
? this.repeatService.reconstructMetaFromTask(task) : null;
187+
if (reconstructed) {
188+
this.customRepeatMeta = reconstructed;
189+
this.repeatOptions = this.repeatService.buildRepeatSelectOptions(baseDate, reconstructed);
190+
this.repeatControl.setValue('customCurrent', {emitEvent: false});
191+
} else {
192+
this.repeatControl.setValue(task.repeatRule ?? 'none');
193+
}
180194
this.assigneeControl.setValue(task.assigneeIds ?? []);
181195
this.tagsControl.setValue(task.tags ?? []);
182196
this.descriptionControl.setValue(task.descriptionHtml ?? '');
@@ -191,7 +205,18 @@ export class TaskCreateEditModalComponent implements OnInit {
191205
this.titleControl.setValue(`${copyPrefix} ${sourceTask.title}`);
192206
this.startTimeControl.setValue(this.hourToTimeStr(sourceTask.startHour));
193207
this.endTimeControl.setValue(this.hourToTimeStr(sourceTask.startHour + sourceTask.duration));
194-
this.repeatControl.setValue(sourceTask.repeatRule ?? 'none');
208+
// Same reconstruction logic as edit mode — copy carries the source's
209+
// custom-repeat rule forward so the copied event opens with the same
210+
// selected option as the original.
211+
const reconstructed = sourceTask.repeatRule && sourceTask.repeatRule !== 'none'
212+
? this.repeatService.reconstructMetaFromTask(sourceTask) : null;
213+
if (reconstructed) {
214+
this.customRepeatMeta = reconstructed;
215+
this.repeatOptions = this.repeatService.buildRepeatSelectOptions(baseDate, reconstructed);
216+
this.repeatControl.setValue('customCurrent', {emitEvent: false});
217+
} else {
218+
this.repeatControl.setValue(sourceTask.repeatRule ?? 'none');
219+
}
195220
this.assigneeControl.setValue(sourceTask.assigneeIds ?? []);
196221
this.tagsControl.setValue(sourceTask.tags ?? []);
197222
this.descriptionControl.setValue(sourceTask.descriptionHtml ?? '');
@@ -586,6 +611,13 @@ export class TaskCreateEditModalComponent implements OnInit {
586611
repeatEndMode,
587612
repeatOccurrences,
588613
repeatUntilDate,
614+
// CSV of JS getDay() weekday indices for multi-day weekly custom rules.
615+
// Sent as null for any non-custom rule (isCustomRule=false), which
616+
// unconditionally clears any stale CSV the row may carry from a prior
617+
// custom selection. See spec — Layer 3 / "explicit clearing rule".
618+
repeatWeekdaysCsv: (isCustomRule && this.customRepeatMeta?.weekdays?.length)
619+
? this.customRepeatMeta.weekdays.join(',')
620+
: null,
589621
driveLink: this.driveLinkControl.value ?? '',
590622
propertyId: this.propertyControl.value ?? this.data.propertyId,
591623
status: 1,

0 commit comments

Comments
 (0)