feat(issue): add default tags and note for issue provider imports (#6906)

* feat(issue): add default tags and note for issue provider imports

Allow users to configure default tags and a default note on issue
provider settings. Imported tasks automatically receive these defaults.

Tags use the existing ChipListInputComponent pattern from the recurring
task config dialog. The default note is a textarea in the advanced
config section.

Closes #6652

* fix(issue): address review feedback for default tags/note feature

- Remove unused `computed` import
- Use translation keys for default tags and note labels
- Change `defaultNote: undefined` to `null` for consistency
- Rename `getProjectOrTagId` to `getTaskDefaults`

* fix(issue): update defaultNote type to accept null

Align with defaultProjectId and pinnedSearch types which also
include null in their union types.

* fix(issue): clean up orphaned defaultTagIds on tag deletion and preserve provider notes

Remove deleted tag IDs from issue provider defaultTagIds in the
tag-shared meta-reducer, preventing stale references from being
applied to future imported tasks.

Only apply defaultNote when the provider didn't already set notes
(CalDAV, iCal, Nextcloud Deck return issue-specific notes that
should take priority over the generic default).

* fix(issue): add TODAY_TAG guard on defaultTagIds and remove stale comment

Filter TODAY_TAG.id from defaultTagIds before applying to imported
tasks, enforcing the architectural invariant that TODAY_TAG must
never appear in task.tagIds. The UI already excludes it from
suggestions, but this guards against corrupted data or sync payloads.

Also removes stale comment about tags being overwritten, since
getTaskDefaults() now handles tags directly.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
This commit is contained in:
John Costa 2026-03-23 06:50:05 -07:00 committed by GitHub
parent 01c6d82a7c
commit 0cacb600af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 189 additions and 19 deletions

View file

@ -93,5 +93,13 @@ export const ISSUE_PROVIDER_COMMON_FORM_FIELDS: LimitedFormlyFieldConfig<IssuePr
label: 'Poll imported for changes and notify',
},
},
{
key: 'defaultNote',
type: 'textarea',
props: {
label: T.F.ISSUE.DEFAULT_NOTE_LABEL,
rows: 3,
},
},
// ISSUE_PROVIDER_FF_LINE,
] as const;

View file

@ -136,6 +136,15 @@
[form]="form"
></formly-form>
<chip-list-input
(addItem)="addTag($event)"
(addNewItem)="addNewTag($event)"
(removeItem)="removeTag($event)"
[label]="T.F.ISSUE.DEFAULT_TAGS_LABEL | translate"
[model]="model.defaultTagIds || []"
[suggestions]="tagSuggestions()"
></chip-list-input>
@switch (issueProviderKey) {
<!-- -->
@case ('JIRA') {

View file

@ -1,4 +1,5 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import {
MAT_DIALOG_DATA,
MatDialog,
@ -59,6 +60,9 @@ import { TaskService } from '../../tasks/task.service';
import { firstValueFrom } from 'rxjs';
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../common-issue-form-stuff.const';
import { TagService } from '../../tag/tag.service';
import { ChipListInputComponent } from '../../../ui/chip-list-input/chip-list-input.component';
import { unique } from '../../../util/unique';
@Component({
selector: 'dialog-edit-issue-provider',
@ -81,6 +85,7 @@ import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../common-issue-form-stuff.co
MatDialogTitle,
TrelloAdditionalCfgComponent, // added for custom trello board loading support
NextcloudDeckAdditionalCfgComponent,
ChipListInputComponent,
],
templateUrl: './dialog-edit-issue-provider.component.html',
styleUrl: './dialog-edit-issue-provider.component.scss',
@ -155,6 +160,31 @@ export class DialogEditIssueProviderComponent {
private _issueService = inject(IssueService);
private _snackService = inject(SnackService);
private _taskService = inject(TaskService);
private _tagService = inject(TagService);
tagSuggestions = toSignal(this._tagService.tagsNoMyDayAndNoList$, { initialValue: [] });
addTag(id: string): void {
this.model = {
...this.model,
defaultTagIds: unique([...(this.model.defaultTagIds || []), id]),
};
}
addNewTag(title: string): void {
const id = this._tagService.addTag({ title });
this.model = {
...this.model,
defaultTagIds: unique([...(this.model.defaultTagIds || []), id]),
};
}
removeTag(id: string): void {
this.model = {
...this.model,
defaultTagIds: (this.model.defaultTagIds || []).filter((tagId) => tagId !== id),
};
}
constructor() {
this._initOAuthAndOptions().catch((err) => {

View file

@ -167,4 +167,6 @@ export const ISSUE_PROVIDER_DEFAULT_COMMON_CFG: Omit<
isIntegratedAddTaskBar: false,
defaultProjectId: null,
pinnedSearch: null,
defaultTagIds: [],
defaultNote: null,
} as const;

View file

@ -225,6 +225,8 @@ export interface IssueProviderBase extends BaseIssueProviderCfg {
isAutoPoll?: boolean;
isAutoAddToBacklog?: boolean;
isIntegratedAddTaskBar?: boolean;
defaultTagIds?: string[];
defaultNote?: string | null;
}
export interface IssueProviderJira extends IssueProviderBase, JiraCfg {

View file

@ -1,4 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { unique } from '../../util/unique';
import { generateCalendarTaskId } from '../calendar-integration/generate-calendar-task-id';
import {
BuiltInIssueProviderKey,
@ -515,33 +516,47 @@ export class IssueService {
} = this._getAddTaskData(issueProviderKey, issueDataReduced);
IssueLog.log({ title, related_to, additionalFromProviderIssueService });
const getProjectOrTagId = async (): Promise<Partial<TaskCopy>> => {
const defaultProjectId = (
await this._issueProviderService
.getCfgOnce$(issueProviderId, issueProviderKey)
.toPromise()
).defaultProjectId;
const getTaskDefaults = async (): Promise<Partial<TaskCopy>> => {
const providerCfg = await this._issueProviderService
.getCfgOnce$(issueProviderId, issueProviderKey)
.toPromise();
const defaultProjectId = providerCfg.defaultProjectId;
const defaultTagIds = (providerCfg.defaultTagIds || []).filter(
(id) => id !== TODAY_TAG.id,
);
const defaultNote = providerCfg.defaultNote;
if (typeof this._workContextService.activeWorkContextId !== 'string') {
throw new Error('No active work context id');
}
const result: Partial<TaskCopy> = {};
if (
defaultNote &&
!(additionalFromProviderIssueService as Partial<TaskCopy>).notes
) {
result.notes = defaultNote;
}
if (
this._workContextService.activeWorkContextType === WorkContextType.PROJECT &&
!isForceDefaultProject
) {
return {
projectId: defaultProjectId || this._workContextService.activeWorkContextId,
};
result.projectId =
defaultProjectId || this._workContextService.activeWorkContextId;
if (defaultTagIds.length) {
result.tagIds = [...defaultTagIds];
}
return result;
} else {
return {
tagIds:
this._workContextService.activeWorkContextType === WorkContextType.TAG &&
this._workContextService.activeWorkContextId !== TODAY_TAG.id
? [this._workContextService.activeWorkContextId]
: [],
projectId: defaultProjectId || undefined,
};
const contextTagIds =
this._workContextService.activeWorkContextType === WorkContextType.TAG &&
this._workContextService.activeWorkContextId !== TODAY_TAG.id
? [this._workContextService.activeWorkContextId]
: [];
result.tagIds = unique([...contextTagIds, ...defaultTagIds]);
result.projectId = defaultProjectId || undefined;
return result;
}
};
@ -554,8 +569,7 @@ export class IssueService {
// Default plan for today unless a precise time is provided by provider
dueDay: getDbDateStr(),
...additionalFromProviderIssueService,
// NOTE: if we were to add tags, this could be overwritten here
...(await getProjectOrTagId()),
...(await getTaskDefaults()),
...additional,
};

View file

@ -21,6 +21,8 @@ import { TASK_REPEAT_CFG_FEATURE_NAME } from '../../../features/task-repeat-cfg/
import { TaskRepeatCfgState } from '../../../features/task-repeat-cfg/task-repeat-cfg.model';
import { TIME_TRACKING_FEATURE_KEY } from '../../../features/time-tracking/store/time-tracking.reducer';
import { TimeTrackingState } from '../../../features/time-tracking/time-tracking.model';
import { ISSUE_PROVIDER_FEATURE_KEY } from '../../../features/issue/store/issue-provider.reducer';
import { IssueProviderState } from '../../../features/issue/issue.model';
describe('tagSharedMetaReducer', () => {
let mockReducer: jasmine.Spy;
@ -782,6 +784,56 @@ describe('tagSharedMetaReducer', () => {
expect(passedState[TIME_TRACKING_FEATURE_KEY].tag.tag2).toBeDefined();
});
it('should cleanup issue provider defaultTagIds that reference the deleted tag', () => {
const testState = createBaseState() as any;
testState[ISSUE_PROVIDER_FEATURE_KEY] = {
ids: ['provider1', 'provider2'],
entities: {
provider1: {
id: 'provider1',
defaultTagIds: ['tag1', 'tag2'],
},
provider2: {
id: 'provider2',
defaultTagIds: ['tag2'],
},
},
} as unknown as IssueProviderState;
const action = deleteTag({ id: 'tag1' });
metaReducer(testState, action);
const passedState = mockReducer.calls.mostRecent().args[0];
expect(
passedState[ISSUE_PROVIDER_FEATURE_KEY].entities.provider1.defaultTagIds,
).toEqual(['tag2']);
expect(
passedState[ISSUE_PROVIDER_FEATURE_KEY].entities.provider2.defaultTagIds,
).toEqual(['tag2']);
});
it('should not modify issue providers without defaultTagIds', () => {
const testState = createBaseState() as any;
testState[ISSUE_PROVIDER_FEATURE_KEY] = {
ids: ['provider1'],
entities: {
provider1: {
id: 'provider1',
},
},
} as unknown as IssueProviderState;
const action = deleteTag({ id: 'tag1' });
metaReducer(testState, action);
const passedState = mockReducer.calls.mostRecent().args[0];
expect(
passedState[ISSUE_PROVIDER_FEATURE_KEY].entities.provider1.defaultTagIds,
).toBeUndefined();
});
it('should handle deleting non-existent tag gracefully', () => {
const testState = createBaseState();

View file

@ -22,6 +22,11 @@ import {
import { adapter as taskRepeatCfgAdapter } from '../../../features/task-repeat-cfg/store/task-repeat-cfg.selectors';
import { TIME_TRACKING_FEATURE_KEY } from '../../../features/time-tracking/store/time-tracking.reducer';
import { TimeTrackingState } from '../../../features/time-tracking/time-tracking.model';
import {
ISSUE_PROVIDER_FEATURE_KEY,
adapter as issueProviderAdapter,
} from '../../../features/issue/store/issue-provider.reducer';
import { IssueProvider, IssueProviderState } from '../../../features/issue/issue.model';
/**
* Extended state type that includes feature stores not in RootState.
@ -29,6 +34,7 @@ import { TimeTrackingState } from '../../../features/time-tracking/time-tracking
*/
interface ExtendedState extends RootState {
[TASK_REPEAT_CFG_FEATURE_NAME]?: TaskRepeatCfgState;
[ISSUE_PROVIDER_FEATURE_KEY]?: IssueProviderState;
}
// =============================================================================
@ -229,6 +235,40 @@ const cleanupTimeTrackingState = (
};
};
/**
* Removes deleted tags from issue provider defaultTagIds.
*/
const cleanupIssueProviders = (
issueProviderState: IssueProviderState | undefined,
tagIdsToRemove: string[],
): IssueProviderState | undefined => {
if (!issueProviderState) return issueProviderState;
const updates: Update<IssueProvider>[] = [];
const tagIdsToRemoveSet = new Set(tagIdsToRemove);
Object.values(issueProviderState.entities).forEach((provider) => {
if (!provider?.defaultTagIds?.length) return;
const hasDeletedTag = provider.defaultTagIds.some((tagId) =>
tagIdsToRemoveSet.has(tagId),
);
if (!hasDeletedTag) return;
updates.push({
id: provider.id,
changes: {
defaultTagIds: provider.defaultTagIds.filter(
(tagId) => !tagIdsToRemoveSet.has(tagId),
),
},
});
});
if (updates.length === 0) return issueProviderState;
return issueProviderAdapter.updateMany(updates, issueProviderState);
};
/**
* Comprehensive handler for tag deletion.
* Atomically handles all related cleanup in a single reducer pass.
@ -258,6 +298,12 @@ const handleTagDeletion = (
tagIdsToRemove,
);
// 5. Cleanup issue provider defaultTagIds
const updatedIssueProviderState = cleanupIssueProviders(
state[ISSUE_PROVIDER_FEATURE_KEY],
tagIdsToRemove,
);
if (orphanedTaskIds.length > 0) {
OpLog.log('tagSharedMetaReducer: Removed orphaned tasks during tag deletion', {
orphanedTaskIds,
@ -274,6 +320,9 @@ const handleTagDeletion = (
...(updatedTimeTrackingState && {
[TIME_TRACKING_FEATURE_KEY]: updatedTimeTrackingState,
}),
...(updatedIssueProviderState && {
[ISSUE_PROVIDER_FEATURE_KEY]: updatedIssueProviderState,
}),
};
};

View file

@ -342,8 +342,10 @@ const T = {
ISSUE_STR: 'F.ISSUE.DEFAULT.ISSUE_STR',
ISSUES_STR: 'F.ISSUE.DEFAULT.ISSUES_STR',
},
DEFAULT_NOTE_LABEL: 'F.ISSUE.DEFAULT_NOTE_LABEL',
DEFAULT_PROJECT_DESCRIPTION: 'F.ISSUE.DEFAULT_PROJECT_DESCRIPTION',
DEFAULT_PROJECT_LABEL: 'F.ISSUE.DEFAULT_PROJECT_LABEL',
DEFAULT_TAGS_LABEL: 'F.ISSUE.DEFAULT_TAGS_LABEL',
HOW_TO_GET_A_TOKEN: 'F.ISSUE.HOW_TO_GET_A_TOKEN',
ISSUE_CONTENT: {
ASSIGNEE: 'F.ISSUE.ISSUE_CONTENT.ASSIGNEE',

View file

@ -340,8 +340,10 @@
"ISSUE_STR": "issue",
"ISSUES_STR": "issues"
},
"DEFAULT_NOTE_LABEL": "Default note for imported tasks",
"DEFAULT_PROJECT_DESCRIPTION": "Project assigned to tasks created from issues.",
"DEFAULT_PROJECT_LABEL": "Default Super Productivity Project",
"DEFAULT_TAGS_LABEL": "Default tags for imported tasks",
"HOW_TO_GET_A_TOKEN": "How to get a token?",
"ISSUE_CONTENT": {
"ASSIGNEE": "Assignee",