Merge pull request #5299 from Ronaldo93/feat/trello-integration

Feat/trello integration
This commit is contained in:
Johannes Millan 2025-11-25 19:23:00 +01:00 committed by GitHub
commit 8c92ffa70f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1442 additions and 9 deletions

View file

@ -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.

View file

@ -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'],

View file

@ -16,7 +16,7 @@
[actionAdvice]="'Check your config!'"
></error-card>
} @else if (!agendaItems()?.length) {
<div class="empty">No items found (already added are not shown)</div>
<div class="empty">No items found.</div>
} @else {
<div class="agenda">
@for (day of agendaItems(); track day.dayStr) {

View file

@ -33,6 +33,13 @@
<mat-icon svgIcon="jira"></mat-icon>
<span>Jira</span>
</button>
<button
mat-raised-button
(click)="openSetupDialog('TRELLO')"
>
<mat-icon svgIcon="trello"></mat-icon>
<span>Trello</span>
</button>
<button
mat-raised-button
(click)="openSetupDialog('GITHUB')"

View file

@ -117,6 +117,14 @@
></open-project-additional-cfg>
<!-- -->
}
@case ('TRELLO') {
<trello-additional-cfg
[section]="configFormSection"
[cfg]="model"
(modelChange)="customCfgCmpSave($event)"
></trello-additional-cfg>
<!-- -->
}
}
}
</mat-dialog-content>

View file

@ -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<IssueProvider>): 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;
}

View file

@ -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<IssueProviderKey, IssueContentConfig<
GITEA: GITEA_ISSUE_CONTENT_CONFIG,
REDMINE: REDMINE_ISSUE_CONTENT_CONFIG,
OPEN_PROJECT: OPEN_PROJECT_ISSUE_CONTENT_CONFIG,
TRELLO: TRELLO_ISSUE_CONTENT_CONFIG,
ICAL: {
issueType: 'ICAL',
fields: [],

View file

@ -29,6 +29,10 @@ import {
CALENDAR_FORM_CFG_NEW,
DEFAULT_CALENDAR_CFG,
} from './providers/calendar/calendar.const';
import {
DEFAULT_TRELLO_CFG,
TRELLO_CONFIG_FORM_SECTION,
} from './providers/trello/trello.const';
export const DELAY_BEFORE_ISSUE_POLLING = 8000;
@ -40,6 +44,7 @@ export const OPEN_PROJECT_TYPE: IssueProviderKey = 'OPEN_PROJECT';
export const GITEA_TYPE: IssueProviderKey = 'GITEA';
export const REDMINE_TYPE: IssueProviderKey = 'REDMINE';
export const ICAL_TYPE: IssueProviderKey = 'ICAL';
export const TRELLO_TYPE: IssueProviderKey = 'TRELLO';
export const ISSUE_PROVIDER_TYPES: IssueProviderKey[] = [
GITLAB_TYPE,
@ -49,6 +54,7 @@ export const ISSUE_PROVIDER_TYPES: IssueProviderKey[] = [
ICAL_TYPE,
OPEN_PROJECT_TYPE,
GITEA_TYPE,
TRELLO_TYPE,
REDMINE_TYPE,
] as const;
@ -60,6 +66,7 @@ export const ISSUE_PROVIDER_ICON_MAP = {
[ICAL_TYPE]: 'calendar',
[OPEN_PROJECT_TYPE]: 'open_project',
[GITEA_TYPE]: 'gitea',
[TRELLO_TYPE]: 'trello',
[REDMINE_TYPE]: 'redmine',
} as const;
@ -71,6 +78,7 @@ export const ISSUE_PROVIDER_HUMANIZED = {
[ICAL_TYPE]: 'Calendar',
[OPEN_PROJECT_TYPE]: 'OpenProject',
[GITEA_TYPE]: 'Gitea',
[TRELLO_TYPE]: 'Trello',
[REDMINE_TYPE]: 'Redmine',
} as const;
@ -82,6 +90,7 @@ export const DEFAULT_ISSUE_PROVIDER_CFGS = {
[ICAL_TYPE]: DEFAULT_CALENDAR_CFG,
[OPEN_PROJECT_TYPE]: DEFAULT_OPEN_PROJECT_CFG,
[GITEA_TYPE]: DEFAULT_GITEA_CFG,
[TRELLO_TYPE]: DEFAULT_TRELLO_CFG,
[REDMINE_TYPE]: DEFAULT_REDMINE_CFG,
} as const;
@ -93,6 +102,7 @@ export const ISSUE_PROVIDER_FORM_CFGS_MAP = {
[ICAL_TYPE]: CALENDAR_FORM_CFG_NEW as any,
[OPEN_PROJECT_TYPE]: OPEN_PROJECT_CONFIG_FORM_SECTION,
[GITEA_TYPE]: GITEA_CONFIG_FORM_SECTION,
[TRELLO_TYPE]: TRELLO_CONFIG_FORM_SECTION,
[REDMINE_TYPE]: REDMINE_CONFIG_FORM_SECTION,
} as const;
@ -116,6 +126,7 @@ export const ISSUE_STR_MAP: { [key: string]: { ISSUE_STR: string; ISSUES_STR: st
ISSUES_STR: T.F.OPEN_PROJECT.ISSUE_STRINGS.ISSUES_STR,
},
[GITEA_TYPE]: DEFAULT_ISSUE_STRS,
[TRELLO_TYPE]: DEFAULT_ISSUE_STRS,
[REDMINE_TYPE]: DEFAULT_ISSUE_STRS,
} as const;

View file

@ -15,6 +15,8 @@ import { GiteaCfg } from './providers/gitea/gitea.model';
import { GiteaIssue } from './providers/gitea/gitea-issue.model';
import { RedmineCfg } from './providers/redmine/redmine.model';
import { RedmineIssue } from './providers/redmine/redmine-issue.model';
import { TrelloCfg } from './providers/trello/trello.model';
import { TrelloIssue, TrelloIssueReduced } from './providers/trello/trello-issue.model';
import { EntityState } from '@ngrx/entity';
import {
CalendarProviderCfg,
@ -26,6 +28,7 @@ export interface BaseIssueProviderCfg {
isEnabled: boolean;
}
// Trello integration is available alongside other providers
export type IssueProviderKey =
| 'JIRA'
| 'GITHUB'
@ -34,6 +37,7 @@ export type IssueProviderKey =
| 'ICAL'
| 'OPEN_PROJECT'
| 'GITEA'
| 'TRELLO'
| 'REDMINE';
export type IssueIntegrationCfg =
@ -44,6 +48,7 @@ export type IssueIntegrationCfg =
| CalendarProviderCfg
| OpenProjectCfg
| GiteaCfg
| TrelloCfg
| RedmineCfg;
export enum IssueLocalState {
@ -60,6 +65,7 @@ export interface IssueIntegrationCfgs {
CALDAV?: CaldavCfg;
CALENDAR?: CalendarProviderCfg;
OPEN_PROJECT?: OpenProjectCfg;
TRELLO?: TrelloCfg;
GITEA?: GiteaCfg;
REDMINE?: RedmineCfg;
}
@ -72,7 +78,8 @@ export type IssueData =
| ICalIssue
| OpenProjectWorkPackage
| GiteaIssue
| RedmineIssue;
| RedmineIssue
| TrelloIssue;
export type IssueDataReduced =
| GithubIssueReduced
@ -82,7 +89,8 @@ export type IssueDataReduced =
| CaldavIssueReduced
| ICalIssueReduced
| GiteaIssue
| RedmineIssue;
| RedmineIssue
| TrelloIssueReduced;
export type IssueDataReducedMap = {
[K in IssueProviderKey]: K extends 'JIRA'
@ -99,11 +107,15 @@ export type IssueDataReducedMap = {
? OpenProjectWorkPackageReduced
: K extends 'GITEA'
? GiteaIssue
: K extends 'REDMINE'
? RedmineIssue
: never;
: K extends 'TRELLO'
? TrelloIssueReduced
: K extends 'REDMINE'
? RedmineIssue
: never;
};
// TODO: add issue model to the IssueDataReducedMap
export interface SearchResultItem<
T extends keyof IssueDataReducedMap = keyof IssueDataReducedMap,
> {
@ -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 IssueProviderKey> = T extends 'JIRA'
? IssueProviderJira
@ -198,4 +215,6 @@ export type IssueProviderTypeMap<T extends IssueProviderKey> = T extends 'JIRA'
? IssueProviderCaldav
: T extends 'ICAL'
? IssueProviderCalendar
: never;
: T extends 'TRELLO'
? IssueProviderTrello
: never;

View file

@ -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: {

View file

@ -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;
};

View file

@ -0,0 +1,4 @@
import { TrelloCfg } from './trello.model';
export const isTrelloEnabled = (cfg: TrelloCfg): boolean =>
!!cfg && cfg.isEnabled && !!cfg.apiKey && !!cfg.token && !!cfg.boardId;

View file

@ -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<SnackService>;
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<SnackService>;
});
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: [] });
});
});
});

View file

@ -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<boolean> {
return this._request$<{ id: string }>(`/boards/${cfg.boardId}`, cfg, {
fields: 'id',
}).pipe(map(() => true));
}
issuePicker$(
searchTerm: string,
cfg: TrelloCfg,
maxResults: number = 25,
): Observable<SearchResultItem<'TRELLO'>[]> {
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$<TrelloSearchResponse>('/search', cfg, params).pipe(
map((res) => mapTrelloSearchResults(res.cards || [])),
);
}
findAutoImportIssues$(
cfg: TrelloCfg,
maxResults: number = 200,
): Observable<TrelloIssueReduced[]> {
return this._fetchBoardCards$(cfg, maxResults).pipe(
map((cards) => cards.map((card) => mapTrelloCardReduced(card))),
);
}
// list all projects from user
getBoards$(cfg: TrelloCfg): Observable<any> {
return this._request$('/members/me/boards', cfg, {
filter: 'open',
fields: 'name,id',
});
}
getIssueById$(issueId: string, cfg: TrelloCfg): Observable<TrelloIssue> {
return this._request$<TrelloCardResponse>(`/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<TrelloIssueReduced> {
return this._request$<TrelloCardResponse>(`/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<string> {
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<TrelloCardResponse[]> {
const limit = Math.min(Math.max(maxResults, 1), 500);
return this._request$<TrelloCardResponse[]>(`/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$<T>(
path: string,
cfg: TrelloCfg,
params?: Record<string, unknown>,
): Observable<T> {
this._checkSettings(cfg);
const httpParams = this._createParams(cfg, params);
const headers = new HttpHeaders().set('Authorization', `Bearer ${cfg.token}`);
return this._http
.get<T>(`${BASE_URL}${path}`, {
params: httpParams,
headers,
})
.pipe(catchError((err) => this._handleError$(err)));
}
private _createParams(cfg: TrelloCfg, params?: Record<string, unknown>): 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<never> {
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,
});
}
}

View file

@ -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<boolean> {
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<TrelloIssue> {
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<SearchResultItem[]> {
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<Task>;
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<Task>; 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<Task> & { 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<string> {
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<TrelloIssueReduced[]> {
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<IssueProviderTrello> {
return this._issueProviderService.getCfgOnce$(issueProviderId, 'TRELLO');
}
}

View file

@ -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<TrelloIssue> = {
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,
};

View file

@ -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';
};

View file

@ -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;

View file

@ -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: `
<!--section for selecting board-->
<p style="margin-top: 12px;">Select a Trello Board for searching issues.</p>
@let isLoading = isLoading$ | async;
<button
mat-stroked-button
color="primary"
type="button"
style="margin-bottom: 12px;"
(click)="reloadBoards()"
[disabled]="isLoading"
>
Load Trello Boards
</button>
<mat-form-field
appearance="outline"
style="width: 100%;"
>
<!--label for showing that this is the board-->
@if (isLoading) {
<mat-label>No boards found (yet)...</mat-label>
}
<mat-select
[(ngModel)]="selectedBoardId"
(ngModelChange)="onBoardSelect($event)"
[disabled]="!isCredentialsComplete"
>
@for (board of boards$ | async; track board.id) {
<mat-option [value]="board.id">
{{ board.name }}
</mat-option>
}
@if ((boards$ | async)?.length === 0 && isCredentialsComplete) {
<mat-option disabled> No boards available </mat-option>
}
@if (!isCredentialsComplete) {
<mat-option disabled> Enter API key and token first </mat-option>
}
</mat-select>
</mat-form-field>
`,
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<ConfigFormSection<IssueProviderTrello>>();
readonly modelChange = output<IssueProviderTrello>();
// state
private _cfg?: IssueProviderTrello;
private _credentialsChanged$ = new BehaviorSubject<IssueProviderTrello | null>(null);
private _boardsList$ = new BehaviorSubject<TrelloBoard[]>([]);
selectedBoardId?: string | null;
isCredentialsComplete = false;
boards$: Observable<TrelloBoard[]>;
// lets make it true at first (since we load data when user first change their board)
isLoading$ = new BehaviorSubject<boolean>(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<TrelloBoard[]>([]);
}
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<TrelloBoard[]>([]);
}),
);
}),
startWith<TrelloBoard[]>([]),
);
}
@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();
}
}

View file

@ -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<IssueProviderTrello>[] = [
// ...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<IssueProviderTrello> = {
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.',
},
],
};

View file

@ -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;
}

View file

@ -0,0 +1,5 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="19" height="19" rx="2.5" ry="2.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="6" y="6" width="5.5" height="12" rx="1.2" ry="1.2" fill="currentColor" opacity="0.85" />
<rect x="12.5" y="6" width="5.5" height="8" rx="1.2" ry="1.2" fill="currentColor" opacity="0.55" />
</svg>

After

Width:  |  Height:  |  Size: 391 B