mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-21 02:20:12 +00:00
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:
parent
01c6d82a7c
commit
0cacb600af
10 changed files with 189 additions and 19 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -167,4 +167,6 @@ export const ISSUE_PROVIDER_DEFAULT_COMMON_CFG: Omit<
|
|||
isIntegratedAddTaskBar: false,
|
||||
defaultProjectId: null,
|
||||
pinnedSearch: null,
|
||||
defaultTagIds: [],
|
||||
defaultNote: null,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue