From 0cacb600af511ebd3dad596858876fc5d2eda623 Mon Sep 17 00:00:00 2001 From: John Costa Date: Mon, 23 Mar 2026 06:50:05 -0700 Subject: [PATCH] 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 --- .../issue/common-issue-form-stuff.const.ts | 8 +++ .../dialog-edit-issue-provider.component.html | 9 ++++ .../dialog-edit-issue-provider.component.ts | 30 +++++++++++ src/app/features/issue/issue.const.ts | 2 + src/app/features/issue/issue.model.ts | 2 + src/app/features/issue/issue.service.ts | 52 ++++++++++++------- .../tag-shared.reducer.spec.ts | 52 +++++++++++++++++++ .../tag-shared.reducer.ts | 49 +++++++++++++++++ src/app/t.const.ts | 2 + src/assets/i18n/en.json | 2 + 10 files changed, 189 insertions(+), 19 deletions(-) diff --git a/src/app/features/issue/common-issue-form-stuff.const.ts b/src/app/features/issue/common-issue-form-stuff.const.ts index 183344f892..f5d69190e2 100644 --- a/src/app/features/issue/common-issue-form-stuff.const.ts +++ b/src/app/features/issue/common-issue-form-stuff.const.ts @@ -93,5 +93,13 @@ export const ISSUE_PROVIDER_COMMON_FORM_FIELDS: LimitedFormlyFieldConfig + + @switch (issueProviderKey) { @case ('JIRA') { diff --git a/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.ts b/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.ts index 5b7a8e0d42..8c85c697f3 100644 --- a/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.ts +++ b/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.ts @@ -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) => { diff --git a/src/app/features/issue/issue.const.ts b/src/app/features/issue/issue.const.ts index 741d2dccfd..96c791e2ae 100644 --- a/src/app/features/issue/issue.const.ts +++ b/src/app/features/issue/issue.const.ts @@ -167,4 +167,6 @@ export const ISSUE_PROVIDER_DEFAULT_COMMON_CFG: Omit< isIntegratedAddTaskBar: false, defaultProjectId: null, pinnedSearch: null, + defaultTagIds: [], + defaultNote: null, } as const; diff --git a/src/app/features/issue/issue.model.ts b/src/app/features/issue/issue.model.ts index 30a24d2ab8..84b4df4b9a 100644 --- a/src/app/features/issue/issue.model.ts +++ b/src/app/features/issue/issue.model.ts @@ -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 { diff --git a/src/app/features/issue/issue.service.ts b/src/app/features/issue/issue.service.ts index 18f667c860..a81e589b4b 100644 --- a/src/app/features/issue/issue.service.ts +++ b/src/app/features/issue/issue.service.ts @@ -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> => { - const defaultProjectId = ( - await this._issueProviderService - .getCfgOnce$(issueProviderId, issueProviderKey) - .toPromise() - ).defaultProjectId; + const getTaskDefaults = async (): Promise> => { + 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 = {}; + if ( + defaultNote && + !(additionalFromProviderIssueService as Partial).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, }; diff --git a/src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.spec.ts index ffbe0b0849..612562c543 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.spec.ts @@ -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(); diff --git a/src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.ts index 71006f7bee..842f3e51ab 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.ts @@ -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[] = []; + 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, + }), }; }; diff --git a/src/app/t.const.ts b/src/app/t.const.ts index d1753e3161..dc39604ba2 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 7b2b8468a5..994ac6ebb6 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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",