mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
Merge pull request #8519 from archit-goyal/codex/caldav-empty-dropdown-guidance
Clarify empty CalDAV option loads
This commit is contained in:
commit
a9ed0b2df2
6 changed files with 93 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue