diff --git a/changelog.md b/changelog.md index d085024..6647a06 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # 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) improve architecture and call flow documentation with Mermaid diagrams diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e350c80..3a5e209 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.20.5', + version: '1.21.0', description: 'undefined' } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index e350c80..3a5e209 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: 'siprouter', - version: '1.20.5', + version: '1.21.0', description: 'undefined' } diff --git a/ts_web/elements/sipproxy-view-providers.ts b/ts_web/elements/sipproxy-view-providers.ts index 48bc8b9..81f51c0 100644 --- a/ts_web/elements/sipproxy-view-providers.ts +++ b/ts_web/elements/sipproxy-view-providers.ts @@ -164,173 +164,346 @@ export class SipproxyViewProviders extends DeesElement { iconName: 'lucide:plus', type: ['header'] as any, actionFunc: async () => { - await this.openAddModal(); - }, - }, - { - 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'); + await this.openAddStepper(); }, }, ]; } - // ---- add provider modal -------------------------------------------------- + // ---- add provider stepper ------------------------------------------------ - private async openAddModal( - template?: typeof PROVIDER_TEMPLATES.sipgate, - templateName?: string, - ) { - const { DeesModal } = await import('@design.estate/dees-catalog'); + private async openAddStepper() { + const { DeesStepper } = await import('@design.estate/dees-catalog'); + type TDeesStepper = InstanceType; + // IStep / menuOptions types: we keep content typing loose (`any[]`) to + // avoid having to import tsclass IMenuItem just for one parameter annotation. - const formData = { - displayName: templateName || '', - domain: template?.domain || '', - outboundProxyAddress: template?.outboundProxy?.address || '', - outboundProxyPort: String(template?.outboundProxy?.port ?? 5060), + type TProviderType = 'Custom' | 'Sipgate' | 'O2/Alice'; + interface IAccumulator { + providerType: TProviderType; + displayName: string; + 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: '', password: '', - registerIntervalSec: String(template?.registerIntervalSec ?? 300), - codecs: template?.codecs ? template.codecs.join(', ') : '9, 0, 8, 101', - earlyMediaSilence: template?.quirks?.earlyMediaSilence ?? false, + registerIntervalSec: '300', + codecs: '9, 0, 8, 101', + earlyMediaSilence: false, }; - const heading = template - ? `Add ${templateName} Provider` - : 'Add Provider'; + // Snapshot the currently-selected step's form (if any) into accumulator. + const snapshotActiveForm = async (stepper: TDeesStepper) => { + const form = stepper.activeForm; + if (!form) return; + const data: Record = await form.collectFormData(); + Object.assign(accumulator, data); + }; - await DeesModal.createAndShow({ - heading, - width: 'small', - showCloseButton: true, + // Overwrite template-owned fields. Keep user-owned fields (username, + // password) untouched. displayName is replaced only when empty or still + // holds a branded auto-fill. + 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` -
+ { formData.displayName = (e.target as any).value; }} + .value=${accumulator.displayName} + .required=${true} > { formData.domain = (e.target as any).value; }} + .value=${accumulator.domain} + .required=${true} > { formData.outboundProxyAddress = (e.target as any).value; }} + .value=${accumulator.outboundProxyAddress} > { formData.outboundProxyPort = (e.target as any).value; }} + .value=${accumulator.outboundProxyPort} > + + `, + menuOptions: [ + { + name: 'Continue', + iconName: 'lucide:arrow-right', + action: async (stepper: TDeesStepper) => { + await snapshotActiveForm(stepper); + stepper.goNext(); + }, + }, + ], + }); + + const buildCredentialsStep = (): any => ({ + title: 'Credentials', + content: html` + { formData.username = (e.target as any).value; }} + .value=${accumulator.username} + .required=${true} > { formData.password = (e.target as any).value; }} + .value=${accumulator.password} + .required=${true} > + + `, + menuOptions: [ + { + name: 'Continue', + iconName: 'lucide:arrow-right', + action: async (stepper: TDeesStepper) => { + await snapshotActiveForm(stepper); + stepper.goNext(); + }, + }, + ], + }); + + const buildAdvancedStep = (): any => ({ + title: 'Advanced', + content: html` + { formData.registerIntervalSec = (e.target as any).value; }} + .value=${accumulator.registerIntervalSec} > { formData.codecs = (e.target as any).value; }} + .value=${accumulator.codecs} > { formData.earlyMediaSilence = e.detail; }} + .value=${accumulator.earlyMediaSilence} > -
+ `, menuOptions: [ { - name: 'Cancel', - iconName: 'lucide:x', - action: async (modalRef: any) => { - modalRef.destroy(); + name: 'Continue', + iconName: 'lucide:arrow-right', + action: async (stepper: TDeesStepper) => { + 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) => { - if (!formData.displayName.trim() || !formData.domain.trim()) { - deesCatalog.DeesToast.error('Display name and domain are required'); - return; - } - try { - const providerId = slugify(formData.displayName); - const codecs = formData.codecs + ], + }); + + const buildReviewStep = (): any => { + const resolvedId = slugify(accumulator.displayName); + return { + title: 'Review & Create', + content: html` + +
+
Type
+
${accumulator.providerType}
+
Display Name
+
${accumulator.displayName}
+
ID
+
${resolvedId}
+
Domain
+
${accumulator.domain}
+
Outbound Proxy
+
+ ${accumulator.outboundProxyAddress || accumulator.domain}:${accumulator.outboundProxyPort} +
+
Username
+
${accumulator.username}
+
Password
+
${'*'.repeat(Math.min(accumulator.password.length, 12))}
+
Register Interval
+
${accumulator.registerIntervalSec}s
+
Codecs
+
${accumulator.codecs}
+
Early-Media Silence
+
${accumulator.earlyMediaSilence ? 'yes' : 'no'}
+
+
+ `, + 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(',') .map((s: string) => parseInt(s.trim(), 10)) .filter((n: number) => !isNaN(n)); const newProvider: any = { - id: providerId, - displayName: formData.displayName.trim(), - domain: formData.domain.trim(), + id: uniqueId, + displayName: accumulator.displayName.trim(), + domain: accumulator.domain.trim(), outboundProxy: { - address: formData.outboundProxyAddress.trim() || formData.domain.trim(), - port: parseInt(formData.outboundProxyPort, 10) || 5060, + address: + accumulator.outboundProxyAddress.trim() || accumulator.domain.trim(), + port: parseInt(accumulator.outboundProxyPort, 10) || 5060, }, - username: formData.username.trim(), - password: formData.password, - registerIntervalSec: parseInt(formData.registerIntervalSec, 10) || 300, - codecs, + username: accumulator.username.trim(), + password: accumulator.password, + registerIntervalSec: parseInt(accumulator.registerIntervalSec, 10) || 300, + codecs: parsedCodecs.length ? parsedCodecs : [9, 0, 8, 101], quirks: { - earlyMediaSilence: formData.earlyMediaSilence, + earlyMediaSilence: accumulator.earlyMediaSilence, }, }; - const result = await appState.apiSaveConfig({ - addProvider: newProvider, - }); - - if (result.ok) { - modalRef.destroy(); - deesCatalog.DeesToast.success(`Provider "${formData.displayName}" created`); - } else { - deesCatalog.DeesToast.error('Failed to save provider'); + try { + const result = await appState.apiSaveConfig({ + addProvider: newProvider, + }); + if (result.ok) { + await stepper.destroy(); + deesCatalog.DeesToast.success( + `Provider "${newProvider.displayName}" created`, + ); + } 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` + + + + `, + 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 = 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 -------------------------------------------------