diff --git a/README.md b/README.md
index 6e3bc4776..b49dd7268 100644
--- a/README.md
+++ b/README.md
@@ -132,7 +132,7 @@
- The **anti-procrastination feature** helps you gain perspective when you really need to.
- Need some extra focus? A **Pomodoro timer** is also always at hand.
- **Collect personal metrics** to see, which of your work routines need adjustments.
-- Integrate with **Jira**, **GitHub**, **GitLab**, **Gitea** and **OpenProject**. Auto import tasks assigned to you, plan the details locally, automatically create work logs, and get notified immediately, when something changes.
+- Integrate with **Jira**, **Trello**, **GitHub**, **GitLab**, **Gitea** and **OpenProject**. Auto import tasks assigned to you, plan the details locally, automatically create work logs, and get notified immediately, when something changes.
- Basic [**CalDAV**](https://github.com/johannesjo/super-productivity/blob/master/docs/caldav.md) integration.
- Back up and synchronize your data across multiple devices with **Dropbox** and **WebDAV** support
- Attach context information to tasks and projects. Create **notes**, attach **files** or create **project-level bookmarks** for links, files, and even commands.
diff --git a/src/app/core/theme/global-theme.service.ts b/src/app/core/theme/global-theme.service.ts
index 2b7cb23e5..1d177e745 100644
--- a/src/app/core/theme/global-theme.service.ts
+++ b/src/app/core/theme/global-theme.service.ts
@@ -160,6 +160,8 @@ export class GlobalThemeService {
['repeat', 'assets/icons/repeat.svg'],
['gitea', 'assets/icons/gitea.svg'],
['redmine', 'assets/icons/redmine.svg'],
+ // trello icon
+ ['trello', 'assets/icons/trello.svg'],
['calendar', 'assets/icons/calendar.svg'],
['early_on', 'assets/icons/early-on.svg'],
['tomorrow', 'assets/icons/tomorrow.svg'],
diff --git a/src/app/features/issue-panel/issue-panel-calendar-agenda/issue-panel-calendar-agenda.component.html b/src/app/features/issue-panel/issue-panel-calendar-agenda/issue-panel-calendar-agenda.component.html
index 1a0aebecf..600bf2fc7 100644
--- a/src/app/features/issue-panel/issue-panel-calendar-agenda/issue-panel-calendar-agenda.component.html
+++ b/src/app/features/issue-panel/issue-panel-calendar-agenda/issue-panel-calendar-agenda.component.html
@@ -16,7 +16,7 @@
[actionAdvice]="'Check your config!'"
>
} @else if (!agendaItems()?.length) {
-
No items found (already added are not shown)
+ No items found.
} @else {
@for (day of agendaItems(); track day.dayStr) {
diff --git a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html
index e2dae3a9f..7087603e2 100644
--- a/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html
+++ b/src/app/features/issue-panel/issue-provider-setup-overview/issue-provider-setup-overview.component.html
@@ -33,6 +33,13 @@
Jira
+
+
+ Trello
+
}
+ @case ('TRELLO') {
+
+
+ }
}
}
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 c587c61f0..3a65753f4 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
@@ -45,6 +45,7 @@ import { MatIcon } from '@angular/material/icon';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { devError } from '../../../util/dev-error';
import { IssueLog } from '../../../core/log';
+import { TrelloAdditionalCfgComponent } from '../providers/trello/trello-view-components/trello_cfg/trello_additional_cfg.component';
@Component({
selector: 'dialog-edit-issue-provider',
@@ -65,6 +66,7 @@ import { IssueLog } from '../../../core/log';
MatButton,
MatIcon,
MatDialogTitle,
+ TrelloAdditionalCfgComponent, // added for custom trello board loading support
],
templateUrl: './dialog-edit-issue-provider.component.html',
styleUrl: './dialog-edit-issue-provider.component.scss',
@@ -146,7 +148,9 @@ export class DialogEditIssueProviderComponent {
customCfgCmpSave(cfgUpdates: IssueIntegrationCfg): void {
IssueLog.log('customCfgCmpSave()', cfgUpdates);
+ console.log('Dialog received config update:', cfgUpdates);
this.updateModel(cfgUpdates);
+ console.log('Dialog model after update:', this.model);
}
updateModel(model: Partial): void {
@@ -239,5 +243,8 @@ export class DialogEditIssueProviderComponent {
protected readonly ICAL_TYPE = ICAL_TYPE;
protected readonly IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW;
protected readonly IS_ELECTRON = IS_ELECTRON;
+ protected readonly IS_WEB_EXTENSION_REQUIRED_FOR_JIRA =
+ IS_WEB_EXTENSION_REQUIRED_FOR_JIRA;
+ // TODO: trello
protected readonly IS_WEB_EXTENSION_REQUIRED_FOR_JIRA = IS_WEB_BROWSER;
}
diff --git a/src/app/features/issue/issue-content/issue-content-configs.const.ts b/src/app/features/issue/issue-content/issue-content-configs.const.ts
index a6a49c34e..0e331fc6f 100644
--- a/src/app/features/issue/issue-content/issue-content-configs.const.ts
+++ b/src/app/features/issue/issue-content/issue-content-configs.const.ts
@@ -12,6 +12,7 @@ import { CALDAV_ISSUE_CONTENT_CONFIG } from '../providers/caldav/caldav-issue-co
import { GITEA_ISSUE_CONTENT_CONFIG } from '../providers/gitea/gitea-issue-content.const';
import { REDMINE_ISSUE_CONTENT_CONFIG } from '../providers/redmine/redmine-issue-content.const';
import { OPEN_PROJECT_ISSUE_CONTENT_CONFIG } from '../providers/open-project/open-project-issue-content.const';
+import { TRELLO_ISSUE_CONTENT_CONFIG } from '../providers/trello/trello-issue-content.const';
// Re-export types for backwards compatibility
export { IssueFieldType, IssueFieldConfig, IssueCommentConfig, IssueContentConfig };
@@ -24,6 +25,7 @@ export const ISSUE_CONTENT_CONFIGS: Record {
@@ -172,6 +184,10 @@ export interface IssueProviderCalendar extends IssueProviderBase, CalendarProvid
issueProviderKey: 'ICAL';
}
+export interface IssueProviderTrello extends IssueProviderBase, TrelloCfg {
+ issueProviderKey: 'TRELLO';
+}
+
export type IssueProvider =
| IssueProviderJira
| IssueProviderGithub
@@ -180,7 +196,8 @@ export type IssueProvider =
| IssueProviderCalendar
| IssueProviderOpenProject
| IssueProviderGitea
- | IssueProviderRedmine;
+ | IssueProviderRedmine
+ | IssueProviderTrello;
export type IssueProviderTypeMap = T extends 'JIRA'
? IssueProviderJira
@@ -198,4 +215,6 @@ export type IssueProviderTypeMap = T extends 'JIRA'
? IssueProviderCaldav
: T extends 'ICAL'
? IssueProviderCalendar
- : never;
+ : T extends 'TRELLO'
+ ? IssueProviderTrello
+ : never;
diff --git a/src/app/features/issue/issue.service.ts b/src/app/features/issue/issue.service.ts
index 5893c0be2..0b7b60dab 100644
--- a/src/app/features/issue/issue.service.ts
+++ b/src/app/features/issue/issue.service.ts
@@ -20,6 +20,7 @@ import {
ISSUE_STR_MAP,
JIRA_TYPE,
OPEN_PROJECT_TYPE,
+ TRELLO_TYPE,
REDMINE_TYPE,
} from './issue.const';
import { TaskService } from '../tasks/task.service';
@@ -27,6 +28,7 @@ import { IssueTask, Task, TaskCopy } from '../tasks/task.model';
import { IssueServiceInterface } from './issue-service-interface';
import { JiraCommonInterfacesService } from './providers/jira/jira-common-interfaces.service';
import { GithubCommonInterfacesService } from './providers/github/github-common-interfaces.service';
+import { TrelloCommonInterfacesService } from './providers/trello/trello-common-interfaces.service';
import { catchError, map, switchMap } from 'rxjs/operators';
import { IssueLog } from '../../core/log';
import { GitlabCommonInterfacesService } from './providers/gitlab/gitlab-common-interfaces.service';
@@ -59,6 +61,7 @@ import { GlobalProgressBarService } from '../../core-ui/global-progress-bar/glob
export class IssueService {
private _taskService = inject(TaskService);
private _jiraCommonInterfacesService = inject(JiraCommonInterfacesService);
+ private _trelloCommonInterfacesService = inject(TrelloCommonInterfacesService);
private _githubCommonInterfacesService = inject(GithubCommonInterfacesService);
private _gitlabCommonInterfacesService = inject(GitlabCommonInterfacesService);
private _caldavCommonInterfaceService = inject(CaldavCommonInterfacesService);
@@ -84,6 +87,9 @@ export class IssueService {
[GITEA_TYPE]: this._giteaInterfaceService,
[REDMINE_TYPE]: this._redmineInterfaceService,
[ICAL_TYPE]: this._calendarCommonInterfaceService,
+
+ // trello
+ [TRELLO_TYPE]: this._trelloCommonInterfacesService,
};
ISSUE_REFRESH_MAP: {
diff --git a/src/app/features/issue/mapping-helper/get-issue-provider-tooltip.ts b/src/app/features/issue/mapping-helper/get-issue-provider-tooltip.ts
index 66711fc50..c2852ffbf 100644
--- a/src/app/features/issue/mapping-helper/get-issue-provider-tooltip.ts
+++ b/src/app/features/issue/mapping-helper/get-issue-provider-tooltip.ts
@@ -19,6 +19,10 @@ export const getIssueProviderTooltip = (issueProvider: IssueProvider): string =>
return issueProvider.projectId;
case 'OPEN_PROJECT':
return issueProvider.projectId;
+ case 'TRELLO':
+ return issueProvider.boardName || issueProvider.boardId;
+ default:
+ return undefined;
}
})();
return v || issueProvider.issueProviderKey;
@@ -82,6 +86,10 @@ export const getIssueProviderInitials = (
return getRepoInitials(issueProvider.project);
case 'GITEA':
return getRepoInitials(issueProvider.repoFullname);
+ case 'TRELLO':
+ return (issueProvider.boardName || issueProvider.boardId)
+ ?.substring(0, 2)
+ ?.toUpperCase();
}
return undefined;
};
diff --git a/src/app/features/issue/providers/trello/is-trello-enabled.util.ts b/src/app/features/issue/providers/trello/is-trello-enabled.util.ts
new file mode 100644
index 000000000..74cf95c4a
--- /dev/null
+++ b/src/app/features/issue/providers/trello/is-trello-enabled.util.ts
@@ -0,0 +1,4 @@
+import { TrelloCfg } from './trello.model';
+
+export const isTrelloEnabled = (cfg: TrelloCfg): boolean =>
+ !!cfg && cfg.isEnabled && !!cfg.apiKey && !!cfg.token && !!cfg.boardId;
diff --git a/src/app/features/issue/providers/trello/trello-api.service.spec.ts b/src/app/features/issue/providers/trello/trello-api.service.spec.ts
new file mode 100644
index 000000000..03ee33292
--- /dev/null
+++ b/src/app/features/issue/providers/trello/trello-api.service.spec.ts
@@ -0,0 +1,232 @@
+import { TestBed } from '@angular/core/testing';
+import {
+ HttpTestingController,
+ provideHttpClientTesting,
+} from '@angular/common/http/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { TrelloApiService } from './trello-api.service';
+import { SnackService } from '../../../../core/snack/snack.service';
+import { TrelloCfg } from './trello.model';
+
+describe('TrelloApiService', () => {
+ let service: TrelloApiService;
+ let httpMock: HttpTestingController;
+ let snackService: jasmine.SpyObj;
+
+ const mockCfg: TrelloCfg = {
+ isEnabled: true,
+ apiKey: 'test-api-key',
+ token: 'test-token',
+ boardId: '5f1a1a1a1a1a1a1a1a1a1a1a',
+ };
+
+ beforeEach(() => {
+ const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
+
+ TestBed.configureTestingModule({
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ TrelloApiService,
+ {
+ provide: SnackService,
+ useValue: snackServiceSpy,
+ },
+ ],
+ });
+
+ service = TestBed.inject(TrelloApiService);
+ httpMock = TestBed.inject(HttpTestingController);
+ snackService = TestBed.inject(SnackService) as jasmine.SpyObj;
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ describe('testConnection$', () => {
+ it('should make request to boards endpoint with credentials', (done) => {
+ service.testConnection$(mockCfg).subscribe((result) => {
+ expect(result).toBe(true);
+ done();
+ });
+
+ const req = httpMock.expectOne((request) => request.url.includes('/boards/'));
+ expect(req.request.urlWithParams).toContain(`key=${mockCfg.apiKey}`);
+ expect(req.request.urlWithParams).toContain(`token=${mockCfg.token}`);
+ expect(req.request.method).toBe('GET');
+ req.flush({ id: mockCfg.boardId });
+ });
+
+ it('should return true on successful connection', (done) => {
+ service.testConnection$(mockCfg).subscribe((result) => {
+ expect(result).toBe(true);
+ done();
+ });
+
+ const req = httpMock.expectOne((request) => request.url.includes('/boards/'));
+ req.flush({ id: mockCfg.boardId });
+ });
+ });
+
+ describe('issuePicker$', () => {
+ it('should fetch board cards when search term is empty', (done) => {
+ service.issuePicker$('', mockCfg).subscribe(() => {
+ done();
+ });
+
+ const req = httpMock.expectOne((request) => request.url.includes('/boards/'));
+ expect(req.request.method).toBe('GET');
+ req.flush([]);
+ });
+
+ it('should search when search term is provided', (done) => {
+ service.issuePicker$('test search', mockCfg).subscribe(() => {
+ done();
+ });
+
+ const req = httpMock.expectOne((request) => request.url.includes('/search'));
+ expect(req.request.urlWithParams).toContain('query=test');
+ req.flush({ cards: [] });
+ });
+
+ it('should limit search results correctly', (done) => {
+ service.issuePicker$('test', mockCfg, 10).subscribe(() => {
+ done();
+ });
+
+ const req = httpMock.expectOne((request) => request.url.includes('/search'));
+ expect(req.request.urlWithParams).toContain('cards_limit=10');
+ req.flush({ cards: [] });
+ });
+
+ it('should cap cards_limit to 100 maximum', (done) => {
+ service.issuePicker$('test', mockCfg, 200).subscribe(() => {
+ done();
+ });
+
+ const req = httpMock.expectOne((request) => request.url.includes('/search'));
+ expect(req.request.urlWithParams).toContain('cards_limit=100');
+ req.flush({ cards: [] });
+ });
+ });
+
+ describe('configuration validation', () => {
+ it('should throw error when apiKey is missing', () => {
+ const invalidCfg = { ...mockCfg, apiKey: null };
+
+ expect(() => {
+ service.testConnection$(invalidCfg).subscribe();
+ }).toThrowError('Trello: Not enough settings');
+
+ expect(snackService.open).toHaveBeenCalled();
+ });
+
+ it('should throw error when token is missing', () => {
+ const invalidCfg = { ...mockCfg, token: null };
+
+ expect(() => {
+ service.testConnection$(invalidCfg).subscribe();
+ }).toThrowError('Trello: Not enough settings');
+
+ expect(snackService.open).toHaveBeenCalled();
+ });
+
+ it('should throw error when boardId is missing', () => {
+ const invalidCfg = { ...mockCfg, boardId: null };
+
+ expect(() => {
+ service.testConnection$(invalidCfg).subscribe();
+ }).toThrowError('Trello: Not enough settings');
+
+ expect(snackService.open).toHaveBeenCalled();
+ });
+
+ it('should accept valid board IDs', (done) => {
+ const validCfg = { ...mockCfg, boardId: 'abc123DEF456abc123DEF456' };
+
+ service.testConnection$(validCfg).subscribe(() => {
+ done();
+ });
+
+ const req = httpMock.expectOne((request) => request.url.includes('/boards/'));
+ req.flush({ id: validCfg.boardId });
+ });
+ });
+
+ describe('error handling', () => {
+ it('should handle invalid object id error from API', (done) => {
+ let errorOccurred = false;
+ service.testConnection$(mockCfg).subscribe(
+ () => {},
+ () => {
+ errorOccurred = true;
+ done();
+ },
+ );
+
+ const req = httpMock.expectOne((request) => request.url.includes('/boards/'));
+ req.flush(
+ { error: 'Invalid objectId' },
+ { status: 400, statusText: 'Bad Request' },
+ );
+ expect(errorOccurred).toBe(true);
+ });
+
+ it('should handle network errors', (done) => {
+ let errorOccurred = false;
+ service.testConnection$(mockCfg).subscribe(
+ () => {},
+ () => {
+ errorOccurred = true;
+ done();
+ },
+ );
+
+ const req = httpMock.expectOne((request) => request.url.includes('/boards/'));
+ req.error(new ErrorEvent('Network error'));
+ expect(errorOccurred).toBe(true);
+ });
+
+ it('should handle generic API errors', (done) => {
+ let errorOccurred = false;
+ service.testConnection$(mockCfg).subscribe(
+ () => {},
+ () => {
+ errorOccurred = true;
+ done();
+ },
+ );
+
+ const req = httpMock.expectOne((request) => request.url.includes('/boards/'));
+ req.flush({ error: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
+ expect(errorOccurred).toBe(true);
+ });
+ });
+
+ describe('HTTP parameters', () => {
+ it('should include api key in query parameters', (done) => {
+ service.testConnection$(mockCfg).subscribe(() => {
+ done();
+ });
+
+ const req = httpMock.expectOne((request) => request.url.includes('/boards/'));
+ expect(req.request.urlWithParams).toContain('key=test-api-key');
+ expect(req.request.headers.has('Authorization')).toBe(true);
+ expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
+ req.flush({ id: mockCfg.boardId });
+ });
+
+ it('should properly encode search terms', (done) => {
+ service.issuePicker$('test & special', mockCfg).subscribe(() => {
+ done();
+ });
+
+ const req = httpMock.expectOne((request) => request.url.includes('/search'));
+ expect(req.request.urlWithParams).toContain('query=test');
+ expect(req.request.urlWithParams).toContain('%26');
+ expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
+ req.flush({ cards: [] });
+ });
+ });
+});
diff --git a/src/app/features/issue/providers/trello/trello-api.service.ts b/src/app/features/issue/providers/trello/trello-api.service.ts
new file mode 100644
index 000000000..b5dcb4702
--- /dev/null
+++ b/src/app/features/issue/providers/trello/trello-api.service.ts
@@ -0,0 +1,280 @@
+import { Injectable, inject } from '@angular/core';
+import {
+ HttpClient,
+ HttpErrorResponse,
+ HttpParams,
+ HttpHeaders,
+} from '@angular/common/http';
+import { Observable, throwError } from 'rxjs';
+import { catchError, map } from 'rxjs/operators';
+import { TrelloCfg } from './trello.model';
+import { SnackService } from '../../../../core/snack/snack.service';
+import { SearchResultItem } from '../../issue.model';
+import {
+ TrelloCardResponse,
+ TrelloSearchResponse,
+ mapTrelloCardReduced,
+ mapTrelloCardToIssue,
+ mapTrelloSearchResults,
+} from './trello-issue-map.util';
+import { TrelloIssue, TrelloIssueReduced } from './trello-issue.model';
+import { throwHandledError } from '../../../../util/throw-handled-error';
+import { ISSUE_PROVIDER_HUMANIZED, TRELLO_TYPE } from '../../issue.const';
+import { T } from '../../../../t.const';
+import { HANDLED_ERROR_PROP_STR } from '../../../../app.constants';
+import { getErrorTxt } from '../../../../util/get-error-text';
+
+const BASE_URL = 'https://api.trello.com/1';
+const DEFAULT_CARD_FIELDS = [
+ 'id',
+ 'idShort',
+ 'shortLink',
+ 'name',
+ 'url',
+ 'desc',
+ 'due',
+ 'dueComplete',
+ 'closed',
+ 'idBoard',
+ 'idList',
+ 'dateLastActivity',
+].join(',');
+const DEFAULT_ATTACHMENT_FIELDS = [
+ 'id',
+ 'name',
+ 'url',
+ 'bytes',
+ 'date',
+ 'mimeType',
+ 'previews',
+ 'edgeColor',
+ 'pos',
+ 'idMember',
+].join(',');
+const DEFAULT_MEMBER_FIELDS = ['fullName', 'username', 'avatarUrl'].join(',');
+
+@Injectable({
+ providedIn: 'root',
+})
+export class TrelloApiService {
+ private _http = inject(HttpClient);
+ private _snackService = inject(SnackService);
+
+ testConnection$(cfg: TrelloCfg): Observable {
+ return this._request$<{ id: string }>(`/boards/${cfg.boardId}`, cfg, {
+ fields: 'id',
+ }).pipe(map(() => true));
+ }
+
+ issuePicker$(
+ searchTerm: string,
+ cfg: TrelloCfg,
+ maxResults: number = 25,
+ ): Observable[]> {
+ const trimmed = searchTerm.trim();
+
+ if (!trimmed) {
+ return this._fetchBoardCards$(cfg, maxResults).pipe(
+ map((cards) => mapTrelloSearchResults(cards)),
+ );
+ }
+
+ const params = {
+ query: trimmed,
+ idBoards: cfg.boardId ?? undefined,
+ modelTypes: 'cards',
+ cards_limit: Math.min(maxResults, 100),
+ card_fields: DEFAULT_CARD_FIELDS,
+ card_attachments: 'true',
+ card_attachment_fields: DEFAULT_ATTACHMENT_FIELDS,
+ card_members: 'true',
+ card_member_fields: DEFAULT_MEMBER_FIELDS,
+ partial: 'true',
+ } as const;
+
+ return this._request$('/search', cfg, params).pipe(
+ map((res) => mapTrelloSearchResults(res.cards || [])),
+ );
+ }
+
+ findAutoImportIssues$(
+ cfg: TrelloCfg,
+ maxResults: number = 200,
+ ): Observable {
+ return this._fetchBoardCards$(cfg, maxResults).pipe(
+ map((cards) => cards.map((card) => mapTrelloCardReduced(card))),
+ );
+ }
+
+ // list all projects from user
+ getBoards$(cfg: TrelloCfg): Observable {
+ return this._request$('/members/me/boards', cfg, {
+ filter: 'open',
+ fields: 'name,id',
+ });
+ }
+
+ getIssueById$(issueId: string, cfg: TrelloCfg): Observable {
+ return this._request$(`/cards/${issueId}`, cfg, {
+ fields: `${DEFAULT_CARD_FIELDS},labels`,
+ attachments: 'true',
+ attachment_fields: DEFAULT_ATTACHMENT_FIELDS,
+ members: 'true',
+ member_fields: DEFAULT_MEMBER_FIELDS,
+ }).pipe(map((card) => mapTrelloCardToIssue(card)));
+ }
+
+ getReducedIssueById$(issueId: string, cfg: TrelloCfg): Observable {
+ return this._request$(`/cards/${issueId}`, cfg, {
+ fields: DEFAULT_CARD_FIELDS,
+ attachments: 'true',
+ attachment_fields: DEFAULT_ATTACHMENT_FIELDS,
+ members: 'true',
+ member_fields: DEFAULT_MEMBER_FIELDS,
+ }).pipe(map((card) => mapTrelloCardReduced(card)));
+ }
+
+ getCardUrl$(issueId: string, cfg: TrelloCfg): Observable {
+ return this._request$<{ url: string; shortLink: string }>(`/cards/${issueId}`, cfg, {
+ fields: 'url,shortLink',
+ }).pipe(map((card) => card.url || `https://trello.com/c/${card.shortLink}`));
+ }
+
+ private _fetchBoardCards$(
+ cfg: TrelloCfg,
+ maxResults: number,
+ ): Observable {
+ const limit = Math.min(Math.max(maxResults, 1), 500);
+ return this._request$(`/boards/${cfg.boardId}/cards`, cfg, {
+ filter: 'open',
+ limit,
+ fields: `${DEFAULT_CARD_FIELDS},labels`,
+ attachments: 'true',
+ attachment_fields: DEFAULT_ATTACHMENT_FIELDS,
+ members: 'true',
+ member_fields: DEFAULT_MEMBER_FIELDS,
+ }).pipe(
+ map((cards) => (Array.isArray(cards) ? cards : [])),
+ map((cards) =>
+ cards
+ .slice()
+ .sort(
+ (a, b) =>
+ new Date(b.dateLastActivity).getTime() -
+ new Date(a.dateLastActivity).getTime(),
+ )
+ .slice(0, limit),
+ ),
+ );
+ }
+
+ private _request$(
+ path: string,
+ cfg: TrelloCfg,
+ params?: Record,
+ ): Observable {
+ this._checkSettings(cfg);
+ const httpParams = this._createParams(cfg, params);
+ const headers = new HttpHeaders().set('Authorization', `Bearer ${cfg.token}`);
+ return this._http
+ .get(`${BASE_URL}${path}`, {
+ params: httpParams,
+ headers,
+ })
+ .pipe(catchError((err) => this._handleError$(err)));
+ }
+
+ private _createParams(cfg: TrelloCfg, params?: Record): HttpParams {
+ let httpParams = new HttpParams()
+ .set('key', cfg.apiKey as string)
+ .set('token', cfg.token as string);
+
+ if (!params) {
+ return httpParams;
+ }
+
+ Object.entries(params).forEach(([key, value]) => {
+ if (value === undefined || value === null) {
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ value.forEach((v) => {
+ httpParams = httpParams.append(key, String(v));
+ });
+ } else {
+ httpParams = httpParams.set(key, String(value));
+ }
+ });
+
+ return httpParams;
+ }
+
+ private _checkSettings(cfg: TrelloCfg): void {
+ if (!cfg || !cfg.apiKey || !cfg.token || !cfg.boardId) {
+ this._snackService.open({
+ type: 'ERROR',
+ msg: T.F.ISSUE.S.ERR_NOT_CONFIGURED,
+ translateParams: {
+ issueProviderName: ISSUE_PROVIDER_HUMANIZED[TRELLO_TYPE],
+ },
+ });
+ throwHandledError('Trello: Not enough settings');
+ }
+
+ // Validate boardId format (should be alphanumeric, typically 24 chars for Trello - reference: https://community.developer.atlassian.com/t/uniqueness-of-trello-board-ids/67032/2)
+ // const boardIdRegex = /^[a-zA-Z0-9]+$/;
+ // remove this for now in favor of the trello board picker.
+ // if (cfg.boardId && cfg.boardId.length !== 24) {
+ // this._snackService.open({
+ // type: 'ERROR',
+ // msg: `${ISSUE_PROVIDER_HUMANIZED[TRELLO_TYPE]}: Invalid board ID format`,
+ // isSkipTranslate: true,
+ // });
+ // throwHandledError('Trello: Invalid board ID format');
+ // }
+ }
+
+ private _handleError$(error: HttpErrorResponse): Observable {
+ const issueProviderName = ISSUE_PROVIDER_HUMANIZED[TRELLO_TYPE];
+ const errorTxt = getErrorTxt(error);
+ const normalizedError = errorTxt.toLowerCase();
+ let displayMessage = `${issueProviderName}: ${errorTxt}`;
+
+ if (error.error instanceof ErrorEvent) {
+ this._snackService.open({
+ type: 'ERROR',
+ msg: T.F.ISSUE.S.ERR_NETWORK,
+ translateParams: {
+ issueProviderName,
+ },
+ });
+ } else if (
+ normalizedError.includes('invalid') &&
+ (normalizedError.includes('id') || normalizedError.includes('objectid'))
+ ) {
+ displayMessage = `${issueProviderName}: Invalid board ID. Please double-check the board ID in the Trello settings.`;
+ this._snackService.open({
+ type: 'ERROR',
+ msg: displayMessage,
+ isSkipTranslate: true,
+ });
+ } else if (error.error && typeof error.error === 'object' && error.error.message) {
+ this._snackService.open({
+ type: 'ERROR',
+ msg: `${issueProviderName}: ${error.error.message}`,
+ isSkipTranslate: true,
+ });
+ } else {
+ this._snackService.open({
+ type: 'ERROR',
+ msg: `${issueProviderName}: ${errorTxt}`,
+ isSkipTranslate: true,
+ });
+ }
+
+ return throwError({
+ [HANDLED_ERROR_PROP_STR]: displayMessage,
+ });
+ }
+}
diff --git a/src/app/features/issue/providers/trello/trello-common-interfaces.service.ts b/src/app/features/issue/providers/trello/trello-common-interfaces.service.ts
new file mode 100644
index 000000000..73c48b7bc
--- /dev/null
+++ b/src/app/features/issue/providers/trello/trello-common-interfaces.service.ts
@@ -0,0 +1,182 @@
+/**
+ * Service for trello
+ */
+
+import { Injectable, inject } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { Task } from 'src/app/features/tasks/task.model';
+import { first, switchMap, tap } from 'rxjs/operators';
+import { IssueServiceInterface } from '../../issue-service-interface';
+import { TrelloApiService } from './trello-api.service';
+import { IssueProviderTrello, SearchResultItem } from '../../issue.model';
+import { TrelloIssue, TrelloIssueReduced } from './trello-issue.model';
+import { TaskAttachment } from '../../../tasks/task-attachment/task-attachment.model';
+import { mapTrelloAttachmentToAttachment } from './trello-issue-map.util';
+import { TrelloCfg } from './trello.model';
+import { isTrelloEnabled } from './is-trello-enabled.util';
+import { IssueProviderService } from '../../issue-provider.service';
+import { assertTruthy } from '../../../../util/assert-truthy';
+import { IssueLog } from '../../../../core/log';
+import { TRELLO_POLL_INTERVAL } from './trello.const';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class TrelloCommonInterfacesService implements IssueServiceInterface {
+ // trello service interval
+ private readonly _trelloApiService = inject(TrelloApiService);
+ private readonly _issueProviderService = inject(IssueProviderService);
+
+ pollInterval: number = TRELLO_POLL_INTERVAL;
+
+ isEnabled(cfg: TrelloCfg): boolean {
+ return isTrelloEnabled(cfg);
+ }
+
+ testConnection(cfg: TrelloCfg): Promise {
+ return this._trelloApiService
+ .testConnection$(cfg)
+ .pipe(first())
+ .toPromise()
+ .then((result) => result ?? false);
+ }
+
+ // NOTE: we're using the issueKey instead of the real issueId
+ getById(issueId: string | number, issueProviderId: string): Promise {
+ return this._getCfgOnce$(issueProviderId)
+ .pipe(
+ switchMap((trelloCfg) =>
+ this._trelloApiService.getIssueById$(
+ assertTruthy(issueId).toString(),
+ trelloCfg,
+ ),
+ ),
+ )
+ .toPromise()
+ .then((result) => {
+ if (!result) {
+ throw new Error('Failed to get Trello card');
+ }
+ return result;
+ });
+ }
+
+ // NOTE: this gives back issueKey instead of issueId
+ searchIssues(searchTerm: string, issueProviderId: string): Promise {
+ return this._getCfgOnce$(issueProviderId)
+ .pipe(
+ switchMap((trelloCfg) =>
+ this.isEnabled(trelloCfg)
+ ? this._trelloApiService
+ .issuePicker$(searchTerm, trelloCfg)
+ .pipe(tap((v) => IssueLog.log('trello.issuePicker$', v)))
+ : of([]),
+ ),
+ )
+ .toPromise()
+ .then((result) => result ?? []);
+ }
+
+ async getFreshDataForIssueTask(task: Task): Promise<{
+ taskChanges: Partial;
+ issue: TrelloIssue;
+ issueTitle: string;
+ } | null> {
+ if (!task.issueProviderId) {
+ throw new Error('No issueProviderId');
+ }
+ if (!task.issueId) {
+ throw new Error('No issueId');
+ }
+
+ const cfg = await this._getCfgOnce$(task.issueProviderId).toPromise();
+ const issue = (await this._trelloApiService
+ .getIssueById$(task.issueId, cfg)
+ .toPromise()) as TrelloIssue;
+
+ const newUpdated = new Date(issue.updated).getTime();
+ const wasUpdated = newUpdated > (task.issueLastUpdated || 0);
+
+ if (wasUpdated) {
+ return {
+ taskChanges: {
+ ...this.getAddTaskData(issue),
+ issueWasUpdated: true,
+ },
+ issue,
+ issueTitle: issue.summary,
+ };
+ }
+ return null;
+ }
+
+ async getFreshDataForIssueTasks(
+ tasks: Task[],
+ ): Promise<{ task: Task; taskChanges: Partial; issue: TrelloIssue }[]> {
+ return Promise.all(
+ tasks.map((task) =>
+ this.getFreshDataForIssueTask(task).then((refreshDataForTask) => ({
+ task,
+ refreshDataForTask,
+ })),
+ ),
+ ).then((items) => {
+ return items
+ .filter(({ refreshDataForTask, task }) => !!refreshDataForTask)
+ .map(({ refreshDataForTask, task }) => {
+ if (!refreshDataForTask) {
+ throw new Error('No refresh data for task js error');
+ }
+ return {
+ task,
+ taskChanges: refreshDataForTask.taskChanges,
+ issue: refreshDataForTask.issue,
+ };
+ });
+ });
+ }
+
+ getAddTaskData(issue: TrelloIssueReduced): Partial & { title: string } {
+ return {
+ title: `${issue.key} ${issue.summary}`,
+ issuePoints: issue.storyPoints ?? undefined,
+ issueAttachmentNr: issue.attachments ? issue.attachments.length : 0,
+ issueWasUpdated: false,
+ issueLastUpdated: new Date(issue.updated).getTime(),
+ };
+ }
+
+ issueLink(issueId: string | number, issueProviderId: string): Promise {
+ if (!issueId || !issueProviderId) {
+ throw new Error('No issueId or no issueProviderId');
+ }
+ // const isIssueKey = isNaN(Number(issueId));
+ return this._getCfgOnce$(issueProviderId)
+ .pipe(
+ first(),
+ switchMap((trelloCfg) =>
+ this._trelloApiService.getCardUrl$(issueId.toString(), trelloCfg),
+ ),
+ )
+ .toPromise()
+ .then((result) => result ?? '');
+ }
+
+ async getNewIssuesToAddToBacklog(
+ issueProviderId: string,
+ allExistingIssueIds: number[] | string[],
+ ): Promise {
+ const cfg = await this._getCfgOnce$(issueProviderId).toPromise();
+ return await this._trelloApiService.findAutoImportIssues$(cfg).toPromise();
+ }
+
+ getMappedAttachments(issueData: TrelloIssue): TaskAttachment[] {
+ return issueData?.attachments?.length
+ ? issueData.attachments.map(mapTrelloAttachmentToAttachment)
+ : [];
+ }
+
+ private _getCfgOnce$(issueProviderId: string): Observable {
+ return this._issueProviderService.getCfgOnce$(issueProviderId, 'TRELLO');
+ }
+}
diff --git a/src/app/features/issue/providers/trello/trello-issue-content.const.ts b/src/app/features/issue/providers/trello/trello-issue-content.const.ts
new file mode 100644
index 000000000..22d60f034
--- /dev/null
+++ b/src/app/features/issue/providers/trello/trello-issue-content.const.ts
@@ -0,0 +1,60 @@
+/**
+ * Content of issues imported from trello.
+ * Useful for display in the issue panel.
+ */
+
+import { T } from '../../../../t.const';
+import {
+ IssueContentConfig,
+ IssueFieldType,
+} from '../../issue-content/issue-content.model';
+import { IssueProviderKey } from '../../issue.model';
+import { TrelloIssue } from './trello-issue.model';
+
+const formatMembers = (issue: TrelloIssue): string =>
+ issue.members
+ .map((member) => member.fullName || member.username)
+ .filter(Boolean)
+ .join(', ');
+
+export const TRELLO_ISSUE_CONTENT_CONFIG: IssueContentConfig = {
+ issueType: 'TRELLO' as IssueProviderKey,
+ fields: [
+ {
+ label: T.F.ISSUE.ISSUE_CONTENT.SUMMARY,
+ type: IssueFieldType.LINK,
+ value: (issue: TrelloIssue) => `${issue.key} ${issue.summary}`.trim(),
+ getLink: (issue: TrelloIssue) => issue.url,
+ },
+ {
+ label: T.F.ISSUE.ISSUE_CONTENT.STATUS,
+ type: IssueFieldType.TEXT,
+ value: (issue: TrelloIssue) => (issue.closed ? 'Closed' : 'Open'),
+ },
+ {
+ label: T.F.ISSUE.ISSUE_CONTENT.DUE_DATE,
+ type: IssueFieldType.TEXT,
+ value: (issue: TrelloIssue) => issue.due,
+ isVisible: (issue: TrelloIssue) => !!issue.due,
+ },
+ {
+ label: T.F.ISSUE.ISSUE_CONTENT.LABELS,
+ type: IssueFieldType.CHIPS,
+ value: (issue: TrelloIssue) => issue.labels,
+ isVisible: (issue: TrelloIssue) => (issue.labels?.length ?? 0) > 0,
+ },
+ {
+ label: T.F.ISSUE.ISSUE_CONTENT.ASSIGNEE,
+ type: IssueFieldType.TEXT,
+ value: (issue: TrelloIssue) => formatMembers(issue),
+ isVisible: (issue: TrelloIssue) => (issue.members?.length ?? 0) > 0,
+ },
+ {
+ label: T.F.ISSUE.ISSUE_CONTENT.DESCRIPTION,
+ type: IssueFieldType.MARKDOWN,
+ value: (issue: TrelloIssue) => issue.desc,
+ isVisible: (issue: TrelloIssue) => !!issue.desc,
+ },
+ ],
+ getIssueUrl: (issue: TrelloIssue) => issue.url,
+};
diff --git a/src/app/features/issue/providers/trello/trello-issue-map.util.ts b/src/app/features/issue/providers/trello/trello-issue-map.util.ts
new file mode 100644
index 000000000..b182241e5
--- /dev/null
+++ b/src/app/features/issue/providers/trello/trello-issue-map.util.ts
@@ -0,0 +1,219 @@
+/**
+ * Maps data from trello API to internal format for compatibility
+ */
+
+import { SearchResultItem } from '../../issue.model';
+import {
+ TrelloAttachment,
+ TrelloAttachmentPreview,
+ TrelloIssue,
+ TrelloIssueReduced,
+ TrelloLabel,
+ TrelloMember,
+} from './trello-issue.model';
+import { dedupeByKey } from '../../../../util/de-dupe-by-key';
+import {
+ DropPasteIcons,
+ DropPasteInputType,
+} from '../../../../core/drop-paste-input/drop-paste.model';
+import { TaskAttachment } from '../../../tasks/task-attachment/task-attachment.model';
+
+export interface TrelloCardLabelResponse {
+ id: string;
+ name: string;
+ color: string | null;
+}
+
+export interface TrelloCardMemberResponse {
+ id: string;
+ fullName: string;
+ username: string;
+ avatarUrl?: string | null;
+ avatarHash?: string | null;
+}
+
+export interface TrelloCardAttachmentPreviewResponse {
+ id: string;
+ url: string;
+ scaled: boolean;
+ bytes: number | null;
+ height: number | null;
+ width: number | null;
+}
+
+export interface TrelloCardAttachmentResponse {
+ id: string;
+ bytes: number | null;
+ date: string;
+ edgeColor: string | null;
+ idMember: string | null;
+ isUpload: boolean;
+ mimeType: string | null;
+ name: string;
+ url: string;
+ pos: number;
+ previews?: TrelloCardAttachmentPreviewResponse[];
+}
+
+export interface TrelloCardResponse {
+ id: string;
+ idShort: number | null;
+ shortLink: string;
+ name: string;
+ desc: string;
+ url: string;
+ due: string | null;
+ dueComplete: boolean;
+ closed: boolean;
+ idBoard: string;
+ idList: string;
+ dateLastActivity: string;
+ labels?: TrelloCardLabelResponse[];
+ members?: TrelloCardMemberResponse[];
+ attachments?: TrelloCardAttachmentResponse[];
+}
+
+export interface TrelloSearchResponse {
+ cards: TrelloCardResponse[];
+}
+
+export const mapTrelloCardToIssue = (card: TrelloCardResponse): TrelloIssue => {
+ const base = mapTrelloCardReduced(card);
+ return {
+ ...base,
+ };
+};
+
+export const mapTrelloCardReduced = (card: TrelloCardResponse): TrelloIssueReduced => {
+ const key =
+ card.idShort !== null && card.idShort !== undefined
+ ? card.idShort.toString()
+ : card.shortLink;
+
+ return Object.freeze({
+ id: card.shortLink,
+ idCard: card.id,
+ key,
+ shortLink: card.shortLink,
+ name: card.name,
+ summary: card.name,
+ desc: card.desc || null,
+ url: card.url,
+ due: card.due,
+ dueComplete: card.dueComplete,
+ closed: card.closed,
+ idBoard: card.idBoard,
+ idList: card.idList,
+ updated: card.dateLastActivity,
+ labels: mapTrelloLabels(card.labels),
+ members: mapTrelloMembers(card.members),
+ attachments: mapTrelloAttachments(card.attachments),
+ storyPoints: undefined,
+ });
+};
+
+export const mapTrelloSearchResults = (
+ cards: TrelloCardResponse[] = [],
+): SearchResultItem<'TRELLO'>[] => {
+ const deduped = dedupeByKey(cards, 'shortLink');
+ return deduped.map((card) => {
+ const reduced = mapTrelloCardReduced(card);
+ return {
+ title: `${reduced.key} ${reduced.summary}`.trim(),
+ issueType: 'TRELLO',
+ issueData: reduced,
+ };
+ });
+};
+
+export const mapTrelloAttachmentToAttachment = (
+ attachment: TrelloAttachment,
+): TaskAttachment => {
+ const type = mapAttachmentType(attachment.mimeType, attachment.url);
+ return {
+ id: null,
+ title: attachment.name,
+ path: attachment.url,
+ originalImgPath: attachment.url,
+ type,
+ icon: DropPasteIcons[type],
+ };
+};
+
+const mapTrelloLabels = (labels?: TrelloCardLabelResponse[]): TrelloLabel[] => {
+ if (!labels || !labels.length) {
+ return [];
+ }
+ return labels.map((label) => ({
+ id: label.id,
+ name: label.name,
+ color: label.color ?? null,
+ }));
+};
+
+const mapTrelloMembers = (members?: TrelloCardMemberResponse[]): TrelloMember[] => {
+ if (!members || !members.length) {
+ return [];
+ }
+ return members.map((member) => ({
+ id: member.id,
+ fullName: member.fullName,
+ username: member.username,
+ avatarUrl: member.avatarUrl ?? null,
+ }));
+};
+
+const mapTrelloAttachments = (
+ attachments?: TrelloCardAttachmentResponse[],
+): TrelloAttachment[] => {
+ if (!attachments || !attachments.length) {
+ return [];
+ }
+ return attachments.map((attachment) => ({
+ id: attachment.id,
+ bytes: attachment.bytes ?? null,
+ date: attachment.date,
+ edgeColor: attachment.edgeColor ?? null,
+ idMember: attachment.idMember ?? null,
+ isUpload: attachment.isUpload,
+ mimeType: attachment.mimeType ?? null,
+ name: attachment.name,
+ previews: mapTrelloAttachmentPreviews(attachment.previews),
+ url: attachment.url,
+ pos: attachment.pos,
+ }));
+};
+
+const mapTrelloAttachmentPreviews = (
+ previews?: TrelloCardAttachmentPreviewResponse[],
+): TrelloAttachmentPreview[] => {
+ if (!previews || !previews.length) {
+ return [];
+ }
+ return previews.map((preview) => ({
+ id: preview.id,
+ url: preview.url,
+ height: preview.height ?? null,
+ width: preview.width ?? null,
+ scaled: preview.scaled,
+ bytes: preview.bytes ?? null,
+ }));
+};
+
+const mapAttachmentType = (mimeType: string | null, url: string): DropPasteInputType => {
+ if (mimeType) {
+ if (mimeType.startsWith('image/')) {
+ return 'IMG';
+ }
+ if (mimeType.startsWith('video/')) {
+ return 'FILE';
+ }
+ }
+
+ const extension = url.split('.').pop()?.toLowerCase();
+ if (extension && ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(extension)) {
+ return 'IMG';
+ }
+
+ return 'LINK';
+};
diff --git a/src/app/features/issue/providers/trello/trello-issue.model.ts b/src/app/features/issue/providers/trello/trello-issue.model.ts
new file mode 100644
index 000000000..e32804597
--- /dev/null
+++ b/src/app/features/issue/providers/trello/trello-issue.model.ts
@@ -0,0 +1,65 @@
+/**
+ * trello issue model
+ * reference: https://developer.atlassian.com/cloud/trello/rest/api-group-cards/#api-group-cards
+ */
+export interface TrelloLabel {
+ id: string;
+ name: string;
+ color: string | null;
+}
+
+export interface TrelloMember {
+ id: string;
+ fullName: string;
+ username: string;
+ avatarUrl: string | null;
+}
+
+export interface TrelloAttachmentPreview {
+ id: string;
+ url: string;
+ height: number | null;
+ width: number | null;
+ scaled: boolean;
+ bytes: number | null;
+}
+
+export interface TrelloAttachment {
+ id: string;
+ bytes: number | null;
+ date: string;
+ edgeColor: string | null;
+ idMember: string | null;
+ isUpload: boolean;
+ mimeType: string | null;
+ name: string;
+ previews: TrelloAttachmentPreview[];
+ url: string;
+ pos: number;
+}
+
+export type TrelloIssueReduced = Readonly<{
+ /** Short link for the card; used as the primary identifier within the app */
+ id: string;
+ /** Full Trello card id */
+ idCard: string;
+ /** Numeric short id shown on the board */
+ key: string;
+ shortLink: string;
+ name: string;
+ summary: string;
+ desc: string | null;
+ url: string;
+ due: string | null;
+ dueComplete: boolean;
+ closed: boolean;
+ idBoard: string;
+ idList: string;
+ updated: string;
+ labels: TrelloLabel[];
+ members: TrelloMember[];
+ attachments: TrelloAttachment[];
+ storyPoints?: number | null;
+}>;
+
+export type TrelloIssue = TrelloIssueReduced;
diff --git a/src/app/features/issue/providers/trello/trello-view-components/trello_cfg/trello_additional_cfg.component.ts b/src/app/features/issue/providers/trello/trello-view-components/trello_cfg/trello_additional_cfg.component.ts
new file mode 100644
index 000000000..b48a32e0f
--- /dev/null
+++ b/src/app/features/issue/providers/trello/trello-view-components/trello_cfg/trello_additional_cfg.component.ts
@@ -0,0 +1,222 @@
+// additional configuration for trello e.g. board selection.
+
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ inject,
+ input,
+ Input,
+ OnDestroy,
+ OnInit,
+ output,
+} from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { TrelloApiService } from '../../trello-api.service';
+import { SnackService } from 'src/app/core/snack/snack.service';
+import { IssueProviderTrello } from 'src/app/features/issue/issue.model';
+import { ConfigFormSection } from 'src/app/features/config/global-config.model';
+import { MatFormField, MatLabel } from '@angular/material/form-field';
+import { MatSelect, MatOption } from '@angular/material/select';
+import { MatButton } from '@angular/material/button';
+import { AsyncPipe } from '@angular/common';
+import { BehaviorSubject, Observable, Subscription, of } from 'rxjs';
+import { catchError, map, tap, debounceTime, switchMap, startWith } from 'rxjs/operators';
+
+interface TrelloBoard {
+ id: string;
+ name: string;
+}
+
+@Component({
+ selector: 'trello-additional-cfg',
+ standalone: true,
+ imports: [
+ FormsModule,
+ ReactiveFormsModule,
+ MatFormField,
+ MatLabel,
+ MatSelect,
+ MatOption,
+ MatButton,
+ AsyncPipe,
+ ],
+
+ // dynamic template
+ // TODO: possibly need to add translation here
+ template: `
+
+ Select a Trello Board for searching issues.
+ @let isLoading = isLoading$ | async;
+
+ Load Trello Boards
+
+
+
+ @if (isLoading) {
+ No boards found (yet)...
+ }
+
+ @for (board of boards$ | async; track board.id) {
+
+ {{ board.name }}
+
+ }
+ @if ((boards$ | async)?.length === 0 && isCredentialsComplete) {
+ No boards available
+ }
+ @if (!isCredentialsComplete) {
+ Enter API key and token first
+ }
+
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TrelloAdditionalCfgComponent implements OnInit, OnDestroy {
+ // inject some of the service
+ private _trelloApiService = inject(TrelloApiService);
+ private _snackService = inject(SnackService);
+ private _cdr = inject(ChangeDetectorRef);
+
+ // inputs and outputs
+ readonly section = input>();
+ readonly modelChange = output();
+
+ // state
+ private _cfg?: IssueProviderTrello;
+ private _credentialsChanged$ = new BehaviorSubject(null);
+ private _boardsList$ = new BehaviorSubject([]);
+
+ selectedBoardId?: string | null;
+ isCredentialsComplete = false;
+
+ boards$: Observable;
+ // lets make it true at first (since we load data when user first change their board)
+ isLoading$ = new BehaviorSubject(true);
+
+ private _subs = new Subscription();
+
+ constructor() {
+ // Initialize boards$ with proper debounce and switchMap
+ this.boards$ = this._credentialsChanged$.pipe(
+ debounceTime(1000), // Wait 1 seconds after user stops typing
+ switchMap((cfg) => {
+ // Check if we have minimum required credentials (apiKey and token)
+ if (!cfg || !cfg.apiKey || !cfg.token) {
+ this.isCredentialsComplete = false;
+ this.isLoading$.next(false);
+ this._boardsList$.next([]);
+ this._cdr.markForCheck();
+ return of([]);
+ }
+
+ this.isCredentialsComplete = true;
+ this.isLoading$.next(true);
+ this._cdr.markForCheck();
+
+ // Create a temporary config with a placeholder boardId for the API call
+ const tempCfgForFetch = { ...cfg, boardId: 'temp' };
+
+ // Fetch all boards from user
+ return this._trelloApiService.getBoards$(tempCfgForFetch).pipe(
+ map((response) => {
+ // Map Trello API response to our format
+ const boards = (response || []).map((board: any) => ({
+ id: board.id,
+ name: board.name,
+ }));
+ // Store boards in BehaviorSubject so we can access them later
+ this._boardsList$.next(boards);
+ return boards;
+ }),
+ tap(() => {
+ this.isLoading$.next(false);
+ this._cdr.markForCheck();
+ }),
+ catchError((error) => {
+ this.isLoading$.next(false);
+ this._boardsList$.next([]);
+ this._cdr.markForCheck();
+ // Show error notification
+ this._snackService.open({
+ type: 'ERROR',
+ msg: 'Failed to load Trello boards. Check your API credentials.',
+ isSkipTranslate: true,
+ });
+ return of([]);
+ }),
+ );
+ }),
+ startWith([]),
+ );
+ }
+
+ @Input() set cfg(cfg: IssueProviderTrello) {
+ this._cfg = cfg;
+ this.selectedBoardId = cfg.boardId;
+ // Emit credential change to trigger debounced API call
+ // Only emit if apiKey or token is present (boardId not required for fetching boards list)
+ if (cfg.apiKey || cfg.token) {
+ this._credentialsChanged$.next(cfg);
+ }
+ }
+
+ ngOnInit(): void {
+ // Load boards if credentials already exist
+ if (this._cfg && (this._cfg.apiKey || this._cfg.token)) {
+ this._credentialsChanged$.next(this._cfg);
+ }
+ }
+
+ ngOnDestroy(): void {
+ this._subs.unsubscribe();
+ this._credentialsChanged$.complete();
+ this._boardsList$.complete();
+ }
+
+ onBoardSelect(boardId: string | null): void {
+ if (this._cfg && boardId) {
+ // Get the board name from the stored boards list
+ const selectedBoard = this._boardsList$.value.find((board) => board.id === boardId);
+
+ const updated: IssueProviderTrello = {
+ ...this._cfg,
+ boardId,
+ boardName: selectedBoard?.name || null, // Add board name
+ };
+ this._cfg = updated;
+ this.modelChange.emit(updated);
+ }
+ }
+
+ reloadBoards(): void {
+ if (!this._cfg || !this._cfg.apiKey || !this._cfg.token) {
+ this._snackService.open({
+ type: 'ERROR',
+ msg: 'Enter API key and token first.',
+ isSkipTranslate: true,
+ });
+ return;
+ }
+
+ this.isCredentialsComplete = true;
+ this.isLoading$.next(true);
+ this._credentialsChanged$.next({ ...this._cfg });
+ this._cdr.markForCheck();
+ }
+}
diff --git a/src/app/features/issue/providers/trello/trello.const.ts b/src/app/features/issue/providers/trello/trello.const.ts
new file mode 100644
index 000000000..a7cdba15b
--- /dev/null
+++ b/src/app/features/issue/providers/trello/trello.const.ts
@@ -0,0 +1,80 @@
+// Necessary fields for trello configuration. Used for building the form, alongside with several essential properties.
+
+import {
+ ConfigFormSection,
+ LimitedFormlyFieldConfig,
+} from '../../../config/global-config.model';
+import { ISSUE_PROVIDER_COMMON_FORM_FIELDS } from '../../common-issue-form-stuff.const';
+import { IssueProviderTrello } from '../../issue.model';
+import { TrelloCfg } from './trello.model';
+
+export const DEFAULT_TRELLO_CFG: TrelloCfg = {
+ isEnabled: false,
+ apiKey: null,
+ token: null,
+ boardId: null,
+};
+
+export const TRELLO_POLL_INTERVAL = 5 * 60 * 1000;
+export const TRELLO_CONFIG_FORM: LimitedFormlyFieldConfig[] = [
+ // ...CROSS_ORIGIN_WARNING,
+ // TODO: add instruction for https://developer.atlassian.com/cloud/trello/guides/rest-api/api-introduction/
+ {
+ key: 'apiKey',
+ type: 'input',
+ props: {
+ label: 'Trello API key',
+ description: 'Insert the Trello API key here',
+ type: 'text',
+ required: true,
+ },
+ },
+ {
+ key: 'token',
+ type: 'input',
+ props: {
+ label: 'Trello API token',
+ description: 'Insert the Trello API token here.',
+ type: 'password',
+ required: true,
+ },
+ validators: {
+ token: {
+ expression: (c: { value: string | undefined }) =>
+ !c.value || c.value.length >= 32,
+ message: 'Trello token is typically 32+ characters',
+ },
+ },
+ },
+ // search boards
+ {
+ type: 'collapsible',
+ props: { label: 'Advanced Config' },
+ fieldGroup: [...ISSUE_PROVIDER_COMMON_FORM_FIELDS],
+ },
+];
+
+export const TRELLO_CONFIG_FORM_SECTION: ConfigFormSection = {
+ title: 'Trello',
+ key: 'TRELLO',
+ items: TRELLO_CONFIG_FORM,
+ helpArr: [
+ {
+ h: 'Getting Started',
+ p: 'To connect Super Productivity with Trello, you need to generate an API key and token from your Trello account. This allows Super Productivity to access your boards and cards.',
+ },
+ {
+ h: 'How to Get API Key & Token',
+ p: 'Visit https://trello.com/power-ups/admin and create a new app. Fills in necessary detail excluding icon. After creating the app, click on "Generate a new API key". This will allow you to view your API key. Token can be generated upon clicking Token hyperlink in the API key section and you can copy it after you have done reviewing your application. You will need both the key and token to configure the integration. See https://developer.atlassian.com/cloud/trello/guides/rest-api/api-introduction/ for more detail if you are unsure of what to do.',
+ p2: 'The token grants Super Productivity permission to read your Trello data. You can revoke it at any time from the Trello security page.',
+ },
+ {
+ h: 'Selecting Your Board',
+ p: 'After entering your API key and token, click "Load Trello Boards" and you will be able to select the Trello board you want to work with. Only cards from the selected board will be accessible in Super Productivity.',
+ },
+ {
+ h: 'Features',
+ p: 'Once configured, you can search for Trello cards, add them as tasks, view card details including attachments, and keep your task data in sync with Trello.',
+ },
+ ],
+};
diff --git a/src/app/features/issue/providers/trello/trello.model.ts b/src/app/features/issue/providers/trello/trello.model.ts
new file mode 100644
index 000000000..8e66142c4
--- /dev/null
+++ b/src/app/features/issue/providers/trello/trello.model.ts
@@ -0,0 +1,14 @@
+/**
+ * Configuration model for the Trello integration.
+ */
+
+import { BaseIssueProviderCfg } from '../../issue.model';
+export interface TrelloCfg extends BaseIssueProviderCfg {
+ isEnabled: boolean;
+ apiKey: string | null;
+ token: string | null;
+ boardId: string | null;
+
+ // experimental: board - add board name
+ boardName?: string | null;
+}
diff --git a/src/assets/icons/trello.svg b/src/assets/icons/trello.svg
new file mode 100644
index 000000000..f8f4ec1cc
--- /dev/null
+++ b/src/assets/icons/trello.svg
@@ -0,0 +1,5 @@
+
+
+
+
+