@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.
- package/dist/form/create-form.js +1 -0
- package/dist/form/create-form.svelte.d.ts +24 -0
- package/dist/form/create-form.svelte.js +1 -0
- package/dist/form/get-form.d.ts +16 -0
- package/dist/form/get-form.js +1 -0
- package/dist/form/index.d.ts +2 -16
- package/dist/form/index.js +1 -1
- package/dist/hooks/action.d.ts +25 -2
- package/dist/hooks/action.js +1 -1
- package/dist/hooks/field.d.ts +3 -2
- package/dist/hooks/field.js +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/step.d.ts +19 -8
- package/dist/hooks/step.js +1 -1
- package/dist/plugins/devtools/components/ExperienceInstanceListItem.svelte +14 -4
- package/dist/plugins/devtools/components/ExperienceInstancesTable.svelte +1 -1
- package/dist/plugins/devtools/components/Tabs.svelte +22 -2
- package/dist/plugins/devtools/plugin.d.ts +9 -0
- package/dist/plugins/devtools/plugin.js +1 -1
- package/dist/plugins/devtools/screens/events/EventListItem.svelte +127 -37
- package/dist/plugins/devtools/screens/experiences/ExperienceDetailsScreen.svelte +2 -5
- package/dist/plugins/devtools/screens/experiences/ExperienceTreeScreen.svelte +107 -21
- package/dist/plugins/devtools/screens/experiences/ExperiencesListScreen.svelte +2 -1
- package/dist/plugins/devtools/screens/history/HistoryEntryListItem.svelte +7 -1
- package/dist/plugins/devtools/screens/responses/ResponseTableRow.svelte +83 -14
- package/dist/plugins/devtools/screens/responses/ResponsesTable.svelte +15 -6
- package/dist/router/Step.svelte +6 -4
- package/dist/router/StepRouter.svelte +3 -2
- package/dist/router/context.d.ts +1 -1
- package/dist/utils/ErrorBoundary.svelte +1 -1
- 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};
|
package/dist/form/index.d.ts
CHANGED
|
@@ -1,16 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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';
|
package/dist/form/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{getForm as e}from"./get-form.js";export{e as getForm};
|
package/dist/hooks/action.d.ts
CHANGED
|
@@ -1,2 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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>;
|
package/dist/hooks/action.js
CHANGED
|
@@ -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};
|
package/dist/hooks/field.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Field, FieldValue } from '@picked/client';
|
|
2
2
|
import { Store } from 'effector';
|
|
3
|
-
|
|
3
|
+
interface GetFieldResponse {
|
|
4
4
|
value: Store<FieldValue>;
|
|
5
5
|
field: Store<Field | null>;
|
|
6
6
|
}
|
|
7
|
-
export declare function getField(
|
|
7
|
+
export declare function getField(key: string): GetFieldResponse;
|
|
8
|
+
export {};
|
package/dist/hooks/field.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{getPicked as e}from"./client.js";function t(t){let n=e();return{field:n.fields.
|
|
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};
|
package/dist/hooks/index.d.ts
CHANGED
package/dist/hooks/step.d.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { Store } from 'effector';
|
|
2
|
-
import { ExperienceStep
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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 {};
|
package/dist/hooks/step.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{STEP_CONTEXT_KEY as e}from"../context.js";import{
|
|
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>{
|
|
28
|
+
<td>{experienceName}</td>
|
|
19
29
|
<td><Button variant="ghost" size="small" onclick={() => experienceInstance.toggleOpen()}>{$isOpen}</Button></td>
|
|
20
|
-
<td>{
|
|
30
|
+
<td>{currentNodeDisplay}</td>
|
|
21
31
|
<td>{$currentNode?.type || ''}</td>
|
|
22
|
-
<td>{
|
|
23
|
-
<td>{$currentStep?.
|
|
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: '
|
|
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<
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
128
|
+
return fields
|
|
129
|
+
}
|
|
60
130
|
|
|
61
|
-
|
|
62
|
-
|
|
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 ('
|
|
66
|
-
fields.push(
|
|
146
|
+
if ('experience' in payload && payload.experience) {
|
|
147
|
+
fields.push(...getExperienceFields(payload.experience))
|
|
67
148
|
}
|
|
68
149
|
|
|
69
|
-
if ('
|
|
70
|
-
fields.push(
|
|
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.
|
|
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
|
-
$:
|
|
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
|
-
|
|
93
|
-
const
|
|
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'
|
|
103
|
-
return
|
|
104
|
-
if (node.type === '
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
if (
|
|
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 !== '
|
|
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?.
|
|
208
|
-
if (edge?.
|
|
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
|
|
257
|
+
// Label true/false branches from condition nodes
|
|
215
258
|
g.selectAll('.edge-label')
|
|
216
|
-
|
|
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?.
|
|
233
|
-
if (edge?.
|
|
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?.
|
|
244
|
-
if (edge?.
|
|
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 === '
|
|
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.
|
|
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>{
|
|
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
|
-
|
|
121
|
+
const trimmed = input.trim()
|
|
70
122
|
|
|
71
|
-
if (
|
|
123
|
+
if (trimmed === '' || trimmed === '-') {
|
|
72
124
|
return null
|
|
73
125
|
}
|
|
74
126
|
|
|
75
|
-
if (
|
|
127
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
76
128
|
try {
|
|
77
|
-
return JSON.parse(
|
|
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 (
|
|
85
|
-
if (
|
|
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(
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
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 &&
|
|
214
|
+
{#if hasOptions && staticOptions}
|
|
146
215
|
<Stack direction="vertical" gap="small">
|
|
147
|
-
{#each
|
|
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
|
-
|
|
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
|
-
|
|
14
|
+
const fieldsStore = client.fields.listStore
|
|
14
15
|
$: allFields = $fieldsStore ?? []
|
|
15
16
|
|
|
16
|
-
$: responseMap = new Map(responses.map((r) => [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.
|
|
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: '
|
|
46
|
+
{ key: 'key', header: 'Key' },
|
|
38
47
|
{ key: 'field', header: 'Field' },
|
|
39
48
|
{ key: 'value', header: 'Value' },
|
|
40
49
|
{ key: 'type', header: 'Type' },
|
package/dist/router/Step.svelte
CHANGED
|
@@ -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
|
-
|
|
8
|
+
key: string
|
|
8
9
|
component?: Component | Promise<Component> | Promise<{ default: Component }>
|
|
9
10
|
children?: Snippet
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
const {
|
|
13
|
+
const { key, component, children }: Props = $props()
|
|
14
|
+
|
|
13
15
|
|
|
14
16
|
const step = {
|
|
15
|
-
|
|
17
|
+
key,
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
const { registerStep, unregisterStep } = getStepRouter()
|
|
19
21
|
|
|
20
|
-
setStepContext(
|
|
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.
|
|
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.
|
|
55
|
+
fn: ({ steps, currentStep }) => steps.find((s) => s.key === currentStep?.key) || null,
|
|
55
56
|
target: setActiveStepEvent,
|
|
56
57
|
})
|
|
57
58
|
|
package/dist/router/context.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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"
|