Merge pull request #8519 from archit-goyal/codex/caldav-empty-dropdown-guidance

Clarify empty CalDAV option loads
This commit is contained in:
Johannes Millan 2026-07-13 12:46:55 +02:00 committed by GitHub
commit a9ed0b2df2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 93 additions and 17 deletions

View file

@ -26,6 +26,8 @@ The **issue provider panel** (Integrations & Calendars) is a side panel that lis
If the integration “doesnt 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

View file

@ -183,6 +183,21 @@
{{ T.F.ISSUE.DIALOG.LOAD_OPTIONS_FAILED | translate }}
</span>
}
@case ('empty') {
<button
mat-stroked-button
type="button"
color="primary"
(click)="loadDynamicOptions()"
>
<mat-icon>refresh</mat-icon>
{{ T.F.ISSUE.DIALOG.RELOAD_OPTIONS | translate }}
</button>
<span class="empty-status">
<mat-icon class="empty-status-icon">info</mat-icon>
{{ T.F.ISSUE.DIALOG.NO_OPTIONS_FOUND_HELP | translate }}
</span>
}
@default {
<button
mat-stroked-button

View file

@ -71,6 +71,7 @@
}
.connected-status-icon,
.empty-status-icon,
.error-status-icon {
vertical-align: middle;
font-size: 18px;
@ -78,6 +79,10 @@
height: 18px;
}
.empty-status {
margin-left: var(--s);
}
.error-status {
margin-left: var(--s);
color: var(--palette-warn);

View file

@ -74,6 +74,8 @@ import { ChipListInputComponent } from '../../../ui/chip-list-input/chip-list-in
import { unique } from '../../../util/unique';
import { mergeIssueProviderModelUpdates } from './issue-provider-model-merge.util';
type OptionsLoadState = 'idle' | 'loading' | 'loaded' | 'empty' | 'failed';
@Component({
selector: 'dialog-edit-issue-provider',
imports: [
@ -113,7 +115,7 @@ export class DialogEditIssueProviderComponent {
isConnectionWorks = signal(false);
isOAuthConnected = signal(false);
isOAuthConnecting = signal(false);
optionsLoadState = signal<'idle' | 'loading' | 'loaded' | 'failed'>('idle');
optionsLoadState = signal<OptionsLoadState>('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<void> {
@ -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<boolean> {
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<string, unknown>)['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<string, unknown>,
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<string, unknown>)['pluginConfig'];
this.model = currentPluginCfg
@ -471,8 +476,46 @@ export class DialogEditIssueProviderComponent {
pluginConfig: { ...(currentPluginCfg as Record<string, unknown>) },
}
: { ...this.model };
return { isSuccess: !anyFailed, hasOptions };
}
private async _loadAndSetDynamicOptionsState(): Promise<void> {
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<void> {
this.optionsLoadState.set('loading');
const success = await this._loadDynamicOptions();
this.optionsLoadState.set(success ? 'loaded' : 'failed');
await this._loadAndSetDynamicOptionsState();
}
}

View file

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

View file

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