@picked/svelte 0.0.41 → 0.0.42-next.2

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.
@@ -10,6 +10,8 @@
10
10
  import ProjectScreen from './screens/project/ProjectScreen.svelte'
11
11
  import HistoryEntriesListScreen from './screens/history/HistoryEntriesListScreen.svelte'
12
12
  import ResponsesListScreen from './screens/responses/ResponsesListScreen.svelte'
13
+ import FeaturesListScreen from './screens/features/FeaturesListScreen.svelte'
14
+ import EventsListScreen from './screens/events/EventsListScreen.svelte'
13
15
 
14
16
  const experience = getExperience()
15
17
 
@@ -26,6 +28,12 @@
26
28
  component: HistoryEntriesListScreen,
27
29
  props: {},
28
30
  },
31
+ {
32
+ id: 'events',
33
+ label: 'Events',
34
+ component: EventsListScreen,
35
+ props: {},
36
+ },
29
37
  {
30
38
  id: 'responses',
31
39
  label: 'Responses',
@@ -50,6 +58,12 @@
50
58
  component: PredicatesListScreen,
51
59
  props: {},
52
60
  },
61
+ {
62
+ id: 'features',
63
+ label: 'Features',
64
+ component: FeaturesListScreen,
65
+ props: {},
66
+ },
53
67
  {
54
68
  id: 'components',
55
69
  label: 'Components',
@@ -20,7 +20,7 @@
20
20
  </tr>
21
21
  </thead>
22
22
  <tbody>
23
- {#each experienceInstances as experienceInstance (experienceInstance.experienceId)}
23
+ {#each experienceInstances as experienceInstance (experienceInstance.id)}
24
24
  <ExperienceInstanceListItem {experienceInstance} />
25
25
  {/each}
26
26
  </tbody>
@@ -0,0 +1,142 @@
1
+ <script lang="ts">
2
+ import type { EventBusPayload, EventHistoryEntry } from '@picked/client'
3
+
4
+ export let event: EventHistoryEntry
5
+ export let isExpanded: boolean
6
+ export let onToggle: () => void
7
+
8
+ function formatTime(date: Date): string {
9
+ return date.toLocaleTimeString('en-US', {
10
+ hour12: false,
11
+ hour: '2-digit',
12
+ minute: '2-digit',
13
+ second: '2-digit',
14
+ fractionalSecondDigits: 3,
15
+ })
16
+ }
17
+
18
+ interface PayloadField {
19
+ label: string
20
+ value: unknown
21
+ }
22
+
23
+ function getPayloadFields(payload: EventBusPayload): PayloadField[] {
24
+ const fields: PayloadField[] = []
25
+
26
+ // Add type
27
+ fields.push({ label: 'Type', value: payload.type })
28
+
29
+ // Add optional value field if present
30
+ if ('value' in payload && payload.value !== undefined) {
31
+ fields.push({ label: 'Value', value: payload.value })
32
+ }
33
+
34
+ // Add experience identifiers if present
35
+ if ('experience' in payload && payload.experience) {
36
+ const exp = payload.experience
37
+ if (exp && typeof exp === 'object') {
38
+ if ('id' in exp && exp.id) {
39
+ fields.push({ label: 'Experience ID', value: exp.id })
40
+ }
41
+ if ('experienceId' in exp && exp.experienceId) {
42
+ fields.push({ label: 'Experience Type', value: exp.experienceId })
43
+ }
44
+ }
45
+ }
46
+
47
+ // Add step information if present
48
+ if ('step' in payload && payload.step) {
49
+ const step = payload.step
50
+ if (step && typeof step === 'object') {
51
+ if ('id' in step && step.id) {
52
+ fields.push({ label: 'Step ID', value: step.id })
53
+ }
54
+ if ('label' in step && step.label) {
55
+ fields.push({ label: 'Step Label', value: step.label })
56
+ }
57
+ }
58
+ }
59
+
60
+ // Add form information if present
61
+ if ('form' in payload && payload.form) {
62
+ fields.push({ label: 'Form', value: JSON.stringify(payload.form) })
63
+ }
64
+
65
+ // Add feature information if present
66
+ if ('feature' in payload && payload.feature) {
67
+ fields.push({ label: 'Feature', value: JSON.stringify(payload.feature) })
68
+ }
69
+
70
+ // Add exception/validation errors if present
71
+ if ('exception' in payload && payload.exception) {
72
+ fields.push({ label: 'Exception', value: JSON.stringify(payload.exception) })
73
+ }
74
+
75
+ // Add version if present
76
+ if ('version' in payload && payload.version) {
77
+ fields.push({ label: 'Version', value: payload.version })
78
+ }
79
+
80
+ // Add session id if present
81
+ if ('id' in payload && payload.id && payload.type === 'session') {
82
+ fields.push({ label: 'Session ID', value: payload.id })
83
+ }
84
+
85
+ return fields
86
+ }
87
+
88
+ function formatValue(value: unknown): string {
89
+ if (value === null) return 'null'
90
+ if (value === undefined) return 'undefined'
91
+ if (typeof value === 'string') return value
92
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
93
+ return JSON.stringify(value)
94
+ }
95
+ </script>
96
+
97
+ <tr on:click={onToggle} style="cursor: pointer;">
98
+ <td style="font-family: monospace; font-size: 12px;">
99
+ <span style="margin-right: 0.5rem; display: inline-block; width: 12px;">
100
+ {isExpanded ? '▼' : '▶'}
101
+ </span>
102
+ {formatTime(event.at)}
103
+ </td>
104
+ <td>
105
+ <code
106
+ style="display: inline-block; padding: 0.25rem 0.5rem; background-color: #e8f4ff; border: 1px solid #b3d9ff; border-radius: 3px; font-size: 12px; color: #0066cc;"
107
+ >
108
+ {event.payload.type}
109
+ </code>
110
+ </td>
111
+ <td style="font-size: 13px; color: #333;">
112
+ {'experience' in event.payload && event.payload.experience ? event.payload.experience.experienceId : '-'}
113
+ </td>
114
+ <td style="font-size: 12px; color: #666; font-style: italic;">
115
+ {isExpanded ? 'Click to collapse' : 'Click to expand payload'}
116
+ </td>
117
+ </tr>
118
+ {#if isExpanded}
119
+ <tr>
120
+ <td colspan="4" style="background-color: #f8f9fa; padding: 1rem; border-left: 3px solid #0066cc;">
121
+ <div style="font-weight: 600; margin-bottom: 0.5rem; font-size: 13px; color: #333;">
122
+ Event Payload
123
+ </div>
124
+ <div
125
+ style="background-color: #fff; padding: 0.75rem; border: 1px solid #e0e0e0; border-radius: 4px;"
126
+ >
127
+ {#each getPayloadFields(event.payload) as field}
128
+ <div style="margin-bottom: 0.5rem; font-size: 12px;">
129
+ <span style="font-weight: 600; color: #555; margin-right: 0.5rem;"
130
+ >{field.label}:</span
131
+ >
132
+ <code
133
+ style="font-family: 'Monaco', 'Menlo', 'Courier New', monospace; background-color: #f0f0f0; padding: 0.125rem 0.25rem; border-radius: 2px; color: #333;"
134
+ >
135
+ {formatValue(field.value)}
136
+ </code>
137
+ </div>
138
+ {/each}
139
+ </div>
140
+ </td>
141
+ </tr>
142
+ {/if}
@@ -0,0 +1,19 @@
1
+ <script lang="ts">
2
+ import { getPicked } from '../../../../hooks'
3
+ import EventsTable from './EventsTable.svelte'
4
+
5
+ const { events } = getPicked()
6
+ </script>
7
+
8
+ <div style="display: flex; flex-direction: column; height: 100%;">
9
+ <EventsTable />
10
+ <div
11
+ style="margin-top: auto; padding: 0.5rem; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid lightgray;"
12
+ >
13
+ <div style="display: flex; gap: 0.5rem; align-items: center;">
14
+ <span style="font-size: 13px; color: #666;">
15
+ {$events.length} events
16
+ </span>
17
+ </div>
18
+ </div>
19
+ </div>
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import { combine, createStore, createEvent, sample } from 'effector'
3
+ import { getPicked } from '../../../../hooks'
4
+ import EventListItem from './EventListItem.svelte'
5
+ import type { EventHistoryEntry } from '@picked/client'
6
+
7
+ const { events } = getPicked()
8
+
9
+ let expandedRowIds = new Set<number>()
10
+
11
+ function toggleRow(eventId: number) {
12
+ const newSet = new Set(expandedRowIds)
13
+ if (newSet.has(eventId)) {
14
+ newSet.delete(eventId)
15
+ } else {
16
+ newSet.add(eventId)
17
+ }
18
+ expandedRowIds = newSet
19
+ }
20
+
21
+
22
+ const sortDirectionStore = createStore<'asc' | 'desc'>('desc')
23
+ const toggleSortDirection = createEvent<void>()
24
+ const oppositeSortDirection: Record<'asc' | 'desc', 'asc' | 'desc'> = { asc: 'desc', desc: 'asc' }
25
+
26
+ sample({
27
+ clock: toggleSortDirection,
28
+ source: sortDirectionStore,
29
+ fn: (dir) => oppositeSortDirection[dir],
30
+ target: sortDirectionStore,
31
+ })
32
+
33
+ function sortEvents(history: EventHistoryEntry[], sort: 'asc' | 'desc'): EventHistoryEntry[] {
34
+ return [...history].sort((a, b) => {
35
+ const diff = a.at.getTime() - b.at.getTime()
36
+ return sort === 'asc' ? diff : -diff
37
+ })
38
+ }
39
+
40
+ const sortedEvents = combine(events, sortDirectionStore, sortEvents)
41
+ </script>
42
+
43
+ <table>
44
+ <thead>
45
+ <tr>
46
+ <th
47
+ style="width: 140px; cursor: pointer; user-select: none;"
48
+ on:click={() => toggleSortDirection()}
49
+ >
50
+ Time {$sortDirectionStore === 'asc' ? '↑' : '↓'}
51
+ </th>
52
+ <th style="width: 200px;">Event Type</th>
53
+ <th style="width: 180px;">Experience</th>
54
+ <th></th>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ {#if $sortedEvents.length === 0}
59
+ <tr>
60
+ <td colspan="4" style="text-align: center; padding: 2rem; color: #666;">
61
+ No events yet. Interact with the page to see events.
62
+ </td>
63
+ </tr>
64
+ {:else}
65
+ {#each $sortedEvents as event (event.at)}
66
+ <EventListItem
67
+ {event}
68
+ isExpanded={expandedRowIds.has(event.id)}
69
+ onToggle={() => toggleRow(event.id)}
70
+ />
71
+ {/each}
72
+ {/if}
73
+ </tbody>
74
+ </table>
@@ -0,0 +1,309 @@
1
+ <script lang="ts">
2
+ import { createStore, createEvent } from 'effector'
3
+ import type { FeatureBucket } from '@picked/client'
4
+ import { getPicked, useUnit } from '../../../../hooks'
5
+
6
+ interface FeatureSelection {
7
+ included: boolean
8
+ value: string
9
+ }
10
+
11
+ const { features: { listStore: featuresStore } } = getPicked()
12
+ const featuresList = useUnit(featuresStore)
13
+
14
+ const initSelections = createEvent<FeatureBucket[]>()
15
+ const featureToggled = createEvent<string>()
16
+ const valueChanged = createEvent<{ id: string; value: string }>()
17
+ const copySucceeded = createEvent()
18
+ const copyReset = createEvent()
19
+
20
+ const selectionsStore = createStore<Record<string, FeatureSelection>>({})
21
+ // Triggered whenever the features list changes. Adds an entry for any feature
22
+ // not yet tracked, seeding its override value from the already-resolved bucket
23
+ // (or 'true' as a sensible default for unresolved boolean features).
24
+ .on(initSelections, (state, feats) => {
25
+ const next = { ...state }
26
+ feats.forEach((feature) => {
27
+ if (!(feature.id in next)) {
28
+ const bucket = feature.isResolved ? feature.getBucket().bucket : null
29
+ next[feature.id] = {
30
+ included: false,
31
+ value: bucket !== null ? String(bucket) : 'true',
32
+ }
33
+ }
34
+ })
35
+ return next
36
+ })
37
+ // Toggles whether the feature is included in the generated URL.
38
+ .on(featureToggled, (state, id) => ({
39
+ ...state,
40
+ [id]: { ...state[id], included: !state[id].included },
41
+ }))
42
+ // Updates the override value for a feature (edited via the text/select input).
43
+ .on(valueChanged, (state, { id, value }) => ({
44
+ ...state,
45
+ [id]: { ...state[id], value },
46
+ }))
47
+
48
+ const copySuccessStore = createStore(false)
49
+ .on(copySucceeded, () => true) // Show "Copied!" feedback after a successful clipboard write.
50
+ .on(copyReset, () => false) // Reset back to "Copy" after the feedback timeout.
51
+
52
+ const selections = useUnit(selectionsStore)
53
+ const copySuccess = useUnit(copySuccessStore)
54
+
55
+ $effect(() => {
56
+ initSelections($featuresList)
57
+ })
58
+
59
+ let urlInputRef: HTMLInputElement | null = $state(null)
60
+
61
+ function getBucketValue(feature: FeatureBucket): string | null {
62
+ return feature.isResolved ? String(feature.getBucket().bucket) : null
63
+ }
64
+
65
+ function isBooleanFeature(feature: FeatureBucket): boolean {
66
+ if (!feature.isResolved) return true
67
+ const bucket = getBucketValue(feature)
68
+ return bucket === 'true' || bucket === 'false'
69
+ }
70
+
71
+ const generatedUrl = $derived.by(() => {
72
+ const url = new URL(window.location.href)
73
+ url.searchParams.delete('picked-feature')
74
+ const featureParams = $featuresList
75
+ .filter((feature) => $selections[feature.id]?.included)
76
+ .map((feature) => `picked-feature=${feature.id}:${$selections[feature.id].value}`)
77
+ const base = url.toString()
78
+ if (featureParams.length === 0) return base
79
+ const separator = url.search ? '&' : '?'
80
+ return base + separator + featureParams.join('&')
81
+ })
82
+
83
+ const hasSelections = $derived(Object.values($selections).some((s) => s.included))
84
+
85
+ async function copyUrl() {
86
+ try {
87
+ await navigator.clipboard.writeText(generatedUrl)
88
+ copySucceeded()
89
+ setTimeout(copyReset, 2000)
90
+ } catch {
91
+ urlInputRef?.select()
92
+ }
93
+ }
94
+
95
+ function refreshPage() {
96
+ window.location.href = generatedUrl
97
+ }
98
+ </script>
99
+
100
+ <div class="url-builder">
101
+ <p class="description">
102
+ Select feature overrides to generate a URL. Anyone who loads this URL will get the selected
103
+ feature values from page load — before any resolution occurs.
104
+ </p>
105
+
106
+ <table>
107
+ <thead>
108
+ <tr>
109
+ <th style="width: 2.5rem;">Include</th>
110
+ <th>Feature</th>
111
+ <th>Current Value</th>
112
+ <th>Override Value</th>
113
+ </tr>
114
+ </thead>
115
+ <tbody>
116
+ {#each $featuresList as feature (feature.id)}
117
+ {@const selection = $selections[feature.id]}
118
+ {@const currentValue = getBucketValue(feature)}
119
+ {@const isBoolean = isBooleanFeature(feature)}
120
+ {#if selection}
121
+ <tr class:selected={selection.included}>
122
+ <td style="text-align: center;">
123
+ <input
124
+ type="checkbox"
125
+ checked={selection.included}
126
+ onchange={() => featureToggled(feature.id)}
127
+ aria-label="Include {feature.name} in URL"
128
+ />
129
+ </td>
130
+ <td>
131
+ <span style="font-weight: 500;">{feature.name}</span>
132
+ <code
133
+ style="
134
+ display: block;
135
+ font-size: 10px;
136
+ color: #888;
137
+ margin-top: 2px;
138
+ ">{feature.key}</code>
139
+ </td>
140
+ <td>
141
+ {#if currentValue !== null}
142
+ <code style="font-size: 11px;">{currentValue}</code>
143
+ {:else}
144
+ <button onclick={() => feature.getBucket()} style="margin-left: 0.5rem; font-size: 10px; padding: 0.125rem 0.25rem;">
145
+ Click to resolve
146
+ </button>
147
+ {/if}
148
+ </td>
149
+ <td>
150
+ {#if isBoolean}
151
+ <select
152
+ value={selection.value}
153
+ onchange={(e) => valueChanged({ id: feature.id, value: e.currentTarget.value })}
154
+ disabled={!selection.included}
155
+ style="font-size: 11px; padding: 0.125rem 0.25rem;"
156
+ >
157
+ <option value="true">true</option>
158
+ <option value="false">false</option>
159
+ </select>
160
+ {:else}
161
+ <input
162
+ type="text"
163
+ value={selection.value}
164
+ oninput={(e) => valueChanged({ id: feature.id, value: e.currentTarget.value })}
165
+ disabled={!selection.included}
166
+ style="width: 100%; font-size: 11px; padding: 0.125rem 0.25rem;"
167
+ placeholder="value"
168
+ />
169
+ {/if}
170
+ </td>
171
+ </tr>
172
+ {/if}
173
+ {/each}
174
+ </tbody>
175
+ </table>
176
+
177
+ {#if $featuresList.length === 0}
178
+ <div style="padding: 2rem; text-align: center; color: #999;">No features registered</div>
179
+ {/if}
180
+
181
+ <div class="url-output">
182
+ <div class="url-label">Generated URL</div>
183
+ <div class="url-row">
184
+ <input
185
+ bind:this={urlInputRef}
186
+ type="text"
187
+ readonly
188
+ value={generatedUrl}
189
+ class="url-input"
190
+ onclick={(e) => e.currentTarget.select()}
191
+ aria-label="Generated URL"
192
+ />
193
+ <button
194
+ onclick={copyUrl}
195
+ class="action-button copy-button"
196
+ class:success={copySuccess}
197
+ title="Copy URL to clipboard"
198
+ >
199
+ {copySuccess ? 'Copied!' : 'Copy'}
200
+ </button>
201
+ <button
202
+ onclick={refreshPage}
203
+ class="action-button refresh-button"
204
+ disabled={!hasSelections}
205
+ title="Reload the page with this URL"
206
+ >
207
+ Reload
208
+ </button>
209
+ </div>
210
+ {#if !hasSelections}
211
+ <p class="hint">Select at least one feature above to add overrides to the URL.</p>
212
+ {/if}
213
+ </div>
214
+ </div>
215
+
216
+ <style>
217
+ .url-builder {
218
+ display: flex;
219
+ flex-direction: column;
220
+ gap: 0;
221
+ }
222
+
223
+ .description {
224
+ margin: 0;
225
+ padding: 0.75rem 0.5rem;
226
+ font-size: 12px;
227
+ color: #555;
228
+ border-bottom: 1px solid lightgray;
229
+ }
230
+
231
+ tr.selected {
232
+ background-color: #f0f7ff;
233
+ }
234
+
235
+ .url-output {
236
+ padding: 0.75rem 0.5rem;
237
+ border-top: 1px solid lightgray;
238
+ display: flex;
239
+ flex-direction: column;
240
+ gap: 0.5rem;
241
+ }
242
+
243
+ .url-label {
244
+ font-size: 11px;
245
+ font-weight: 600;
246
+ color: #555;
247
+ text-transform: uppercase;
248
+ letter-spacing: 0.05em;
249
+ }
250
+
251
+ .url-row {
252
+ display: flex;
253
+ gap: 0.5rem;
254
+ align-items: center;
255
+ }
256
+
257
+ .url-input {
258
+ flex: 1;
259
+ font-size: 11px;
260
+ font-family: monospace;
261
+ padding: 0.375rem 0.5rem;
262
+ border: 1px solid #ccc;
263
+ border-radius: 3px;
264
+ background-color: #f9f9f9;
265
+ color: #333;
266
+ min-width: 0;
267
+ }
268
+
269
+ .url-input:focus {
270
+ outline: 2px solid #0066cc;
271
+ outline-offset: -1px;
272
+ }
273
+
274
+ .action-button {
275
+ padding: 0.375rem 0.75rem;
276
+ font-size: 11px;
277
+ border: none;
278
+ border-radius: 3px;
279
+ cursor: pointer;
280
+ white-space: nowrap;
281
+ font-weight: 500;
282
+ }
283
+
284
+ .action-button:disabled {
285
+ opacity: 0.5;
286
+ cursor: not-allowed;
287
+ }
288
+
289
+ .copy-button {
290
+ background-color: #2196f3;
291
+ color: white;
292
+ }
293
+
294
+ .copy-button.success {
295
+ background-color: #4caf50;
296
+ }
297
+
298
+ .refresh-button {
299
+ background-color: #555;
300
+ color: white;
301
+ }
302
+
303
+ .hint {
304
+ margin: 0;
305
+ font-size: 11px;
306
+ color: #999;
307
+ font-style: italic;
308
+ }
309
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@picked/svelte",
3
- "version": "0.0.41",
3
+ "version": "0.0.42-next.2",
4
4
  "dependencies": {
5
5
  "d3": "^7.9.0"
6
6
  },
@@ -75,7 +75,7 @@
75
75
  ],
76
76
  "module": "./dist/index.js",
77
77
  "peerDependencies": {
78
- "@picked/client": "0.0.41",
78
+ "@picked/client": "0.0.42-next.2",
79
79
  "effector": "^23.3.0",
80
80
  "svelte": "^5.34.3"
81
81
  },