@picked/svelte 0.0.50 → 0.0.51-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/form/create-form.js +1 -0
  2. package/dist/form/create-form.svelte.d.ts +24 -0
  3. package/dist/form/create-form.svelte.js +1 -0
  4. package/dist/form/get-form.d.ts +16 -0
  5. package/dist/form/get-form.js +1 -0
  6. package/dist/form/index.d.ts +2 -16
  7. package/dist/form/index.js +1 -1
  8. package/dist/hooks/action.d.ts +25 -2
  9. package/dist/hooks/action.js +1 -1
  10. package/dist/hooks/field.d.ts +3 -2
  11. package/dist/hooks/field.js +1 -1
  12. package/dist/hooks/index.d.ts +1 -0
  13. package/dist/hooks/step.d.ts +19 -8
  14. package/dist/hooks/step.js +1 -1
  15. package/dist/plugins/devtools/components/ExperienceInstanceListItem.svelte +14 -4
  16. package/dist/plugins/devtools/components/ExperienceInstancesTable.svelte +1 -1
  17. package/dist/plugins/devtools/components/Tabs.svelte +22 -2
  18. package/dist/plugins/devtools/plugin.d.ts +9 -0
  19. package/dist/plugins/devtools/plugin.js +1 -1
  20. package/dist/plugins/devtools/screens/events/EventListItem.svelte +127 -37
  21. package/dist/plugins/devtools/screens/experiences/ExperienceDetailsScreen.svelte +2 -5
  22. package/dist/plugins/devtools/screens/experiences/ExperienceTreeScreen.svelte +107 -21
  23. package/dist/plugins/devtools/screens/experiences/ExperiencesListScreen.svelte +2 -1
  24. package/dist/plugins/devtools/screens/history/HistoryEntryListItem.svelte +7 -1
  25. package/dist/plugins/devtools/screens/responses/ResponseTableRow.svelte +83 -14
  26. package/dist/plugins/devtools/screens/responses/ResponsesTable.svelte +15 -6
  27. package/dist/router/Step.svelte +6 -4
  28. package/dist/router/StepRouter.svelte +3 -2
  29. package/dist/router/context.d.ts +1 -1
  30. package/dist/utils/ErrorBoundary.svelte +1 -1
  31. package/package.json +4 -4
@@ -0,0 +1 @@
1
+ import{getExperience as e,getPicked as t}from"../hooks/client.js";import{createForm as n}from"@picked/client/form";import*as r from"svelte/internal/client";function i(i,a,o){let s=n({client:t(),experienceInstance:e()},i,a,o);return{...s,form:{...s.form,onBeforeSubmit:e=>{r.user_effect(()=>s.form.onBeforeSubmit(e))},onSuccess:e=>{r.user_effect(()=>s.form.onSuccess(e))},onError:e=>{r.user_effect(()=>s.form.onError(e))}}}}export{i as createForm};
@@ -0,0 +1,24 @@
1
+ import { Store } from 'effector';
2
+ import { FormInstance as CoreFormInstance, ErrorCallback, SubmitCallback, SuccessCallback } from '@picked/client/form';
3
+ export type { FormField } from '@picked/client/form';
4
+ /**
5
+ * Svelte FormInstance — identical to core but callback registration returns void
6
+ * (cleanup is handled automatically via $effect, not by the consumer).
7
+ */
8
+ export interface FormInstance<TFieldIds extends string = string> extends Omit<CoreFormInstance<TFieldIds>, 'form'> {
9
+ form: Omit<CoreFormInstance<TFieldIds>['form'], 'onBeforeSubmit' | 'onSuccess' | 'onError'> & {
10
+ onBeforeSubmit: (callback: SubmitCallback<TFieldIds>) => void;
11
+ onSuccess: (callback: SuccessCallback<TFieldIds>) => void;
12
+ onError: (callback: ErrorCallback) => void;
13
+ };
14
+ }
15
+ /**
16
+ * Creates a form instance with Svelte lifecycle management.
17
+ *
18
+ * Wraps the framework-agnostic createForm from @picked/client/form:
19
+ * - Injects client and experienceInstance from Svelte context
20
+ * - Auto-wires callback cleanup via $effect so consumers don't need to manage lifecycle
21
+ *
22
+ * Used internally by setStepContext — access via getStep() in components.
23
+ */
24
+ export declare function createForm(formId: string, title: Store<string>, fieldIds: Store<string[]>): FormInstance;
@@ -0,0 +1 @@
1
+ export * from './create-form.js';
@@ -0,0 +1,16 @@
1
+ import { ValidationError } from '@picked/client';
2
+ interface GetFormOptions {
3
+ id: string;
4
+ title: string;
5
+ onSuccess?: () => void | Promise<void>;
6
+ onError?: (errors: ValidationError[]) => void | Promise<void>;
7
+ }
8
+ type SubmitEventWithCurrentTarget = SubmitEvent & {
9
+ currentTarget: EventTarget & HTMLFormElement;
10
+ };
11
+ export declare function getForm({ id: formId, title, onError, onSuccess }: GetFormOptions): {
12
+ form: {
13
+ onsubmit: import('effector').EventCallable<SubmitEventWithCurrentTarget>;
14
+ };
15
+ };
16
+ export {};
@@ -0,0 +1 @@
1
+ import{getExperience as e,getPicked as t}from"../hooks/client.js";import{getStep as n}from"../hooks/step.js";import"../hooks/index.js";import{buildResponsesFromFormData as r}from"@picked/client/form";import{attach as i,createEffect as a,createEvent as o,sample as s,split as c}from"effector";function l({id:l,title:u,onError:d,onSuccess:f}){let p=t(),m=n(),h=e(),{currentStepStore:g}=h,_=o(),v=a(e=>p.responses.commit(e)),y=i({source:{step:g},effect:a(({step:e})=>{p.eventBus({type:`form_submit_success`,form:{id:l,title:u},experience:h,step:e??void 0}),f?.()})}),b=i({source:{step:g},mapParams:(e,t)=>({step:t.step,errors:e.errors}),effect:a(({step:e,errors:t})=>{p.eventBus({type:`form_submit_error`,form:{id:l,title:u},exception:{reason:t},experience:h,step:e??void 0}),t.forEach(t=>{p.eventBus({type:`field_validation_error`,exception:{reason:[t]},experience:h,step:e??void 0})}),d?.(t)})});return s({clock:_,source:{step:g},fn:({step:e})=>({formId:l,title:u,step:e}),target:a(({formId:e,title:t,step:n})=>{p.eventBus({type:`form_submit`,form:{id:e,title:t},experience:h,step:n??void 0})})}),s({clock:_,source:{fields:p.fields.listStore,stepFields:m.fields,step:g},fn:({stepFields:e},t)=>(t.preventDefault(),r(e,new FormData(t.currentTarget))),target:v}),c({source:v.doneData,match:{hasErrors:({errors:e})=>e.length>0},cases:{hasErrors:b,__:y}}),{form:{onsubmit:_}}}export{l as getForm};
@@ -1,16 +1,2 @@
1
- import { ValidationError } from '@picked/client';
2
- interface GetFormOptions {
3
- id: string;
4
- title: string;
5
- onSuccess?: () => void | Promise<void>;
6
- onError?: (errors: ValidationError[]) => void | Promise<void>;
7
- }
8
- type SubmitEventWithCurrentTarget = SubmitEvent & {
9
- currentTarget: EventTarget & HTMLFormElement;
10
- };
11
- export declare function getForm({ id: formId, title, onError, onSuccess }: GetFormOptions): {
12
- form: {
13
- onsubmit: import('effector').EventCallable<SubmitEventWithCurrentTarget>;
14
- };
15
- };
16
- export {};
1
+ export type { FormField } from '@picked/client/form';
2
+ export { getForm } from './get-form';
@@ -1 +1 @@
1
- import{getExperience as e,getPicked as t}from"../hooks/client.js";import{getStep as n}from"../hooks/step.js";import"../hooks/index.js";import{attach as r,createEffect as i,createEvent as a,sample as o,split as s}from"effector";function c({id:c,title:l,onError:u,onSuccess:d}){let f=t(),p=n(),m=e(),{currentStepStore:h}=m,g=a(),_=i(e=>f.responses.commit(e)),v=r({source:{step:h},effect:i(({step:e})=>{f.eventBus({type:`form_submit_success`,form:{id:c,title:l},experience:m,step:e??void 0}),d?.()})}),y=r({source:{step:h},mapParams:(e,t)=>({step:t.step,errors:e.errors}),effect:i(({step:e,errors:t})=>{f.eventBus({type:`form_submit_error`,form:{id:c,title:l},exception:{reason:t},experience:m,step:e??void 0}),t.forEach(t=>{f.eventBus({type:`field_validation_error`,exception:{reason:[t]},experience:m,step:e??void 0})}),u?.(t)})});return o({clock:g,source:{step:h},fn:({step:e})=>({formId:c,title:l,step:e}),target:i(({formId:e,title:t,step:n})=>{f.eventBus({type:`form_submit`,form:{id:e,title:t},experience:m,step:n??void 0})})}),o({clock:g,source:{fields:f.fields.listStore,stepFields:p.fields,step:h},fn:({stepFields:e},t)=>{t.preventDefault();let n=new FormData(t.currentTarget),r=Array.from(n.entries()),i={data:{},push(e){let t=e.questionId,n=this.data[t];n&&Array.isArray(n)&&Array.isArray(e.value)?Array.isArray(n)&&Array.isArray(e.value)&&(typeof n[0]==`string`?this.data[t]=[...n,...e.value].map(String):typeof n[0]==`number`?this.data[t]=[...n,...e.value].map(Number):typeof n[0]==`boolean`?this.data[t]=[...n,...e.value].map(e=>!!e):this.data[t]=e.value):this.data[t]=e.value}};return e.forEach(e=>{let t=r.filter(([t])=>t===e.id);t.length===0&&(t=[[e.id,``]]),t.forEach(([,t])=>{let n=e.id;if(t===``){i.push({questionId:n,value:null});return}switch(e.type){case`text`:i.push({questionId:n,value:e.multiple?[String(t)]:String(t)});break;case`number`:i.push({questionId:n,value:e.multiple?[Number(t)]:Number(t)});break;case`boolean`:i.push({questionId:n,value:e.multiple?[t===`true`]:t===`true`});break}})}),i.data},target:_}),s({source:_.doneData,match:{hasErrors:({errors:e})=>e.length>0},cases:{hasErrors:y,__:v}}),{form:{onsubmit:g}}}export{c as getForm};
1
+ import{getForm as e}from"./get-form.js";export{e as getForm};
@@ -1,2 +1,25 @@
1
- export declare function getAction(actionName: string): import('effector').Store<(<T = void>(params?: T) => void) | undefined>;
2
- export declare function getActionResult(actionName: string): import('effector').StoreWritable<void | undefined>;
1
+ import { Action } from '@picked/client';
2
+ import { Store } from 'effector';
3
+ /**
4
+ * Returns the reactive evokable action store for the given action name.
5
+ *
6
+ * @param {string} actionName - The registered action name.
7
+ * @returns {Store<() => ReturnType<Action>>} A store containing the callable action function or undefined.
8
+ * @example
9
+ * ```ts
10
+ * const actionStore = getAction('logEvent')
11
+ * ```
12
+ */
13
+ export declare function getAction(actionName: string): Store<() => ReturnType<Action>>;
14
+ /**
15
+ * Returns a reactive store holding the latest result of invoking the named action.
16
+ * Re-evaluates automatically when the underlying action store updates.
17
+ *
18
+ * @param {string} actionName - The registered action name.
19
+ * @returns {Store<string | string[] | boolean | void | undefined>} A store containing the latest action return value.
20
+ * @example
21
+ * ```ts
22
+ * const result = getActionResult('isLoggedIn')
23
+ * ```
24
+ */
25
+ export declare function getActionResult(actionName: string): import('effector').StoreWritable<string | boolean | void | string[] | undefined>;
@@ -1 +1 @@
1
- import{getPicked as e}from"./client.js";import{attach as t,createEffect as n,createEvent as r,createStore as i,sample as a}from"effector";function o(t){return e().actions.getEvokableAction(t)}function s(e){let s=o(e),c=i(void 0,{skipVoid:!1}),l=r(),u=t({source:s,mapParams:(e,t)=>t,effect:n(async e=>{if(e)return e()})});return a({clock:l,target:u}),a({clock:u.doneData,target:c}),l(),c}export{o as getAction,s as getActionResult};
1
+ import{getPicked as e}from"./client.js";import{attach as t,createEffect as n,createEvent as r,createStore as i,sample as a}from"effector";import"@picked/client";function o(t){return e().actions.getEvokableAction(t)}function s(e){let s=o(e),c=i(void 0,{skipVoid:!1}),l=r(),u=t({source:s,mapParams:(e,t)=>t,effect:n(async e=>{if(e)return await e()})});return a({clock:l,target:u}),a({clock:u.doneData,target:c}),l(),a({clock:s,target:l}),c}export{o as getAction,s as getActionResult};
@@ -1,7 +1,8 @@
1
1
  import { Field, FieldValue } from '@picked/client';
2
2
  import { Store } from 'effector';
3
- export interface GetFieldResponse {
3
+ interface GetFieldResponse {
4
4
  value: Store<FieldValue>;
5
5
  field: Store<Field | null>;
6
6
  }
7
- export declare function getField(id: string): GetFieldResponse;
7
+ export declare function getField(key: string): GetFieldResponse;
8
+ export {};
@@ -1 +1 @@
1
- import{getPicked as e}from"./client.js";function t(t){let n=e();return{field:n.fields.getById(t),value:n.responses.getByFieldId(t)}}export{t as getField};
1
+ import{getPicked as e}from"./client.js";function t(t){let n=e();return{field:n.fields.getByKey(t),value:n.responses.getByFieldKey(t)}}export{t as getField};
@@ -1,3 +1,4 @@
1
+ /** @file Public re-exports for Svelte hook utilities. */
1
2
  export { getPicked, getExperience } from './client';
2
3
  export { getPredicate, getPredicateResult } from './predicate';
3
4
  export { getFeature, getFeatureResult } from './feature';
@@ -1,13 +1,24 @@
1
1
  import { Store } from 'effector';
2
- import { ExperienceStep, Field, Response } from '@picked/client';
3
- type FieldWithValue = Field & {
4
- value: Response | undefined;
5
- };
6
- export interface StepContext {
2
+ import { ExperienceStep } from '@picked/client';
3
+ import { FormInstance, FormField } from '../form/create-form.svelte';
4
+ export interface StepContext extends FormInstance {
7
5
  step: Store<ExperienceStep>;
8
- fields: Store<FieldWithValue[]>;
6
+ /** Directly accessible fields with current response values — usable without a form. */
7
+ fields: Store<FormField[]>;
8
+ isActive: Store<boolean>;
9
+ /** @deprecated Use `isActive` instead. */
9
10
  isActiveStep: Store<boolean>;
10
11
  }
12
+ /**
13
+ * Creates and sets step context in the Svelte context tree.
14
+ *
15
+ * @param stepKey - The step key.
16
+ * @example setStepContext('step-1')
17
+ */
18
+ export declare function setStepContext(stepKey: string): StepContext;
19
+ /**
20
+ * Retrieves the step context from the Svelte context tree.
21
+ *
22
+ * @example getStep()
23
+ */
11
24
  export declare function getStep(): StepContext;
12
- export declare function setStepContext(stepId: string): StepContext;
13
- export {};
@@ -1 +1 @@
1
- import{STEP_CONTEXT_KEY as e}from"../context.js";import{getExperience as t,getPicked as n}from"./client.js";import{getStepRouter as r}from"../router/context.js";import{attach as i,combine as a,createEffect as o,createStore as s,sample as c}from"effector";import{getContext as l,setContext as u}from"svelte";import{getStepFields as d}from"@picked/client";function f(){return l(e)}function p(l){let f=t(),p=n(),{activeStep:m}=r(),h=m.map(e=>e?.id===l),g=f.treeStore.map(e=>{let t=e.steps.find(e=>e.id===l);if(!t)throw Error(`Step with id "${l}" not found in the experience tree.`);return t}),_=s([]),v=a({fields:_,responses:p.responses.listStore},({fields:e,responses:t})=>e.map(e=>{let n=t.find(t=>t.fieldId===e.id)?.value;return{...e,value:n}})),y=i({source:a({step:g,fields:p.fields.listStore}),effect:o(async({step:{fieldIds:e},fields:t})=>await d(p,t.filter(t=>e.includes(t.id))))});return c({source:{isActiveStep:h},filter:({isActiveStep:e})=>e,target:y}),c({clock:p.responses.listStore,source:{isActiveStep:h},filter:({isActiveStep:e})=>e,target:y}),c({source:y.doneData,target:_}),u(e,{step:g,fields:v,isActiveStep:h})}export{f as getStep,p as setStepContext};
1
+ import{STEP_CONTEXT_KEY as e}from"../context.js";import{getPicked as t}from"./client.js";import{getStepRouter as n}from"../router/context.js";import{getContext as r,setContext as i}from"svelte";import{createForm as a}from"../form/create-form.svelte";function o(r){let o=t(),{activeStep:s}=n(),c=s.map(e=>e?.key===r),l=o.steps.listStore.map(e=>e.find(e=>e.key===r)??{id:r,key:r,name:``,fieldIds:[]});return i(e,{step:l,isActive:c,isActiveStep:c,...a(r,l.map(e=>e.name),l.map(e=>e.fieldIds))})}function s(){return r(e)}export{s as getStep,o as setStepContext};
@@ -1,9 +1,14 @@
1
1
  <script lang="ts">
2
+ /** @file List item row displaying an experience instance's current state in the devtools panel. */
2
3
  import type { ExperienceInstance } from '@picked/client'
3
4
  import { Button } from '../ui'
5
+ import { getPicked } from '../../../hooks'
4
6
 
5
7
  export let experienceInstance: ExperienceInstance
6
8
 
9
+ const client = getPicked()
10
+ const experienceStore = client.experiences.getById(experienceInstance.experienceId)
11
+
7
12
  const currentNode = experienceInstance.currentNodeStore
8
13
  const currentStep = experienceInstance.currentStepStore
9
14
  const nextStepNodes = experienceInstance.nextReachableStepNodesStore
@@ -12,15 +17,20 @@
12
17
  const isOpen = experienceInstance.isOpenStore
13
18
 
14
19
  $: totalNextStepNodes = $nextStepNodes.length
20
+ $: experienceName = $experienceStore?.name ?? experienceInstance.experienceId
21
+ $: currentNodeDisplay = $currentNode
22
+ ? (('stepKey' in $currentNode && $currentNode.stepKey) || $currentNode.id)
23
+ : ''
24
+ $: currentStepDisplay = $currentStep?.name ?? ''
15
25
  </script>
16
26
 
17
27
  <tr>
18
- <td>{experienceInstance.experienceId}</td>
28
+ <td>{experienceName}</td>
19
29
  <td><Button variant="ghost" size="small" onclick={() => experienceInstance.toggleOpen()}>{$isOpen}</Button></td>
20
- <td>{$currentNode?.id || ''}</td>
30
+ <td>{currentNodeDisplay}</td>
21
31
  <td>{$currentNode?.type || ''}</td>
22
- <td>{$currentStep?.id || ''}</td>
23
- <td>{$currentStep?.title || ''}</td>
32
+ <td>{currentStepDisplay}</td>
33
+ <td>{$currentStep?.name || ''}</td>
24
34
  <td>{totalNextStepNodes}</td>
25
35
  <td>{$stepsToOrigin[0]} - {$stepsToOrigin[1]}</td>
26
36
  <td>{$stepsToLeaf[0]} - {$stepsToLeaf[1]}</td>
@@ -6,7 +6,7 @@
6
6
  export let experienceInstances: ExperienceInstance[]
7
7
 
8
8
  const columns = [
9
- { key: 'experience', header: 'Experience' },
9
+ { key: 'experience', header: 'Name' },
10
10
  { key: 'isOpen', header: 'Is Open' },
11
11
  { key: 'currentNodeId', header: 'Current node ID' },
12
12
  { key: 'currentNodeType', header: 'Current node Type' },
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
+ /** @file Accessible tab container that renders dynamic Svelte components per tab. */
2
3
  import type { Component } from 'svelte'
3
4
 
4
5
  interface Tab {
5
6
  id: string
6
7
  label: string
7
- component: Component<any>
8
+ component: Component<Record<string, unknown>>
8
9
  props?: Record<string, unknown>
9
10
  }
10
11
 
@@ -13,10 +14,28 @@
13
14
 
14
15
  let activeTab = initialTab
15
16
 
17
+ /**
18
+ * Sets the active tab to the given index.
19
+ *
20
+ * @param {number} index - The zero-based tab index to activate.
21
+ * @example
22
+ * ```typescript
23
+ * activateTab(2) // switches to the third tab
24
+ * ```
25
+ */
16
26
  function activateTab(index: number) {
17
27
  activeTab = index
18
28
  }
19
29
 
30
+ /**
31
+ * Handles arrow-key, Home, and End navigation within the tab list.
32
+ *
33
+ * @param {KeyboardEvent} event - The keyboard event from the tab list.
34
+ * @example
35
+ * ```typescript
36
+ * handleKeydown(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
37
+ * ```
38
+ */
20
39
  function handleKeydown(event: KeyboardEvent) {
21
40
  const tabCount = tabs.length
22
41
  let newIndex = activeTab
@@ -83,6 +102,7 @@
83
102
  display: flex;
84
103
  flex-direction: column;
85
104
  flex: 1;
105
+ min-height: 0;
86
106
  }
87
107
 
88
108
  .tab-list {
@@ -124,7 +144,7 @@
124
144
  .tab-panel {
125
145
  flex: 1 1 auto;
126
146
  display: grid;
127
- align-content: start;
147
+ min-height: 0;
128
148
  overflow: auto;
129
149
  }
130
150
 
@@ -11,5 +11,14 @@ export interface SvelteDevtoolsPluginOptions {
11
11
  /**
12
12
  * Registers the devtools experience for on-demand loading via the Chrome DevTools extension.
13
13
  * The devtools component is lazily loaded — it is never included in the initial bundle.
14
+ *
15
+ * @param options - Optional configuration for the devtools plugin.
16
+ * @returns An array of plugins to register with the Picked client.
17
+ * @example
18
+ * ```typescript
19
+ * const client = new Picked({
20
+ * plugins: [...svelteDevtoolsPlugin({ enabled: true })],
21
+ * })
22
+ * ```
14
23
  */
15
24
  export declare const svelteDevtoolsPlugin: (options?: SvelteDevtoolsPluginOptions) => Plugin[];
@@ -1 +1 @@
1
- import{adaptSvelteComponent as e}from"../../component.js";import{__vitePreload as t}from"../../_virtual/_vite/preload-helper.js";import{lazyComponent as n}from"@picked/client";import{createExperience as r,createExperiencePlugin as i}from"@picked/client/plugins/create-experience";var a=a=>{let o=r({id:`devtools`,name:`Picked Devtools`,condition:!1,initializeOpen:!1,targets:[{selector:`body`,placement:`append`}],component:e(n(()=>t(()=>import(`./DevToolsExperience.svelte`),[])))});return o.addStep({label:`Tools`}),[{name:`svelte-devtools`,setup:e=>{a?.enabled&&e.eventBus.watch(t=>{t.type===`ready`&&e.experiences.launch({id:o.id,options:{bypassCondition:!0}})})}},i({experiences:[o]})]};export{a as svelteDevtoolsPlugin};
1
+ import{adaptSvelteComponent as e}from"../../component.js";import{__vitePreload as t}from"../../_virtual/_vite/preload-helper.js";import{lazyComponent as n}from"@picked/client";import{createExperience as r,createExperiencePlugin as i}from"@picked/client/plugins/create-experience";var a=a=>{let o=r({id:`devtools`,key:`devtools`,name:`Picked Devtools`,condition:!1,initializeOpen:!1,targets:[{selector:`body`,placement:`append`}],component:e(n(()=>t(()=>import(`./DevToolsExperience.svelte`),[])))});return o.addStep({name:`Tools`}),[{name:`svelte-devtools`,setup:e=>{a?.enabled&&e.eventBus.watch(t=>{t.type===`ready`&&e.experiences.launch({key:o.key,options:{bypassCondition:!0}})})}},i({experiences:[o]})]};export{a as svelteDevtoolsPlugin};
@@ -1,11 +1,34 @@
1
1
  <script lang="ts">
2
+ /** @file Expandable list item row for displaying an event bus history entry in the devtools events screen. */
2
3
  import type { EventBusPayload, EventHistoryEntry } from '@picked/client'
3
4
  import { Badge, Card, CodeBlock, Stack, Text } from '../../ui'
5
+ import { getPicked } from '../../../../hooks'
4
6
 
5
7
  export let event: EventHistoryEntry
6
8
  export let isExpanded: boolean
7
9
  export let onToggle: () => void
8
10
 
11
+ const client = getPicked()
12
+
13
+ const eventExpId =
14
+ 'experience' in event.payload && event.payload.experience
15
+ ? event.payload.experience.experienceId
16
+ : undefined
17
+ const eventExperienceStore = client.experiences.getById(eventExpId ?? '')
18
+ $: eventExpName = eventExpId
19
+ ? ($eventExperienceStore?.name ?? eventExpId)
20
+ : '-'
21
+
22
+ /**
23
+ * Formats a Date as a 24-hour time string with milliseconds.
24
+ *
25
+ * @param {Date} date - The date to format.
26
+ * @returns {string} The formatted time string.
27
+ * @example
28
+ * ```typescript
29
+ * formatTime(new Date()) // '14:30:05.123'
30
+ * ```
31
+ */
9
32
  function formatTime(date: Date): string {
10
33
  return date.toLocaleTimeString('en-US', {
11
34
  hour12: false,
@@ -21,53 +44,111 @@
21
44
  value: unknown
22
45
  }
23
46
 
24
- function getPayloadFields(payload: EventBusPayload): PayloadField[] {
47
+ /**
48
+ * Extracts experience-related fields from an event payload's experience object.
49
+ *
50
+ * @param {unknown} exp - The experience object from the event payload.
51
+ * @returns {PayloadField[]} Extracted experience fields.
52
+ * @example
53
+ * ```typescript
54
+ * getExperienceFields({ id: 'abc', experienceId: 'quiz' })
55
+ * // [{ label: 'Experience ID', value: 'abc' }, { label: 'Experience Type', value: 'quiz' }]
56
+ * ```
57
+ */
58
+ function getExperienceFields(exp: unknown): PayloadField[] {
59
+ if (!exp || typeof exp !== 'object') return []
25
60
  const fields: PayloadField[] = []
61
+ if ('id' in exp && exp.id) fields.push({ label: 'Experience ID', value: exp.id })
62
+ if ('experienceId' in exp && exp.experienceId)
63
+ fields.push({ label: 'Experience Type', value: exp.experienceId })
64
+ return fields
65
+ }
26
66
 
27
- fields.push({ label: 'Type', value: payload.type })
67
+ /**
68
+ * Extracts step-related fields from an event payload's step object.
69
+ *
70
+ * @param {unknown} step - The step object from the event payload.
71
+ * @returns {PayloadField[]} Extracted step fields.
72
+ * @example
73
+ * ```typescript
74
+ * getStepFields({ id: 's1', name: 'Welcome' })
75
+ * // [{ label: 'Step ID', value: 's1' }, { label: 'Step Name', value: 'Welcome' }]
76
+ * ```
77
+ */
78
+ function getStepFields(step: unknown): PayloadField[] {
79
+ if (!step || typeof step !== 'object'){
80
+ return []
81
+ }
28
82
 
29
- if ('value' in payload && payload.value !== undefined) {
30
- fields.push({ label: 'Value', value: payload.value })
83
+ const fields: PayloadField[] = []
84
+ if ('key' in step && step.key) {
85
+ fields.push({ label: 'Step Key', value: step.key })
31
86
  }
32
87
 
33
- if ('experience' in payload && payload.experience) {
34
- const exp = payload.experience
35
- if (exp && typeof exp === 'object') {
36
- if ('id' in exp && exp.id) {
37
- fields.push({ label: 'Experience ID', value: exp.id })
38
- }
39
- if ('experienceId' in exp && exp.experienceId) {
40
- fields.push({ label: 'Experience Type', value: exp.experienceId })
41
- }
42
- }
88
+ // eslint-disable-next-line sonarjs/deprecation -- backwards-compat fallback for label
89
+ const stepName = ('name' in step && step.name) || ('label' in step && step.label)
90
+ if (stepName) {
91
+ fields.push({ label: 'Step Name', value: stepName })
43
92
  }
44
93
 
45
- if ('step' in payload && payload.step) {
46
- const step = payload.step
47
- if (step && typeof step === 'object') {
48
- if ('id' in step && step.id) {
49
- fields.push({ label: 'Step ID', value: step.id })
50
- }
51
- if ('label' in step && step.label) {
52
- fields.push({ label: 'Step Label', value: step.label })
53
- }
94
+ return fields
95
+ }
96
+
97
+ /** Scalar payload keys to extract and their display labels. JSON-serializable values are stringified. */
98
+ const SCALAR_KEYS: { key: string; label: string; serialize?: boolean }[] = [
99
+ { key: 'value', label: 'Value' },
100
+ { key: 'form', label: 'Form', serialize: true },
101
+ { key: 'feature', label: 'Feature', serialize: true },
102
+ { key: 'exception', label: 'Exception', serialize: true },
103
+ { key: 'version', label: 'Version' },
104
+ ]
105
+
106
+ /**
107
+ * Extracts scalar (non-nested) fields from an event bus payload.
108
+ *
109
+ * @param {EventBusPayload} payload - The event bus payload to extract scalar fields from.
110
+ * @returns {PayloadField[]} Extracted scalar fields.
111
+ * @example
112
+ * ```typescript
113
+ * getScalarFields({ type: 'step', value: 42 })
114
+ * // [{ label: 'Value', value: 42 }]
115
+ * ```
116
+ */
117
+ function getScalarFields(payload: EventBusPayload): PayloadField[] {
118
+ const fields: PayloadField[] = []
119
+ const record = payload as Record<string, unknown>
120
+
121
+ for (const { key, label, serialize } of SCALAR_KEYS) {
122
+ const raw = record[key]
123
+ if (raw !== undefined) {
124
+ fields.push({ label, value: serialize ? JSON.stringify(raw) : raw })
54
125
  }
55
126
  }
56
127
 
57
- if ('form' in payload && payload.form) {
58
- fields.push({ label: 'Form', value: JSON.stringify(payload.form) })
59
- }
128
+ return fields
129
+ }
60
130
 
61
- if ('feature' in payload && payload.feature) {
62
- fields.push({ label: 'Feature', value: JSON.stringify(payload.feature) })
63
- }
131
+ /**
132
+ * Builds a flat list of display fields from an event bus payload.
133
+ *
134
+ * @param {EventBusPayload} payload - The event bus payload to extract fields from.
135
+ * @returns {PayloadField[]} All extracted fields.
136
+ * @example
137
+ * ```typescript
138
+ * getPayloadFields({ type: 'session', id: 'sess-1' })
139
+ * // [{ label: 'Type', value: 'session' }, { label: 'Session ID', value: 'sess-1' }]
140
+ * ```
141
+ */
142
+ function getPayloadFields(payload: EventBusPayload): PayloadField[] {
143
+ const fields: PayloadField[] = [{ label: 'Type', value: payload.type }]
144
+ fields.push(...getScalarFields(payload))
64
145
 
65
- if ('exception' in payload && payload.exception) {
66
- fields.push({ label: 'Exception', value: JSON.stringify(payload.exception) })
146
+ if ('experience' in payload && payload.experience) {
147
+ fields.push(...getExperienceFields(payload.experience))
67
148
  }
68
149
 
69
- if ('version' in payload && payload.version) {
70
- fields.push({ label: 'Version', value: payload.version })
150
+ if ('step' in payload && payload.step) {
151
+ fields.push(...getStepFields(payload.step))
71
152
  }
72
153
 
73
154
  if ('id' in payload && payload.id && payload.type === 'session') {
@@ -77,6 +158,17 @@
77
158
  return fields
78
159
  }
79
160
 
161
+ /**
162
+ * Formats a field value for display as a string.
163
+ *
164
+ * @param {unknown} value - The value to format.
165
+ * @returns {string} The formatted string representation.
166
+ * @example
167
+ * ```typescript
168
+ * formatValue(42) // '42'
169
+ * formatValue(null) // 'null'
170
+ * ```
171
+ */
80
172
  function formatValue(value: unknown): string {
81
173
  if (value === null) return 'null'
82
174
  if (value === undefined) return 'undefined'
@@ -97,9 +189,7 @@
97
189
  <Badge status="info">{event.payload.type}</Badge>
98
190
  </td>
99
191
  <td>
100
- <Text size="small">
101
- {'experience' in event.payload && event.payload.experience ? event.payload.experience.experienceId : '-'}
102
- </Text>
192
+ <Text size="small">{eventExpName}</Text>
103
193
  </td>
104
194
  <td>
105
195
  <Text size="caption" color="subdued">
@@ -114,7 +204,7 @@
114
204
  <Text size="small" weight="semibold">Event Payload</Text>
115
205
  <Card>
116
206
  <Stack direction="vertical" gap="small">
117
- {#each getPayloadFields(event.payload) as field}
207
+ {#each getPayloadFields(event.payload) as field (field.label)}
118
208
  <Text size="caption" inline>
119
209
  <Text size="caption" weight="semibold" color="subdued" inline>{field.label}:</Text>
120
210
  <CodeBlock code={formatValue(field.value)} inline />
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ /** @file Detail screen for a single experience, showing its config, target selectors, and decision logic. */
2
3
  import type { ExperienceStore } from '@picked/client'
3
4
  import { getPicked } from '../../../../hooks'
4
5
  import { Button, Card, Text, CodeBlock, Stack } from '../../ui'
@@ -11,17 +12,13 @@
11
12
 
12
13
  <Stack direction="vertical" gap="medium" padding="medium">
13
14
  <Stack direction="horizontal" gap="small">
14
- <Button variant="primary" size="small" onclick={() => client.launchExperienceById(experience.id)}>
15
+ <Button variant="primary" size="small" onclick={() => client.experiences.open({ key: experience.key, options: { bypassCondition: true } })}>
15
16
  Launch experience
16
17
  </Button>
17
18
  </Stack>
18
19
 
19
20
  <Card sectioned>
20
21
  <Stack direction="vertical" gap="medium" padding="medium">
21
- <div>
22
- <Text weight="semibold">Type</Text>
23
- <Text>{$config.type}</Text>
24
- </div>
25
22
 
26
23
  <div>
27
24
  <Text weight="semibold">Target selectors</Text>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ /** @file D3-based tree visualization of an experience's node graph in the devtools panel. */
2
3
  import * as d3 from 'd3'
3
4
  import type { ExperienceEdge, ExperienceNode, ExperienceStore } from '@picked/client'
4
5
  import { Badge, Button, Spinner, Stack, Text, VisualizationContainer } from '../../ui'
@@ -7,19 +8,20 @@
7
8
 
8
9
  let svgEl: SVGSVGElement
9
10
  let zoom = 1
11
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- reserved for future node details panel
10
12
  let selectedNode = null
13
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, sonarjs/no-unused-collection -- reserved for future path highlighting
11
14
  let highlightedPath: string[][] = []
12
- let error = null
15
+ let error: string | null = null
13
16
  const loading = false
14
17
 
15
18
  let treeRenderingDone = false
16
19
 
17
20
  const tree = experience.treeStore
18
21
 
19
- $: steps = $tree.steps
20
22
  $: nodes = $tree.nodes
21
23
  $: edges = $tree.edges
22
- $: conditionals = $tree.conditionals
24
+ $: conditions = $tree.conditions
23
25
 
24
26
  let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown>
25
27
 
@@ -35,10 +37,30 @@
35
37
  _referenceTo?: string
36
38
  }
37
39
 
40
+ /**
41
+ * Type guard that checks if a value is a non-null, non-array object.
42
+ *
43
+ * @param val - The value to check.
44
+ * @example
45
+ * ```typescript
46
+ * isRecord({ key: 'value' }) // true
47
+ * isRecord([1, 2]) // false
48
+ * ```
49
+ */
38
50
  function isRecord(val: unknown): val is Record<string, unknown> {
39
51
  return val !== null && typeof val === 'object' && !Array.isArray(val)
40
52
  }
41
53
 
54
+ /**
55
+ * Recursively formats a JSON Logic rule into a human-readable string.
56
+ *
57
+ * @param rule - The JSON Logic rule object to format.
58
+ * @example
59
+ * ```typescript
60
+ * formatLogicRule({ '==': [{ var: 'age' }, 18] }) // 'age = 18'
61
+ * ```
62
+ */
63
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- recursive logic rule formatter handles all operator types
42
64
  function formatLogicRule(rule: unknown): string {
43
65
  if (rule === null || rule === undefined) return '?'
44
66
  if (typeof rule === 'string') return rule
@@ -46,6 +68,7 @@
46
68
  if (Array.isArray(rule)) {
47
69
  return rule.map(formatLogicRule).join(', ')
48
70
  }
71
+
49
72
  if (isRecord(rule)) {
50
73
  const keys = Object.keys(rule)
51
74
  if (keys.length === 0) return 'Condition'
@@ -69,28 +92,43 @@
69
92
  if (op in compOps && Array.isArray(args) && args.length >= 2) {
70
93
  return `${formatLogicRule(args[0])} ${compOps[op]} ${formatLogicRule(args[1])}`
71
94
  }
95
+
72
96
  if (op === 'and' && Array.isArray(args)) {
73
97
  return args.map(formatLogicRule).join(' & ')
74
98
  }
99
+
75
100
  if (op === 'or' && Array.isArray(args)) {
76
101
  return args.map(formatLogicRule).join(' | ')
77
102
  }
103
+
78
104
  if ((op === '!' || op === 'not') && Array.isArray(args)) {
79
105
  return `!${formatLogicRule(args[0])}`
80
106
  }
107
+
81
108
  if (op === 'in' && Array.isArray(args) && args.length >= 2) {
82
109
  return `${formatLogicRule(args[0])} in ${formatLogicRule(args[1])}`
83
110
  }
111
+
84
112
  return `${op}(?)`
85
113
  }
114
+
86
115
  return '?'
87
116
  }
88
117
 
118
+ /**
119
+ * Renders the experience node graph as an interactive D3 tree visualization.
120
+ *
121
+ * @example
122
+ * ```typescript
123
+ * renderGraph() // builds and renders the SVG tree
124
+ * ```
125
+ */
89
126
  function renderGraph() {
90
127
  d3.select(svgEl).selectAll('*').remove()
91
128
 
92
- const stepsMap = new Map(steps.map((s) => [s.id, s]))
93
- const conditionalsMap = new Map(conditionals.map((c) => [c.id, c]))
129
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- D3 visualization runs imperatively outside Svelte reactivity
130
+ const conditionsMap = new Map(conditions.map((c) => [c.id, c]))
131
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- D3 visualization runs imperatively outside Svelte reactivity
94
132
  const outgoingEdges = new Map<string, ExperienceEdge[]>()
95
133
  edges.forEach((e) => {
96
134
  if (!outgoingEdges.has(e.sourceNodeId)) outgoingEdges.set(e.sourceNodeId, [])
@@ -99,17 +137,20 @@
99
137
 
100
138
  const getNodeLabel = (node: ExperienceNode): string => {
101
139
  if (node.type === 'origin') return 'Start'
102
- if (node.type === 'step' && node.stepId)
103
- return stepsMap.get(node.stepId)?.label ?? 'Unknown Step'
104
- if (node.type === 'conditional' && node.conditionalId) {
105
- const conditional = conditionalsMap.get(node.conditionalId)
106
- if (conditional?.label) return conditional.label
107
- if (conditional?.condition) return formatLogicRule(conditional.condition)
140
+ if (node.type === 'step')
141
+ return node.name ?? 'Unknown Step'
142
+ if (node.type === 'condition') {
143
+ const conditionId = node.conditionId
144
+ const conditionItem = conditionId ? conditionsMap.get(conditionId) : undefined
145
+ if (conditionItem?.name) return conditionItem.name
146
+ if (conditionItem?.logic) return formatLogicRule(conditionItem.logic)
108
147
  return 'Condition'
109
148
  }
149
+
110
150
  return 'Unknown Node'
111
151
  }
112
152
 
153
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- D3 visualization runs imperatively outside Svelte reactivity
113
154
  const processedNodes = new Map<string, ProcessedNode>()
114
155
  nodes.forEach((n) => {
115
156
  processedNodes.set(n.id, {
@@ -141,6 +182,7 @@
141
182
  // Use a global visited set so join nodes (multiple incoming branches converging
142
183
  // to the same node) only appear once in the tree, with reference nodes on
143
184
  // subsequent visits to indicate the convergence point.
185
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- D3 visualization runs imperatively outside Svelte reactivity
144
186
  const globalVisited = new Set<string>()
145
187
  const convertGraphToTree = (node: ProcessedNode): ProcessedNode => {
146
188
  if (globalVisited.has(node.id)) {
@@ -196,32 +238,37 @@
196
238
  .append('path')
197
239
  .attr('class', 'link')
198
240
  .attr('fill', 'none')
241
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
199
242
  .attr('stroke', (d: any) => {
200
- if (d.source.data.type !== 'conditional') return '#94a3b8'
243
+ if (d.source.data.type !== 'condition') return '#94a3b8'
201
244
  const targetId = d.target.data._isReference
202
245
  ? d.target.data._referenceTo
203
246
  : d.target.data.id
204
247
  const edge = d.source.data._originalEdges.find(
205
248
  (e: ExperienceEdge) => e.destinationNodeId === targetId,
206
249
  )
207
- if (edge?.sourceNodeHandle === 'true') return '#10b981'
208
- if (edge?.sourceNodeHandle === 'false') return '#ef4444'
250
+ if (edge?.sourceHandle === 'true') return '#10b981'
251
+ if (edge?.sourceHandle === 'false') return '#ef4444'
209
252
  return '#94a3b8'
210
253
  })
211
254
  .attr('stroke-width', 2)
212
255
  .attr('d', linkGenerator)
213
256
 
214
- // Label true/false branches from conditional nodes
257
+ // Label true/false branches from condition nodes
215
258
  g.selectAll('.edge-label')
216
- .data(treeRoot.links().filter((d: any) => d.source.data.type === 'conditional'))
259
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
260
+ .data(treeRoot.links().filter((d: any) => d.source.data.type === 'condition'))
217
261
  .enter()
218
262
  .append('text')
219
263
  .attr('class', 'edge-label')
220
264
  .attr('text-anchor', 'middle')
221
265
  .attr('font-size', '10px')
222
266
  .attr('font-weight', 'bold')
267
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
223
268
  .attr('x', (d: any) => (d.source.y + d.target.y) / 2)
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
224
270
  .attr('y', (d: any) => (d.source.x + d.target.x) / 2 - 6)
271
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
225
272
  .attr('fill', (d: any) => {
226
273
  const targetId = d.target.data._isReference
227
274
  ? d.target.data._referenceTo
@@ -229,10 +276,11 @@
229
276
  const edge = d.source.data._originalEdges.find(
230
277
  (e: ExperienceEdge) => e.destinationNodeId === targetId,
231
278
  )
232
- if (edge?.sourceNodeHandle === 'true') return '#10b981'
233
- if (edge?.sourceNodeHandle === 'false') return '#ef4444'
279
+ if (edge?.sourceHandle === 'true') return '#10b981'
280
+ if (edge?.sourceHandle === 'false') return '#ef4444'
234
281
  return '#64748b'
235
282
  })
283
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
236
284
  .text((d: any) => {
237
285
  const targetId = d.target.data._isReference
238
286
  ? d.target.data._referenceTo
@@ -240,8 +288,8 @@
240
288
  const edge = d.source.data._originalEdges.find(
241
289
  (e: ExperienceEdge) => e.destinationNodeId === targetId,
242
290
  )
243
- if (edge?.sourceNodeHandle === 'true') return 'Yes'
244
- if (edge?.sourceNodeHandle === 'false') return 'No'
291
+ if (edge?.sourceHandle === 'true') return 'Yes'
292
+ if (edge?.sourceHandle === 'false') return 'No'
245
293
  return ''
246
294
  })
247
295
 
@@ -251,15 +299,18 @@
251
299
  .enter()
252
300
  .append('g')
253
301
  .attr('class', 'node')
302
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
254
303
  .attr('transform', (d: any) => `translate(${d.y},${d.x})`)
255
304
 
256
305
  nodesSelection
257
306
  .append('circle')
307
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
258
308
  .attr('r', (d: any) => (d.data.type === 'origin' ? 15 : 10))
309
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
259
310
  .attr('fill', (d: any) => {
260
311
  if (d.data._isReference) return '#38bdf8'
261
312
  if (d.data.type === 'origin') return '#00cd90'
262
- if (d.data.type === 'conditional') return '#f59e0b'
313
+ if (d.data.type === 'condition') return '#f59e0b'
263
314
  return '#1748ff'
264
315
  })
265
316
  .attr('stroke', '#fff')
@@ -267,10 +318,12 @@
267
318
 
268
319
  nodesSelection
269
320
  .append('text')
321
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
270
322
  .attr('dy', (d: any) => (d.children ? -15 : 25))
271
323
  .attr('text-anchor', 'middle')
272
324
  .attr('font-size', '12px')
273
325
  .attr('fill', '#1e293b')
326
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- D3 callback types are untyped
274
327
  .text((d: any) =>
275
328
  d.data.name.length > 30 ? `${d.data.name.slice(0, 27)}...` : d.data.name,
276
329
  )
@@ -301,11 +354,27 @@
301
354
  treeRenderingDone = true
302
355
  }
303
356
 
357
+ /**
358
+ * Zooms the tree visualization in by 20%.
359
+ *
360
+ * @example
361
+ * ```typescript
362
+ * handleZoomIn() // increases zoom level
363
+ * ```
364
+ */
304
365
  function handleZoomIn() {
305
366
  if (!svgEl || !zoomBehavior) return
306
367
  d3.select(svgEl).transition().duration(250).call(zoomBehavior.scaleBy, 1.2)
307
368
  }
308
369
 
370
+ /**
371
+ * Zooms the tree visualization out by 20%.
372
+ *
373
+ * @example
374
+ * ```typescript
375
+ * handleZoomOut() // decreases zoom level
376
+ * ```
377
+ */
309
378
  function handleZoomOut() {
310
379
  if (!svgEl || !zoomBehavior) return
311
380
  d3.select(svgEl)
@@ -314,6 +383,14 @@
314
383
  .call(zoomBehavior.scaleBy, 1 / 1.2)
315
384
  }
316
385
 
386
+ /**
387
+ * Resets the tree visualization zoom and position to the default centered view.
388
+ *
389
+ * @example
390
+ * ```typescript
391
+ * handleReset() // resets zoom to default
392
+ * ```
393
+ */
317
394
  function handleReset() {
318
395
  if (!svgEl || !zoomBehavior) return
319
396
  const width = svgEl.clientWidth
@@ -327,6 +404,15 @@
327
404
  d3.select(svgEl).transition().duration(750).call(zoomBehavior.transform, initialTransform)
328
405
  }
329
406
 
407
+ /**
408
+ * Closes the node details panel and resets all link/node highlights.
409
+ *
410
+ * @example
411
+ * ```typescript
412
+ * closeNodeDetails() // resets selection and highlighting
413
+ * ```
414
+ */
415
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- reserved for future node details panel
330
416
  function closeNodeDetails() {
331
417
  selectedNode = null
332
418
  if (svgEl) {
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ /** @file Screen listing all registered experiences as tabs in the devtools panel. */
2
3
  import Tabs from '../../components/Tabs.svelte'
3
4
  import { getPicked } from '../../../../hooks'
4
5
  import ExperienceScreen from './ExperienceScreen.svelte'
@@ -9,7 +10,7 @@
9
10
  $: tabs = $experiences.map((experience) => {
10
11
  return {
11
12
  id: experience.id,
12
- label: experience.id,
13
+ label: experience.name,
13
14
  component: ExperienceScreen,
14
15
  props: {
15
16
  experience,
@@ -1,12 +1,18 @@
1
1
  <script lang="ts">
2
+ /** @file List item row displaying a navigation history entry in the devtools history screen. */
2
3
  import type { HistoryEntry } from '@picked/client'
4
+ import { getPicked } from '../../../../hooks'
3
5
 
4
6
  export let historyEntry: HistoryEntry
7
+
8
+ const client = getPicked()
9
+ const experienceStore = client.experiences.getById(historyEntry.experienceId)
10
+ $: experienceName = $experienceStore?.name ?? historyEntry.experienceId
5
11
  </script>
6
12
 
7
13
  <tr>
8
14
  <td>{historyEntry.id}</td>
9
- <td>{historyEntry.experienceId}</td>
15
+ <td>{experienceName}</td>
10
16
  <td>{historyEntry.node.id}</td>
11
17
  <td>{historyEntry.node.type}</td>
12
18
  <td>{historyEntry.at}</td>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ /** @file Editable table row for a single field response in the devtools responses screen. */
2
3
  import type { ResponseListItem, PrimitiveFieldValue, Field } from '@picked/client'
3
4
  import { getPicked } from '../../../../hooks'
4
5
  import { Button, Text, CodeBlock, Banner, Stack } from '../../ui'
@@ -11,24 +12,47 @@
11
12
  let isEditing = false
12
13
  let editValue = ''
13
14
  let editError = ''
15
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Set is reassigned for Svelte reactivity via `selectedOptions = selectedOptions`
14
16
  let selectedOptions: Set<PrimitiveFieldValue> = new Set()
15
17
 
16
- $: hasOptions = field?.options && field.options.length > 0
18
+ $: hasOptions = Array.isArray(field?.options) && field.options.length > 0
19
+ $: staticOptions = Array.isArray(field?.options) ? field.options : null
17
20
  $: currentValue = response?.value ?? null
18
21
 
22
+ /**
23
+ * Formats a response value for display, handling arrays, objects, and primitives.
24
+ *
25
+ * @param value - The response value to format.
26
+ * @example
27
+ * ```typescript
28
+ * formatValue(['a', 'b']) // 'a, b'
29
+ * formatValue(null) // '-'
30
+ * ```
31
+ */
19
32
  function formatValue(value: unknown): string {
20
33
  if (value === null || value === undefined) {
21
34
  return '-'
22
35
  }
36
+
23
37
  if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
24
38
  return value.join(', ')
25
39
  }
40
+
26
41
  if (typeof value === 'object') {
27
42
  return JSON.stringify(value, null, 2)
28
43
  }
44
+
29
45
  return String(value)
30
46
  }
31
47
 
48
+ /**
49
+ * Enters edit mode for this field, initializing the edit state from the current value.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * startEdit() // opens the inline editor
54
+ * ```
55
+ */
32
56
  function startEdit() {
33
57
  isEditing = true
34
58
  editError = ''
@@ -45,6 +69,14 @@
45
69
  }
46
70
  }
47
71
 
72
+ /**
73
+ * Cancels the current edit, discarding changes and resetting edit state.
74
+ *
75
+ * @example
76
+ * ```typescript
77
+ * cancelEdit() // closes editor without saving
78
+ * ```
79
+ */
48
80
  function cancelEdit() {
49
81
  isEditing = false
50
82
  editValue = ''
@@ -52,6 +84,15 @@
52
84
  selectedOptions = new Set()
53
85
  }
54
86
 
87
+ /**
88
+ * Toggles selection of a field option, supporting both single and multi-select modes.
89
+ *
90
+ * @param optionValue - The option value to toggle.
91
+ * @example
92
+ * ```typescript
93
+ * toggleOption('red') // selects or deselects the 'red' option
94
+ * ```
95
+ */
55
96
  function toggleOption(optionValue: PrimitiveFieldValue) {
56
97
  if (field?.multiple) {
57
98
  if (selectedOptions.has(optionValue)) {
@@ -59,54 +100,74 @@
59
100
  } else {
60
101
  selectedOptions.add(optionValue)
61
102
  }
103
+
62
104
  selectedOptions = selectedOptions
63
105
  } else {
64
106
  selectedOptions = new Set([optionValue])
65
107
  }
66
108
  }
67
109
 
110
+ /**
111
+ * Parses a string input into a typed value based on the field's type.
112
+ *
113
+ * @param input - The raw string input to parse.
114
+ * @example
115
+ * ```typescript
116
+ * parseValue('42') // 42 (when field.type is 'number')
117
+ * parseValue('true') // true (when field.type is 'boolean')
118
+ * ```
119
+ */
68
120
  function parseValue(input: string): unknown {
69
- input = input.trim()
121
+ const trimmed = input.trim()
70
122
 
71
- if (input === '' || input === '-') {
123
+ if (trimmed === '' || trimmed === '-') {
72
124
  return null
73
125
  }
74
126
 
75
- if (input.startsWith('{') || input.startsWith('[')) {
127
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
76
128
  try {
77
- return JSON.parse(input)
129
+ return JSON.parse(trimmed)
78
130
  } catch {
79
131
  throw new Error('Invalid JSON format')
80
132
  }
81
133
  }
82
134
 
83
135
  if (field.type === 'boolean') {
84
- if (input === 'true') return true
85
- if (input === 'false') return false
136
+ if (trimmed === 'true') return true
137
+ if (trimmed === 'false') return false
86
138
  throw new Error('Boolean must be "true" or "false"')
87
139
  }
88
140
 
89
141
  if (field.type === 'number') {
90
- const num = Number(input)
142
+ const num = Number(trimmed)
91
143
  if (isNaN(num)) {
92
144
  throw new Error('Invalid number format')
93
145
  }
146
+
94
147
  return num
95
148
  }
96
149
 
97
- return input
150
+ return trimmed
98
151
  }
99
152
 
153
+ /**
154
+ * Commits the edited value via the client responses API and exits edit mode.
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * await saveEdit() // saves the current edit value
159
+ * ```
160
+ */
100
161
  async function saveEdit() {
101
162
  try {
102
- let newValue: unknown
163
+ let newValue: FieldValue
103
164
 
104
165
  if (hasOptions) {
105
166
  const values = Array.from(selectedOptions)
106
167
  if (field?.multiple) {
107
168
  newValue = values
108
169
  } else {
109
- newValue = values[0] ?? null
170
+ newValue = values.at(0) ?? null
110
171
  }
111
172
  } else {
112
173
  newValue = parseValue(editValue)
@@ -127,6 +188,14 @@
127
188
  }
128
189
  }
129
190
 
191
+ /**
192
+ * Clears the response value for this field via the client responses API.
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * handleClear() // removes the response for this field
197
+ * ```
198
+ */
130
199
  function handleClear() {
131
200
  client.responses.clear(field.id)
132
201
  }
@@ -134,7 +203,7 @@
134
203
 
135
204
  <tr>
136
205
  <td>
137
- <CodeBlock code={field.name} inline />
206
+ <CodeBlock code={field.key} inline />
138
207
  </td>
139
208
  <td>
140
209
  <Text size="small" weight="semibold">{field.label}</Text>
@@ -142,9 +211,9 @@
142
211
  <td>
143
212
  {#if isEditing}
144
213
  <Stack direction="vertical" gap="small">
145
- {#if hasOptions && field?.options}
214
+ {#if hasOptions && staticOptions}
146
215
  <Stack direction="vertical" gap="small">
147
- {#each field.options as option (option.id)}
216
+ {#each staticOptions as option (option.id)}
148
217
  <label class="option-label">
149
218
  {#if field.multiple}
150
219
  <input
@@ -1,8 +1,9 @@
1
1
  <script lang="ts">
2
- import type { ResponseListItem, Field } from '@picked/client'
2
+ /** @file Table displaying all field responses with filtering and clear-all functionality in the devtools panel. */
3
+ import type { ResponseListItem } from '@picked/client'
3
4
  import { getPicked } from '../../../../hooks'
4
- import ResponseTableRow from './ResponseTableRow.svelte'
5
5
  import { Card, DataTable, EmptyState, Button, RadioGroup, Stack } from '../../ui'
6
+ import ResponseTableRow from './ResponseTableRow.svelte'
6
7
 
7
8
  export let responses: ResponseListItem[] = []
8
9
 
@@ -10,13 +11,13 @@
10
11
 
11
12
  let filter = 'set'
12
13
 
13
- $: fieldsStore = client.fields.listStore
14
+ const fieldsStore = client.fields.listStore
14
15
  $: allFields = $fieldsStore ?? []
15
16
 
16
- $: responseMap = new Map(responses.map((r) => [r.fieldId, r]))
17
+ $: responseMap = new Map(responses.map((r) => [r.fieldKey, r]))
17
18
 
18
19
  $: allFieldRows = allFields.map((field) => {
19
- const response = responseMap.get(field.id)
20
+ const response = responseMap.get(field.key)
20
21
  return {
21
22
  field,
22
23
  response: response || null,
@@ -29,12 +30,20 @@
29
30
  return true
30
31
  })
31
32
 
33
+ /**
34
+ * Clears all committed field responses via the client responses API.
35
+ *
36
+ * @example
37
+ * ```typescript
38
+ * handleClearAll() // removes all responses
39
+ * ```
40
+ */
32
41
  function handleClearAll() {
33
42
  client.responses.clearAll()
34
43
  }
35
44
 
36
45
  const columns = [
37
- { key: 'id', header: 'ID' },
46
+ { key: 'key', header: 'Key' },
38
47
  { key: 'field', header: 'Field' },
39
48
  { key: 'value', header: 'Value' },
40
49
  { key: 'type', header: 'Type' },
@@ -1,23 +1,25 @@
1
1
  <script lang="ts">
2
+ /** @file Step component that registers a step with the router and renders its content. */
2
3
  import { type Component, type Snippet } from 'svelte'
3
4
  import { setStepContext } from '../hooks'
4
5
  import { getStepRouter } from './context'
5
6
 
6
7
  interface Props {
7
- id: string
8
+ key: string
8
9
  component?: Component | Promise<Component> | Promise<{ default: Component }>
9
10
  children?: Snippet
10
11
  }
11
12
 
12
- const { id, component, children }: Props = $props()
13
+ const { key, component, children }: Props = $props()
14
+
13
15
 
14
16
  const step = {
15
- id,
17
+ key,
16
18
  }
17
19
 
18
20
  const { registerStep, unregisterStep } = getStepRouter()
19
21
 
20
- setStepContext(id)
22
+ setStepContext(key)
21
23
 
22
24
  $effect(() => {
23
25
  registerStep(step)
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ /** @file Root router component that manages step registration and active step detection via Effector. */
2
3
  import { createEvent, createStore, sample } from 'effector'
3
4
  import { supportsWindow } from '@picked/client'
4
5
  import { getExperience } from '../hooks'
@@ -34,7 +35,7 @@
34
35
  // we just skip them.
35
36
  if (hasActiveStep) return
36
37
 
37
- const isMatchingStep = step.id === currentStepStore.getState()?.id
38
+ const isMatchingStep = step.key === currentStepStore.getState()?.key
38
39
 
39
40
  if (isMatchingStep) {
40
41
  setActiveStepEvent(step)
@@ -51,7 +52,7 @@
51
52
 
52
53
  sample({
53
54
  source: { steps: stepsStore, currentStep: currentStepStore },
54
- fn: ({ steps, currentStep }) => steps.find((s) => s.id === currentStep?.id) || null,
55
+ fn: ({ steps, currentStep }) => steps.find((s) => s.key === currentStep?.key) || null,
55
56
  target: setActiveStepEvent,
56
57
  })
57
58
 
@@ -1,6 +1,6 @@
1
1
  import { Store } from 'effector';
2
2
  export interface Step {
3
- id: string;
3
+ key: string;
4
4
  }
5
5
  interface ContextOptions {
6
6
  activeStep: Store<Step | null>;
@@ -8,9 +8,9 @@
8
8
  }
9
9
 
10
10
  const { component: InnerComponent, componentProps }: Props = $props()
11
+ const client = getPicked()
11
12
 
12
13
  function handleError(error: unknown) {
13
- const client = getPicked()
14
14
 
15
15
  // We still log to console for visibility during development
16
16
  // eslint-disable-next-line no-console
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@picked/svelte",
3
- "version": "0.0.50",
3
+ "version": "0.0.51-next.10",
4
4
  "dependencies": {
5
5
  "d3": "^7.9.0"
6
6
  },
@@ -18,7 +18,7 @@
18
18
  "@vitest/coverage-v8": "^4.1.0",
19
19
  "effector": "^23.3.0",
20
20
  "fast-glob": "^3.3.3",
21
- "happy-dom": "^20.0.2",
21
+ "happy-dom": "^20.8.8",
22
22
  "size-limit": "^12.0.0",
23
23
  "svelte": "^5.46.4",
24
24
  "svelte-check": "^4.2.2",
@@ -88,7 +88,7 @@
88
88
  },
89
89
  "module": "./dist/index.js",
90
90
  "peerDependencies": {
91
- "@picked/client": "0.0.50",
91
+ "@picked/client": "0.0.51-next.10",
92
92
  "effector": "^23.3.0",
93
93
  "svelte": "^5.46.4"
94
94
  },
@@ -108,7 +108,7 @@
108
108
  "svelte-check": "svelte-check --tsconfig ./tsconfig.json",
109
109
  "svelte-check:report": "mkdir -p ./.reports && pnpm svelte-check --tsconfig ./tsconfig.json --output=machine > ./.reports/svelte-check.txt || [ -f './.reports/svelte-check.txt' ]",
110
110
  "test": "vitest run",
111
- "test:coverage": "vitest run --coverage",
111
+ "test:coverage": "../../scripts/vitest-coverage.sh",
112
112
  "test:packaged-types": "attw --pack",
113
113
  "test:watch": "vitest",
114
114
  "type-check": "tsgo -p . --noEmit"