mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
fix(task-repeat): allow selecting day-of-month recurrence (#8896)
* fix(task-repeat): allow selecting day-of-month mode * fix(task-repeat): clarify monthly recurrence pattern * test(task-repeat): cover day-of-month persistence * docs(task-repeat): explain custom monthly patterns
This commit is contained in:
parent
762b3c5d8d
commit
c68929d1fb
7 changed files with 109 additions and 6 deletions
|
|
@ -27,6 +27,7 @@ You can also open the dialog from a repeating task instance or from a repeat pro
|
||||||
- Schedule type (only for intervals > 1): Fixed schedule (every X from start date) or After completion (X after I finish).
|
- Schedule type (only for intervals > 1): Fixed schedule (every X from start date) or After completion (X after I finish).
|
||||||
- Start date — when the pattern begins (required).
|
- Start date — when the pattern begins (required).
|
||||||
- For weekly cycle: check the weekdays (Monday–Sunday) on which the task should recur.
|
- For weekly cycle: check the weekdays (Monday–Sunday) on which the task should recur.
|
||||||
|
- For monthly cycle: choose a **Monthly pattern**. **Day of month** uses the day shown in **Start date**; first, second, third, fourth, and last patterns also require a **Weekday**.
|
||||||
5. Optionally set **Tags to add** (chip list).
|
5. Optionally set **Tags to add** (chip list).
|
||||||
6. Optionally set **Scheduled start time** (e.g. 15:00; leave blank for all-day). If you set a time, **Remind at** appears — choose when to be reminded (e.g. when it starts, 5 minutes before).
|
6. Optionally set **Scheduled start time** (e.g. 15:00; leave blank for all-day). If you set a time, **Remind at** appears — choose when to be reminded (e.g. when it starts, 5 minutes before).
|
||||||
7. Optionally set **Default Estimate** (duration). **Order** (number) controls creation order when several recurring tasks are created at the same time; see the field description in the dialog.
|
7. Optionally set **Default Estimate** (duration). **Order** (number) controls creation order when several recurring tasks are created at the same time; see the field description in the dialog.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@ import {
|
||||||
MatDialogRef,
|
MatDialogRef,
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { DateAdapter, MatNativeDateModule } from '@angular/material/core';
|
import { DateAdapter, MatNativeDateModule } from '@angular/material/core';
|
||||||
|
import { MatFormFieldHarness } from '@angular/material/form-field/testing';
|
||||||
|
import { MatSelectHarness } from '@angular/material/select/testing';
|
||||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
|
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { provideMockStore } from '@ngrx/store/testing';
|
import { provideMockStore } from '@ngrx/store/testing';
|
||||||
import { Observable, of, Subject } from 'rxjs';
|
import { Observable, of, Subject } from 'rxjs';
|
||||||
|
|
@ -74,6 +77,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
||||||
getRepeatCfgReturnValue?:
|
getRepeatCfgReturnValue?:
|
||||||
| Observable<TaskRepeatCfg | undefined>
|
| Observable<TaskRepeatCfg | undefined>
|
||||||
| Subject<TaskRepeatCfg>,
|
| Subject<TaskRepeatCfg>,
|
||||||
|
renderTemplate = false,
|
||||||
): Promise<ComponentFixture<DialogEditTaskRepeatCfgComponent>> => {
|
): Promise<ComponentFixture<DialogEditTaskRepeatCfgComponent>> => {
|
||||||
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
|
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
|
||||||
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
|
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
|
||||||
|
|
@ -117,7 +121,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
||||||
formatTime: () => '12:00 PM',
|
formatTime: () => '12:00 PM',
|
||||||
});
|
});
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
const testModule = TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
DialogEditTaskRepeatCfgComponent,
|
DialogEditTaskRepeatCfgComponent,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
|
|
@ -140,16 +144,20 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
||||||
{ provide: DateService, useValue: mockDateService },
|
{ provide: DateService, useValue: mockDateService },
|
||||||
{ provide: DateAdapter, useClass: CustomDateAdapter },
|
{ provide: DateAdapter, useClass: CustomDateAdapter },
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
.overrideComponent(DialogEditTaskRepeatCfgComponent, {
|
|
||||||
|
if (!renderTemplate) {
|
||||||
|
testModule.overrideComponent(DialogEditTaskRepeatCfgComponent, {
|
||||||
set: {
|
set: {
|
||||||
// Use a minimal template to avoid @ngx-formly/material select rendering,
|
// Use a minimal template to avoid @ngx-formly/material select rendering,
|
||||||
// which triggers a compareWith validation error with Angular Material 21+.
|
// which triggers a compareWith validation error with Angular Material 21+.
|
||||||
// These tests verify component signals/logic, not template rendering.
|
// These tests verify component signals/logic, not template rendering.
|
||||||
template: '<div></div>',
|
template: '<div></div>',
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
.compileComponents();
|
}
|
||||||
|
|
||||||
|
await testModule.compileComponents();
|
||||||
|
|
||||||
return TestBed.createComponent(DialogEditTaskRepeatCfgComponent);
|
return TestBed.createComponent(DialogEditTaskRepeatCfgComponent);
|
||||||
};
|
};
|
||||||
|
|
@ -158,6 +166,72 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps Day of month selected after switching from an Nth weekday (#8886)', async () => {
|
||||||
|
const monthlyNthWeekdayCfg: TaskRepeatCfg = {
|
||||||
|
...DEFAULT_TASK_REPEAT_CFG,
|
||||||
|
id: 'repeat-cfg-monthly-nth-weekday',
|
||||||
|
title: 'Monthly task',
|
||||||
|
quickSetting: 'CUSTOM',
|
||||||
|
repeatCycle: 'MONTHLY',
|
||||||
|
startDate: '2026-06-09',
|
||||||
|
monthlyWeekOfMonth: 2,
|
||||||
|
monthlyWeekday: 1,
|
||||||
|
};
|
||||||
|
const fixture = await setupTestBed(
|
||||||
|
{ repeatCfg: monthlyNthWeekdayCfg },
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
const loader = TestbedHarnessEnvironment.loader(fixture);
|
||||||
|
const selects = await loader.getAllHarnesses(MatSelectHarness);
|
||||||
|
const formFieldsBeforeSwitch = await loader.getAllHarnesses(MatFormFieldHarness);
|
||||||
|
const labelsBeforeSwitch = await Promise.all(
|
||||||
|
formFieldsBeforeSwitch.map((formField) => formField.getLabel()),
|
||||||
|
);
|
||||||
|
expect(labelsBeforeSwitch).toContain(T.F.TASK_REPEAT.F.WEEKDAY);
|
||||||
|
let monthlyPatternSelect: MatSelectHarness | undefined;
|
||||||
|
let dayOfMonthOptionText = '';
|
||||||
|
|
||||||
|
for (const select of selects) {
|
||||||
|
await select.open();
|
||||||
|
const [dayOfMonthOption] = await select.getOptions({
|
||||||
|
text: /MONTHLY_MODE_DAY_OF_MONTH/,
|
||||||
|
});
|
||||||
|
if (dayOfMonthOption) {
|
||||||
|
monthlyPatternSelect = select;
|
||||||
|
dayOfMonthOptionText = await dayOfMonthOption.getText();
|
||||||
|
await dayOfMonthOption.click();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await select.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(monthlyPatternSelect).toBeDefined();
|
||||||
|
expect(await monthlyPatternSelect!.getValueText()).toBe(dayOfMonthOptionText);
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.repeatCfg().monthlyWeekOfMonth).toBeNull();
|
||||||
|
const formFieldsAfterSwitch = await loader.getAllHarnesses(MatFormFieldHarness);
|
||||||
|
const labelsAfterSwitch = await Promise.all(
|
||||||
|
formFieldsAfterSwitch.map((formField) => formField.getLabel()),
|
||||||
|
);
|
||||||
|
expect(labelsAfterSwitch).not.toContain(T.F.TASK_REPEAT.F.WEEKDAY);
|
||||||
|
|
||||||
|
fixture.componentInstance.save();
|
||||||
|
|
||||||
|
const changes =
|
||||||
|
mockTaskRepeatCfgService.updateTaskRepeatCfg.calls.mostRecent().args[1];
|
||||||
|
expect(
|
||||||
|
Object.prototype.hasOwnProperty.call(changes, 'monthlyWeekOfMonth'),
|
||||||
|
).toBeTrue();
|
||||||
|
expect(changes.monthlyWeekOfMonth).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
describe('isLoading signal', () => {
|
describe('isLoading signal', () => {
|
||||||
it('should be false when repeatCfg is provided directly (sync path)', async () => {
|
it('should be false when repeatCfg is provided directly (sync path)', async () => {
|
||||||
const fixture = await setupTestBed({ repeatCfg: mockRepeatCfg });
|
const fixture = await setupTestBed({ repeatCfg: mockRepeatCfg });
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clo
|
||||||
import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds';
|
import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds';
|
||||||
import { isValidSplitTime } from '../../../util/is-valid-split-time';
|
import { isValidSplitTime } from '../../../util/is-valid-split-time';
|
||||||
import { DateService } from '../../../core/date/date.service';
|
import { DateService } from '../../../core/date/date.service';
|
||||||
|
import { MAT_SELECT_CONFIG } from '@angular/material/select';
|
||||||
|
|
||||||
// Fields whose change requires offering "Update all task instances?" — covers
|
// Fields whose change requires offering "Update all task instances?" — covers
|
||||||
// what propagates to existing tasks (vs. schedule fields, which only affect
|
// what propagates to existing tasks (vs. schedule fields, which only affect
|
||||||
|
|
@ -88,6 +89,12 @@ const WEEKDAY_KEYS: (keyof TaskRepeatCfgCopy)[] = [
|
||||||
templateUrl: './dialog-edit-task-repeat-cfg.component.html',
|
templateUrl: './dialog-edit-task-repeat-cfg.component.html',
|
||||||
styleUrls: ['./dialog-edit-task-repeat-cfg.component.scss'],
|
styleUrls: ['./dialog-edit-task-repeat-cfg.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: MAT_SELECT_CONFIG,
|
||||||
|
useValue: { canSelectNullableOptions: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
imports: [
|
imports: [
|
||||||
MatDialogTitle,
|
MatDialogTitle,
|
||||||
TranslatePipe,
|
TranslatePipe,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
TASK_REPEAT_CFG_ADVANCED_FORM_CFG,
|
TASK_REPEAT_CFG_ADVANCED_FORM_CFG,
|
||||||
TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG,
|
TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG,
|
||||||
} from './task-repeat-cfg-form.const';
|
} from './task-repeat-cfg-form.const';
|
||||||
|
import { T } from '../../../t.const';
|
||||||
|
|
||||||
describe('TaskRepeatCfgFormConfig', () => {
|
describe('TaskRepeatCfgFormConfig', () => {
|
||||||
it('should not contain startDate in essential form fields', () => {
|
it('should not contain startDate in essential form fields', () => {
|
||||||
|
|
@ -23,6 +24,22 @@ describe('TaskRepeatCfgFormConfig', () => {
|
||||||
expect(remindAtField).toBeUndefined();
|
expect(remindAtField).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('explains that Day of month uses the start date (#8886)', () => {
|
||||||
|
const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find(
|
||||||
|
(field) => field.fieldGroupClassName === 'repeat-config-container',
|
||||||
|
);
|
||||||
|
const monthlyAnchor = repeatContainer?.fieldGroup?.find(
|
||||||
|
(field) => field.fieldGroupClassName === 'monthly-anchor',
|
||||||
|
);
|
||||||
|
const monthlyPattern = monthlyAnchor?.fieldGroup?.find(
|
||||||
|
(field) => field.key === 'monthlyWeekOfMonth',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(monthlyPattern?.templateOptions?.description).toBe(
|
||||||
|
T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('weekdays group visibility (issue #8025)', () => {
|
describe('weekdays group visibility (issue #8025)', () => {
|
||||||
const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find(
|
const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find(
|
||||||
(field) => field.fieldGroupClassName === 'repeat-config-container',
|
(field) => field.fieldGroupClassName === 'repeat-config-container',
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
|
||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
templateOptions: {
|
templateOptions: {
|
||||||
label: T.F.TASK_REPEAT.F.WEEK_OF_MONTH,
|
label: T.F.TASK_REPEAT.F.WEEK_OF_MONTH,
|
||||||
|
description: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION,
|
||||||
options: [
|
options: [
|
||||||
{ value: null, label: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH },
|
{ value: null, label: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH },
|
||||||
{ value: 1, label: T.F.TASK_REPEAT.F.ORD_FIRST },
|
{ value: 1, label: T.F.TASK_REPEAT.F.ORD_FIRST },
|
||||||
|
|
|
||||||
|
|
@ -2054,6 +2054,8 @@ const T = {
|
||||||
INHERIT_SUBTASKS_DESCRIPTION: 'F.TASK_REPEAT.F.INHERIT_SUBTASKS_DESCRIPTION',
|
INHERIT_SUBTASKS_DESCRIPTION: 'F.TASK_REPEAT.F.INHERIT_SUBTASKS_DESCRIPTION',
|
||||||
MONDAY: 'F.TASK_REPEAT.F.MONDAY',
|
MONDAY: 'F.TASK_REPEAT.F.MONDAY',
|
||||||
MONTHLY_MODE_DAY_OF_MONTH: 'F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH',
|
MONTHLY_MODE_DAY_OF_MONTH: 'F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH',
|
||||||
|
MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION:
|
||||||
|
'F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION',
|
||||||
NOTES: 'F.TASK_REPEAT.F.NOTES',
|
NOTES: 'F.TASK_REPEAT.F.NOTES',
|
||||||
ORD_FIRST: 'F.TASK_REPEAT.F.ORD_FIRST',
|
ORD_FIRST: 'F.TASK_REPEAT.F.ORD_FIRST',
|
||||||
ORD_FIRST_NTH: 'F.TASK_REPEAT.F.ORD_FIRST_NTH',
|
ORD_FIRST_NTH: 'F.TASK_REPEAT.F.ORD_FIRST_NTH',
|
||||||
|
|
|
||||||
|
|
@ -1997,6 +1997,7 @@
|
||||||
"INHERIT_SUBTASKS_DESCRIPTION": "When enabled, subtasks from the most recent task instance will be recreated with the recurring task",
|
"INHERIT_SUBTASKS_DESCRIPTION": "When enabled, subtasks from the most recent task instance will be recreated with the recurring task",
|
||||||
"MONDAY": "Monday",
|
"MONDAY": "Monday",
|
||||||
"MONTHLY_MODE_DAY_OF_MONTH": "Day of month",
|
"MONTHLY_MODE_DAY_OF_MONTH": "Day of month",
|
||||||
|
"MONTHLY_MODE_DAY_OF_MONTH_DESCRIPTION": "\"Day of month\" uses the day shown in Start date.",
|
||||||
"NOTES": "Default notes",
|
"NOTES": "Default notes",
|
||||||
"ORD_FIRST": "first",
|
"ORD_FIRST": "first",
|
||||||
"ORD_FIRST_NTH": "first",
|
"ORD_FIRST_NTH": "first",
|
||||||
|
|
@ -2038,7 +2039,7 @@
|
||||||
"WAIT_FOR_COMPLETION": "Only create next task after completion",
|
"WAIT_FOR_COMPLETION": "Only create next task after completion",
|
||||||
"WAIT_FOR_COMPLETION_DESCRIPTION": "Prevents multiple pending recurring tasks from piling up. The next instance will only be created once the current task is completed",
|
"WAIT_FOR_COMPLETION_DESCRIPTION": "Prevents multiple pending recurring tasks from piling up. The next instance will only be created once the current task is completed",
|
||||||
"WEDNESDAY": "Wednesday",
|
"WEDNESDAY": "Wednesday",
|
||||||
"WEEK_OF_MONTH": "Week of month",
|
"WEEK_OF_MONTH": "Monthly pattern",
|
||||||
"WEEKDAY": "Weekday",
|
"WEEKDAY": "Weekday",
|
||||||
"WEEKDAY_REQUIRED": "Select at least one weekday"
|
"WEEKDAY_REQUIRED": "Select at least one weekday"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue