feat(providers): replace provider creation modal with a guided multi-step setup flow

This commit is contained in:
2026-04-11 20:04:56 +00:00
parent 9ea57cd659
commit 80f710f6d8
4 changed files with 278 additions and 98 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026-04-11 - 1.21.0 - feat(providers)
replace provider creation modal with a guided multi-step setup flow
- Adds a stepper-based provider creation flow with provider type selection, connection, credentials, advanced settings, and review steps.
- Applies built-in templates for Sipgate and O2/Alice from the selected provider type instead of separate add actions.
- Adds a final review step with generated provider ID preview and duplicate ID collision handling before saving.
## 2026-04-11 - 1.20.5 - fix(readme) ## 2026-04-11 - 1.20.5 - fix(readme)
improve architecture and call flow documentation with Mermaid diagrams improve architecture and call flow documentation with Mermaid diagrams

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.20.5', version: '1.21.0',
description: 'undefined' description: 'undefined'
} }

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: 'siprouter', name: 'siprouter',
version: '1.20.5', version: '1.21.0',
description: 'undefined' description: 'undefined'
} }

View File

@@ -164,173 +164,346 @@ export class SipproxyViewProviders extends DeesElement {
iconName: 'lucide:plus', iconName: 'lucide:plus',
type: ['header'] as any, type: ['header'] as any,
actionFunc: async () => { actionFunc: async () => {
await this.openAddModal(); await this.openAddStepper();
},
},
{
name: 'Add Sipgate',
iconName: 'lucide:phone',
type: ['header'] as any,
actionFunc: async () => {
await this.openAddModal(PROVIDER_TEMPLATES.sipgate, 'Sipgate');
},
},
{
name: 'Add O2/Alice',
iconName: 'lucide:phone',
type: ['header'] as any,
actionFunc: async () => {
await this.openAddModal(PROVIDER_TEMPLATES.o2, 'O2/Alice');
}, },
}, },
]; ];
} }
// ---- add provider modal -------------------------------------------------- // ---- add provider stepper ------------------------------------------------
private async openAddModal( private async openAddStepper() {
template?: typeof PROVIDER_TEMPLATES.sipgate, const { DeesStepper } = await import('@design.estate/dees-catalog');
templateName?: string, type TDeesStepper = InstanceType<typeof DeesStepper>;
) { // IStep / menuOptions types: we keep content typing loose (`any[]`) to
const { DeesModal } = await import('@design.estate/dees-catalog'); // avoid having to import tsclass IMenuItem just for one parameter annotation.
const formData = { type TProviderType = 'Custom' | 'Sipgate' | 'O2/Alice';
displayName: templateName || '', interface IAccumulator {
domain: template?.domain || '', providerType: TProviderType;
outboundProxyAddress: template?.outboundProxy?.address || '', displayName: string;
outboundProxyPort: String(template?.outboundProxy?.port ?? 5060), domain: string;
outboundProxyAddress: string;
outboundProxyPort: string;
username: string;
password: string;
// Advanced — exposed in step 4
registerIntervalSec: string;
codecs: string;
earlyMediaSilence: boolean;
}
const accumulator: IAccumulator = {
providerType: 'Custom',
displayName: '',
domain: '',
outboundProxyAddress: '',
outboundProxyPort: '5060',
username: '', username: '',
password: '', password: '',
registerIntervalSec: String(template?.registerIntervalSec ?? 300), registerIntervalSec: '300',
codecs: template?.codecs ? template.codecs.join(', ') : '9, 0, 8, 101', codecs: '9, 0, 8, 101',
earlyMediaSilence: template?.quirks?.earlyMediaSilence ?? false, earlyMediaSilence: false,
}; };
const heading = template // Snapshot the currently-selected step's form (if any) into accumulator.
? `Add ${templateName} Provider` const snapshotActiveForm = async (stepper: TDeesStepper) => {
: 'Add Provider'; const form = stepper.activeForm;
if (!form) return;
const data: Record<string, any> = await form.collectFormData();
Object.assign(accumulator, data);
};
await DeesModal.createAndShow({ // Overwrite template-owned fields. Keep user-owned fields (username,
heading, // password) untouched. displayName is replaced only when empty or still
width: 'small', // holds a branded auto-fill.
showCloseButton: true, const applyTemplate = (type: TProviderType) => {
const tpl =
type === 'Sipgate' ? PROVIDER_TEMPLATES.sipgate
: type === 'O2/Alice' ? PROVIDER_TEMPLATES.o2
: null;
if (!tpl) return;
accumulator.domain = tpl.domain;
accumulator.outboundProxyAddress = tpl.outboundProxy.address;
accumulator.outboundProxyPort = String(tpl.outboundProxy.port);
accumulator.registerIntervalSec = String(tpl.registerIntervalSec);
accumulator.codecs = tpl.codecs.join(', ');
accumulator.earlyMediaSilence = tpl.quirks.earlyMediaSilence;
if (
!accumulator.displayName ||
accumulator.displayName === 'Sipgate' ||
accumulator.displayName === 'O2/Alice'
) {
accumulator.displayName = type;
}
};
// --- Step builders (called after step 1 so accumulator is populated) ---
const buildConnectionStep = (): any => ({
title: 'Connection',
content: html` content: html`
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;"> <dees-form>
<dees-input-text <dees-input-text
.key=${'displayName'} .key=${'displayName'}
.label=${'Display Name'} .label=${'Display Name'}
.value=${formData.displayName} .value=${accumulator.displayName}
@input=${(e: Event) => { formData.displayName = (e.target as any).value; }} .required=${true}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'domain'} .key=${'domain'}
.label=${'Domain'} .label=${'Domain'}
.value=${formData.domain} .value=${accumulator.domain}
@input=${(e: Event) => { formData.domain = (e.target as any).value; }} .required=${true}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'outboundProxyAddress'} .key=${'outboundProxyAddress'}
.label=${'Outbound Proxy Address'} .label=${'Outbound Proxy Address'}
.value=${formData.outboundProxyAddress} .value=${accumulator.outboundProxyAddress}
@input=${(e: Event) => { formData.outboundProxyAddress = (e.target as any).value; }}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'outboundProxyPort'} .key=${'outboundProxyPort'}
.label=${'Outbound Proxy Port'} .label=${'Outbound Proxy Port'}
.value=${formData.outboundProxyPort} .value=${accumulator.outboundProxyPort}
@input=${(e: Event) => { formData.outboundProxyPort = (e.target as any).value; }}
></dees-input-text> ></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
await snapshotActiveForm(stepper);
stepper.goNext();
},
},
],
});
const buildCredentialsStep = (): any => ({
title: 'Credentials',
content: html`
<dees-form>
<dees-input-text <dees-input-text
.key=${'username'} .key=${'username'}
.label=${'Username / Auth ID'} .label=${'Username / Auth ID'}
.value=${formData.username} .value=${accumulator.username}
@input=${(e: Event) => { formData.username = (e.target as any).value; }} .required=${true}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'password'} .key=${'password'}
.label=${'Password'} .label=${'Password'}
.isPasswordBool=${true} .isPasswordBool=${true}
.value=${formData.password} .value=${accumulator.password}
@input=${(e: Event) => { formData.password = (e.target as any).value; }} .required=${true}
></dees-input-text> ></dees-input-text>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
await snapshotActiveForm(stepper);
stepper.goNext();
},
},
],
});
const buildAdvancedStep = (): any => ({
title: 'Advanced',
content: html`
<dees-form>
<dees-input-text <dees-input-text
.key=${'registerIntervalSec'} .key=${'registerIntervalSec'}
.label=${'Register Interval (sec)'} .label=${'Register Interval (sec)'}
.value=${formData.registerIntervalSec} .value=${accumulator.registerIntervalSec}
@input=${(e: Event) => { formData.registerIntervalSec = (e.target as any).value; }}
></dees-input-text> ></dees-input-text>
<dees-input-text <dees-input-text
.key=${'codecs'} .key=${'codecs'}
.label=${'Codecs (comma-separated payload types)'} .label=${'Codecs (comma-separated payload types)'}
.value=${formData.codecs} .value=${accumulator.codecs}
@input=${(e: Event) => { formData.codecs = (e.target as any).value; }}
></dees-input-text> ></dees-input-text>
<dees-input-checkbox <dees-input-checkbox
.key=${'earlyMediaSilence'} .key=${'earlyMediaSilence'}
.label=${'Early Media Silence (quirk)'} .label=${'Early Media Silence (quirk)'}
.value=${formData.earlyMediaSilence} .value=${accumulator.earlyMediaSilence}
@newValue=${(e: CustomEvent) => { formData.earlyMediaSilence = e.detail; }}
></dees-input-checkbox> ></dees-input-checkbox>
</div> </dees-form>
`, `,
menuOptions: [ menuOptions: [
{ {
name: 'Cancel', name: 'Continue',
iconName: 'lucide:x', iconName: 'lucide:arrow-right',
action: async (modalRef: any) => { action: async (stepper: TDeesStepper) => {
modalRef.destroy(); await snapshotActiveForm(stepper);
// Rebuild the review step so its rendering reflects the latest
// accumulator values (the review step captures values at build time).
stepper.steps = [...stepper.steps.slice(0, 4), buildReviewStep()];
await (stepper as any).updateComplete;
stepper.goNext();
}, },
}, },
{ ],
name: 'Create', });
iconName: 'lucide:check',
action: async (modalRef: any) => { const buildReviewStep = (): any => {
if (!formData.displayName.trim() || !formData.domain.trim()) { const resolvedId = slugify(accumulator.displayName);
deesCatalog.DeesToast.error('Display name and domain are required'); return {
return; title: 'Review & Create',
} content: html`
try { <dees-panel>
const providerId = slugify(formData.displayName); <div
const codecs = formData.codecs style="display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:.85rem;padding:8px 4px;"
>
<div style="color:#94a3b8;">Type</div>
<div>${accumulator.providerType}</div>
<div style="color:#94a3b8;">Display Name</div>
<div>${accumulator.displayName}</div>
<div style="color:#94a3b8;">ID</div>
<div style="font-family:'JetBrains Mono',monospace;">${resolvedId}</div>
<div style="color:#94a3b8;">Domain</div>
<div>${accumulator.domain}</div>
<div style="color:#94a3b8;">Outbound Proxy</div>
<div>
${accumulator.outboundProxyAddress || accumulator.domain}:${accumulator.outboundProxyPort}
</div>
<div style="color:#94a3b8;">Username</div>
<div>${accumulator.username}</div>
<div style="color:#94a3b8;">Password</div>
<div>${'*'.repeat(Math.min(accumulator.password.length, 12))}</div>
<div style="color:#94a3b8;">Register Interval</div>
<div>${accumulator.registerIntervalSec}s</div>
<div style="color:#94a3b8;">Codecs</div>
<div>${accumulator.codecs}</div>
<div style="color:#94a3b8;">Early-Media Silence</div>
<div>${accumulator.earlyMediaSilence ? 'yes' : 'no'}</div>
</div>
</dees-panel>
`,
menuOptions: [
{
name: 'Create Provider',
iconName: 'lucide:check',
action: async (stepper: TDeesStepper) => {
// Collision-resolve id against current state.
const existing = (this.appData.providers || []).map((p) => p.id);
let uniqueId = resolvedId;
let suffix = 2;
while (existing.includes(uniqueId)) {
uniqueId = `${resolvedId}-${suffix++}`;
}
const parsedCodecs = accumulator.codecs
.split(',') .split(',')
.map((s: string) => parseInt(s.trim(), 10)) .map((s: string) => parseInt(s.trim(), 10))
.filter((n: number) => !isNaN(n)); .filter((n: number) => !isNaN(n));
const newProvider: any = { const newProvider: any = {
id: providerId, id: uniqueId,
displayName: formData.displayName.trim(), displayName: accumulator.displayName.trim(),
domain: formData.domain.trim(), domain: accumulator.domain.trim(),
outboundProxy: { outboundProxy: {
address: formData.outboundProxyAddress.trim() || formData.domain.trim(), address:
port: parseInt(formData.outboundProxyPort, 10) || 5060, accumulator.outboundProxyAddress.trim() || accumulator.domain.trim(),
port: parseInt(accumulator.outboundProxyPort, 10) || 5060,
}, },
username: formData.username.trim(), username: accumulator.username.trim(),
password: formData.password, password: accumulator.password,
registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300, registerIntervalSec: parseInt(accumulator.registerIntervalSec, 10) || 300,
codecs, codecs: parsedCodecs.length ? parsedCodecs : [9, 0, 8, 101],
quirks: { quirks: {
earlyMediaSilence: formData.earlyMediaSilence, earlyMediaSilence: accumulator.earlyMediaSilence,
}, },
}; };
const result = await appState.apiSaveConfig({ try {
addProvider: newProvider, const result = await appState.apiSaveConfig({
}); addProvider: newProvider,
});
if (result.ok) { if (result.ok) {
modalRef.destroy(); await stepper.destroy();
deesCatalog.DeesToast.success(`Provider "${formData.displayName}" created`); deesCatalog.DeesToast.success(
} else { `Provider "${newProvider.displayName}" created`,
deesCatalog.DeesToast.error('Failed to save provider'); );
} else {
deesCatalog.DeesToast.error('Failed to save provider');
}
} catch (err: any) {
console.error('Failed to create provider:', err);
deesCatalog.DeesToast.error('Failed to create provider');
}
},
},
],
};
};
// --- Step 1: Provider Type ------------------------------------------------
//
// Note: `DeesStepper.createAndShow()` dismisses on backdrop click; a user
// mid-form could lose work. Acceptable for v1 — revisit if users complain.
const typeOptions: { option: string; key: TProviderType }[] = [
{ option: 'Custom', key: 'Custom' },
{ option: 'Sipgate', key: 'Sipgate' },
{ option: 'O2 / Alice', key: 'O2/Alice' },
];
const currentTypeOption =
typeOptions.find((o) => o.key === accumulator.providerType) || null;
const typeStep: any = {
title: 'Choose provider type',
content: html`
<dees-form>
<dees-input-dropdown
.key=${'providerType'}
.label=${'Provider Type'}
.options=${typeOptions}
.selectedOption=${currentTypeOption}
.enableSearch=${false}
.required=${true}
></dees-input-dropdown>
</dees-form>
`,
menuOptions: [
{
name: 'Continue',
iconName: 'lucide:arrow-right',
action: async (stepper: TDeesStepper) => {
// `dees-input-dropdown.value` is an object `{option, key, payload?}`,
// not a plain string — extract the `key` directly instead of using
// the generic `snapshotActiveForm` helper (which would clobber
// `accumulator.providerType`'s string type via Object.assign).
const form = stepper.activeForm;
if (form) {
const data: Record<string, any> = await form.collectFormData();
const selected = data.providerType;
if (selected && typeof selected === 'object' && selected.key) {
accumulator.providerType = selected.key as TProviderType;
} }
} catch (err: any) {
console.error('Failed to create provider:', err);
deesCatalog.DeesToast.error('Failed to create provider');
} }
if (!accumulator.providerType) {
accumulator.providerType = 'Custom';
}
applyTemplate(accumulator.providerType);
// (Re)build steps 2-5 with current accumulator values.
stepper.steps = [
typeStep,
buildConnectionStep(),
buildCredentialsStep(),
buildAdvancedStep(),
buildReviewStep(),
];
await (stepper as any).updateComplete;
stepper.goNext();
}, },
}, },
], ],
}); };
await DeesStepper.createAndShow({ steps: [typeStep] });
} }
// ---- edit provider modal ------------------------------------------------- // ---- edit provider modal -------------------------------------------------