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",