diff --git a/docs/wiki/2.07-Manage-Task-Integrations.md b/docs/wiki/2.07-Manage-Task-Integrations.md
index 3f1a0080f0..0b6236f43f 100755
--- a/docs/wiki/2.07-Manage-Task-Integrations.md
+++ b/docs/wiki/2.07-Manage-Task-Integrations.md
@@ -26,6 +26,8 @@ The **issue provider panel** (Integrations & Calendars) is a side panel that lis
If the integration “doesn’t work,” check that you used the right link and the right provider (iCal vs CalDAV). For more detail see [[4.24-Integrations]] and [[3.07-Issue-Integration-Comparison]].
+For **CalDAV Events** calendar lists, enter the server URL, username, and password first, then click **Load Options** before choosing calendars. The calendar dropdowns stay disabled until options are loaded. Both the server root (for example, `https://host/remote.php/dav` for Nextcloud) and a specific calendar-home URL are supported. If no calendars are found, check the credentials and confirm that the account has at least one event calendar available.
+
After saving, a new **tab** appears in the panel for that provider. You can have multiple instances of the same type (e.g. two Jira configurations) and reorder tabs by dragging.
### Azure DevOps and Self-Hosted TFS URLs
diff --git a/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.html b/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.html
index b7416dfb9e..135ee6c989 100644
--- a/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.html
+++ b/src/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.html
@@ -183,6 +183,21 @@
{{ T.F.ISSUE.DIALOG.LOAD_OPTIONS_FAILED | translate }}
}
+ @case ('empty') {
+
+ refresh
+ {{ T.F.ISSUE.DIALOG.RELOAD_OPTIONS | translate }}
+
+
+ info
+ {{ T.F.ISSUE.DIALOG.NO_OPTIONS_FOUND_HELP | translate }}
+
+ }
@default {
('idle');
+ optionsLoadState = signal('idle');
form = new FormGroup({});
showLoadOptionsButton = false;
@@ -293,7 +295,7 @@ export class DialogEditIssueProviderComponent {
msg: T.F.ISSUE.S.CONNECTION_SUCCESS,
});
// Reload dynamic options (e.g. calendar lists) after successful connection
- await this._loadDynamicOptions();
+ await this._loadAndSetDynamicOptionsState();
} else {
this._snackService.open({
type: 'ERROR',
@@ -384,7 +386,7 @@ export class DialogEditIssueProviderComponent {
});
// _loadDynamicOptions surfaces its own per-field error snacks; failures
// here must not be reported as OAuth failures (the connection succeeded).
- await this._loadDynamicOptions();
+ await this._loadAndSetDynamicOptionsState();
}
async disconnectOAuth(): Promise {
@@ -410,17 +412,20 @@ export class DialogEditIssueProviderComponent {
}
/**
- * @returns true if all fields loaded successfully, false if any failed
+ * @returns whether all fields loaded successfully and at least one field has options
*/
- private async _loadDynamicOptions(): Promise {
+ private async _loadDynamicOptions(): Promise<{
+ isSuccess: boolean;
+ hasOptions: boolean;
+ }> {
const provider = this._pluginRegistry.getProvider(this.issueProviderKey);
if (!provider) {
- return false;
+ return { isSuccess: false, hasOptions: false };
}
const configFields = this._pluginRegistry.getConfigFields(this.issueProviderKey);
const dynamicFields = configFields.filter((f) => typeof f.loadOptions === 'function');
if (!dynamicFields.length) {
- return true;
+ return { isSuccess: true, hasOptions: true };
}
const pluginConfig = (this.model as Record)['pluginConfig'] ?? {};
@@ -430,20 +435,22 @@ export class DialogEditIssueProviderComponent {
);
let anyFailed = false;
+ let hasOptions = false;
for (const field of dynamicFields) {
try {
const options = await field.loadOptions!(
pluginConfig as Record,
http,
);
+ if (options.length > 0) {
+ hasOptions = true;
+ }
const formlyField = this._findFormlyField(
this.fields as FormlyFieldConfig[],
'pluginConfig.' + field.key,
);
if (formlyField?.templateOptions) {
formlyField.templateOptions.options = options;
- } else if (formlyField?.props) {
- formlyField.props.options = options;
}
} catch (e) {
anyFailed = true;
@@ -461,8 +468,6 @@ export class DialogEditIssueProviderComponent {
}
// Trigger formly refresh — reassign both fields and model so mat-select
// re-evaluates display labels for already-selected values.
- // Use detectChanges() instead of markForCheck() because plugin bridge
- // async calls may resolve outside Zone.js (e.g. Electron IPC).
this.fields = [...this.fields];
const currentPluginCfg = (this.model as Record)['pluginConfig'];
this.model = currentPluginCfg
@@ -471,8 +476,46 @@ export class DialogEditIssueProviderComponent {
pluginConfig: { ...(currentPluginCfg as Record) },
}
: { ...this.model };
+ return { isSuccess: !anyFailed, hasOptions };
+ }
+
+ private async _loadAndSetDynamicOptionsState(): Promise {
+ this._setOptionsLoadState('loading');
+ const result = await this._loadDynamicOptions();
+ this._setOptionsLoadState(
+ result.isSuccess ? (result.hasOptions ? 'loaded' : 'empty') : 'failed',
+ );
+ }
+
+ private _setOptionsLoadState(state: OptionsLoadState): void {
+ this.optionsLoadState.set(state);
+ this._refreshDynamicOptionFieldStates(state);
+ // Use detectChanges() instead of markForCheck() because plugin bridge
+ // async calls may resolve outside Zone.js (e.g. Electron IPC).
this._cdr.detectChanges();
- return !anyFailed;
+ }
+
+ private _refreshDynamicOptionFieldStates(state: OptionsLoadState): void {
+ const configFields = this._pluginRegistry.getConfigFields(this.issueProviderKey);
+ const dynamicFields = configFields.filter((f) => typeof f.loadOptions === 'function');
+ for (const field of dynamicFields) {
+ const formlyField = this._findFormlyField(
+ this.fields as FormlyFieldConfig[],
+ 'pluginConfig.' + field.key,
+ );
+ if (!formlyField) {
+ continue;
+ }
+ const templateOptions = formlyField.templateOptions;
+ if (!templateOptions) {
+ continue;
+ }
+ const options = templateOptions.options ?? [];
+ const hasOptions = Array.isArray(options) && options.length > 0;
+ const isDisabled = state === 'loading' || state === 'empty' || !hasOptions;
+
+ templateOptions.disabled = isDisabled;
+ }
}
private _findFormlyField(
@@ -626,6 +669,7 @@ export class DialogEditIssueProviderComponent {
pattern?: string;
options?: { value: string; label: string }[];
showIf?: string;
+ loadOptions?: unknown;
}): unknown {
if (f.type === 'link') {
return {
@@ -656,7 +700,15 @@ export class DialogEditIssueProviderComponent {
...(f.description ? { description: f.description } : {}),
...(f.type === 'password' ? { type: 'password' } : {}),
...(f.type === 'select' || f.type === 'multiSelect'
- ? { options: f.options }
+ ? {
+ options: f.options,
+ ...(f.loadOptions
+ ? {
+ disabled: true,
+ placeholder: T.F.ISSUE.DIALOG.LOAD_OPTIONS_FIRST,
+ }
+ : {}),
+ }
: {}),
...(f.type === 'multiSelect' ? { multiple: true } : {}),
...(f.pattern ? { pattern: f.pattern } : {}),
@@ -762,7 +814,7 @@ export class DialogEditIssueProviderComponent {
);
this.isOAuthConnected.set(hasTokens);
if (hasTokens) {
- await this._loadDynamicOptions();
+ await this._loadAndSetDynamicOptionsState();
} else {
// For non-OAuth plugins (e.g. CalDAV with Basic Auth), show a "Load Calendars"
// button and attempt to load dynamic options if credentials are already saved.
@@ -781,9 +833,7 @@ export class DialogEditIssueProviderComponent {
}
async loadDynamicOptions(): Promise {
- this.optionsLoadState.set('loading');
- const success = await this._loadDynamicOptions();
- this.optionsLoadState.set(success ? 'loaded' : 'failed');
+ await this._loadAndSetDynamicOptionsState();
}
}
diff --git a/src/app/t.const.ts b/src/app/t.const.ts
index 37a8fa0b94..05cb3c1363 100644
--- a/src/app/t.const.ts
+++ b/src/app/t.const.ts
@@ -571,7 +571,9 @@ const T = {
EDIT_TITLE: 'F.ISSUE.DIALOG.EDIT_TITLE',
LOAD_OPTIONS: 'F.ISSUE.DIALOG.LOAD_OPTIONS',
LOAD_OPTIONS_FAILED: 'F.ISSUE.DIALOG.LOAD_OPTIONS_FAILED',
+ LOAD_OPTIONS_FIRST: 'F.ISSUE.DIALOG.LOAD_OPTIONS_FIRST',
LOADING_OPTIONS: 'F.ISSUE.DIALOG.LOADING_OPTIONS',
+ NO_OPTIONS_FOUND_HELP: 'F.ISSUE.DIALOG.NO_OPTIONS_FOUND_HELP',
OAUTH_UNAVAILABLE_WEB: 'F.ISSUE.DIALOG.OAUTH_UNAVAILABLE_WEB',
OPTIONS_LOADED: 'F.ISSUE.DIALOG.OPTIONS_LOADED',
RELOAD_OPTIONS: 'F.ISSUE.DIALOG.RELOAD_OPTIONS',
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 6658216b50..b46b42b84c 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -564,7 +564,9 @@
"EDIT_TITLE": "Edit {{title}} Config",
"LOAD_OPTIONS": "Load Options",
"LOAD_OPTIONS_FAILED": "Failed to load options",
+ "LOAD_OPTIONS_FIRST": "Load options first",
"LOADING_OPTIONS": "Loading options...",
+ "NO_OPTIONS_FOUND_HELP": "No options found. Check the connection details and reload options.",
"OAUTH_UNAVAILABLE_WEB": "Available in the desktop and mobile apps. Synced settings stay saved, but live data cannot be loaded in the web app.",
"OPTIONS_LOADED": "Options loaded",
"RELOAD_OPTIONS": "Reload Options",