First implementation with progressive enhancement form

This commit is contained in:
2023-06-21 00:16:50 +02:00
parent 5e34f24013
commit c1c58118a2
18 changed files with 4647 additions and 3907 deletions
+1
View File
@@ -8,3 +8,4 @@ node_modules
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
/static/tinymce
+5 -2
View File
@@ -3,8 +3,11 @@
"singleQuote": true, "singleQuote": true,
"semi": false, "semi": false,
"trailingComma": "es5", "trailingComma": "es5",
"printWidth": 100, "printWidth": 1000,
"bracketSameLine": true,
"plugins": ["prettier-plugin-svelte"], "plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."], "pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] "overrides": [
{ "files": "*.svelte", "options": { "parser": "svelte" } }
]
} }
+12
View File
@@ -16,6 +16,18 @@
"name": "Firefox Attach", "name": "Firefox Attach",
"type": "firefox", "type": "firefox",
"request": "attach" "request": "attach"
},
{
"name": "Server Launch",
"command": "npx vite dev",
"request": "launch",
"type": "node-terminal"
},
{
"name": "Server Launch (vavite)",
"command": "npx vavite-loader vite dev",
"request": "launch",
"type": "node-terminal"
} }
] ]
} }
BIN
View File
Binary file not shown.
+4126 -3899
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -15,9 +15,12 @@
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0", "@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.5.0", "@sveltejs/kit": "^1.5.0",
"@types/lodash": "^4.14.195",
"@types/qs": "^6.9.7",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.45.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"daisyui": "^3.1.0",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-svelte": "^2.26.0", "eslint-plugin-svelte": "^2.26.0",
@@ -33,5 +36,15 @@
"vite": "^4.3.0", "vite": "^4.3.0",
"vitest": "^0.25.3" "vitest": "^0.25.3"
}, },
"type": "module" "type": "module",
"dependencies": {
"@tinymce/tinymce-svelte": "^1.0.1",
"@vavite/node-loader": "^1.8.1",
"font-color-contrast": "^11.1.0",
"lodash": "^4.17.21",
"qs": "^6.11.2",
"rollup-plugin-copy": "^3.4.0",
"svelte-awesome-color-picker": "^2.4.4",
"tinymce": "^6.4.2"
}
} }
+9
View File
@@ -0,0 +1,9 @@
// Metrics database (in memory for simplicity)
// Keyed by request ID: Stores the test ID, session ID, and duration of each request
export const metricsDb = new Map<string, {
testId: string,
sessionId: string,
start: Date,
end: Date,
}>()
+41
View File
@@ -0,0 +1,41 @@
import { writable } from 'svelte/store'
export type Test = {
id: string
enabled: boolean
chance: number
title: string
body: string
color: string
}
// Example data
export const tests = writable<Test[]>([
{
id: Math.random().toString(36).slice(2),
enabled: false,
chance: 0.5,
title: 'Test 1',
body: 'This is the body of test 1',
color: 'red'
},
{
id: Math.random().toString(36).slice(2),
enabled: false,
chance: 0.5,
title: 'Test 2',
body: 'This is the body of test 2',
color: 'blue'
},
{
id: Math.random().toString(36).slice(2),
enabled: false,
chance: 0.5,
title: 'Test 3',
body: 'This is the body of test 3',
color: 'green'
},
])
// Maps session ID to test ID to keep track of which test each user is in
export const userTests = new Map<string, string>()
+29 -1
View File
@@ -2,4 +2,32 @@
import '../app.css' import '../app.css'
</script> </script>
<slot /> <html lang="en" data-theme="retro">
<div class="navbar bg-base-100">
<div class="navbar-start">
<div class="dropdown">
<label tabindex="0" class="btn-ghost btn lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
</label>
<ul tabindex="0" class="dropdown-content menu rounded-box menu-sm mt-3 w-52 bg-base-100 p-2 shadow">
<li><a href="/">Home</a></li>
<li><a href="/dash">Dashboard</a></li>
</ul>
</div>
<a class="btn-ghost btn text-xl normal-case" href="/">Relevant A/B testing</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="/">Home</a></li>
<li><a href="/dash">Dashboard</a></li>
</ul>
</div>
<div class="navbar-end">
<a class="btn-primary btn min-w-0" href="/logout">Clear session cookie</a>
</div>
</div>
<slot />
<div class="block h-32"></div>
</html>
+46
View File
@@ -0,0 +1,46 @@
import { metricsDb } from '$lib/server/metrics';
import { tests as testsStore, type Test, userTests } from '$lib/server/tests'
import { get } from 'svelte/store'
export async function load({ cookies, params }) {
// Set a random session ID cookie if there is none, so we can uniquely
// identify the user and not randomly change their experience on every page load.
if (!cookies.get('sessionId')) {
cookies.set('sessionId', Math.random().toString(36).slice(2), { sameSite: 'strict' });
}
// Pick a test based on chance
let test: Test
const tests = get(testsStore).filter(test => test.enabled)
if (tests.length === 0) {
throw new Error('No tests enabled')
}
// If user already has a test assigned, use that
test = tests.find(test => test.id === userTests.get(cookies.get('sessionId')!)!)!
if (test === undefined || !test.enabled) { // If the assigned test was disabled after the fact, pick a new one anyways
const sum = tests.reduce((acc, test) => acc + test.chance, 0)
const rand = Math.random() * sum
let cumulative = 0
test = tests.find(test => {
cumulative += test.chance
return rand <= cumulative
})!
userTests.set(cookies.get('sessionId')!, test.id)
}
// Random request ID for metrics logging
const requestId = Math.random().toString(36).slice(2)
metricsDb.set(requestId, {
testId: test.id,
sessionId: cookies.get('sessionId')!,
start: new Date(),
end: new Date(),
})
return {
test,
requestId
}
}
+30 -2
View File
@@ -1,2 +1,30 @@
<h1>Welcome to SvelteKit</h1> <script lang="ts">
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p> import { browser } from '$app/environment'
import fontColorContrast from 'font-color-contrast'
import { onDestroy, onMount } from 'svelte'
export let data
// Auto-select a font color that contrasts with the background color ✨
let fontColor: string
$: data.test && (fontColor = fontColorContrast(data.test.color))
// Client-side metrics (visit duration etc)
// Sending metrics every second since I'm not sure if onbeforeunload is reliable?
if (browser) {
const interval = setInterval(() => {
fetch(`/dash/metrics/${data.requestId}`, {
method: 'POST',
body: JSON.stringify({
path: window.location.pathname,
}),
})
}, 1000)
onDestroy(() => clearInterval(interval))
}
</script>
<div class="container mx-auto mt-16 w-full max-w-screen-lg rounded-lg p-4 shadow-lg" style="background-color: {data.test?.color}; color: {fontColor}">
<h1 class="text-4xl font-bold">{@html data.test?.title}</h1>
<p class="text-lg">{@html data.test?.body}</p>
</div>
+108
View File
@@ -0,0 +1,108 @@
import { metricsDb } from '$lib/server/metrics.js'
import { tests as testsStore, type Test } from '$lib/server/tests'
import { get } from 'svelte/store'
import _ from 'lodash'
import qs from 'qs'
import { fail } from '@sveltejs/kit'
export const load = async ({ params }) => {
// Concatenate tests with analytics
const tests = get(testsStore).map(test => {
const testMetrics = [ ...metricsDb.values() ].filter(metrics => metrics.testId === test.id)
const views = testMetrics.length
const averageDuration = testMetrics.reduce((acc, metrics) => acc + (metrics.end.getTime() - metrics.start.getTime()), 0) / views
return {
...test,
analytics: {
views,
duration: averageDuration / 1000
}
}
}) as (Test & { analytics?: { views: number, duration: number } })[]
return { tests }
}
export const actions = {
default: async ({ request }) => {
const form = await request.formData()
// HACK: Sveltekit's progressive enhancement tends to send the form as
// multipart, so we convert it to a query string so we can parse it using qs
// and save time on trying to group the fields manually.
const formAsQueryString = Array.from(form.entries()).map(([key, value]) => `${key}=${value}`).join('&')
// Parse form body with qs
const parsed = qs.parse(formAsQueryString) as { test: { id: string, enabled: string[], chance: string, title: string, body: string, color: string }[] }
// Validate
for (const parsedTest of parsed.test) {
if (parsedTest.title.length === 0) {
return fail(400, { message: 'Title cannot be empty' })
} else if (parsedTest.body.length === 0) {
return fail(400, { message: 'Body cannot be empty' })
} else if (parsedTest.color.length === 0) {
return fail(400, { message: 'Color is required' })
} else if (parsedTest.chance.length === 0) {
return fail(400, { message: 'Chance is required' })
} else if (isNaN(parseFloat(parsedTest.chance.toString()))) {
return fail(400, { message: 'Chance must be a number' })
}
}
if (parsed.test.every(parsedTest => _.first(parsedTest.enabled) !== 'on')) {
return fail(400, { message: 'At least one test must be enabled' })
}
// Update tests in store
testsStore.update((tests) => {
// Delete missing IDs
tests = tests.filter(test => parsed.test.some(parsedTest => parsedTest.id === test.id))
// Update or create
for (const parsedTest of parsed.test) {
const test = tests.find(test => test.id === parsedTest.id)
if (test) {
test.enabled = _.first(parsedTest.enabled) === 'on'
test.chance = parseFloat(parsedTest.chance.toString())
test.title = parsedTest.title.toString()
test.body = parsedTest.body.toString()
test.color = parsedTest.color.toString()
} else {
tests.push({
id: Math.random().toString(36).slice(2),
enabled: _.first(parsedTest.enabled) === 'on',
chance: parseFloat(parsedTest.chance.toString()),
title: parsedTest.title.toString(),
body: parsedTest.body.toString(),
color: parsedTest.color.toString()
})
}
}
return tests
})
return { success: true }
}
}
type StructuredFormData =
| string
| boolean
| number
| File
| StructuredFormData[];
function formBody(body: FormData) {
return [...body.entries()].reduce((data, [k, v]) => {
let value: StructuredFormData = v;
if (v === "true") value = true;
if (v === "false") value = false;
if (!isNaN(Number(v))) value = Number(v);
// For grouped fields like multi-selects and checkboxes, we need to
// store the values in an array.
if (k in data) {
const val = data[k];
value = Array.isArray(val) ? [...val, value] : [val, value];
}
data[k] = value;
return data;
}, {} as Record<string, StructuredFormData>);
}
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
import { enhance } from '$app/forms'
import { page } from '$app/stores'
import type { Test } from '$lib/server/tests'
import { tick } from 'svelte'
import AB from './AB.svelte'
export let data
export let form
function addNew() {
data.tests = [
...data.tests,
{
id: Math.random().toString(36).slice(2),
enabled: true,
chance: 0.5,
title: 'New Test',
body: '',
color: '#ffffff',
},
]
}
function deleteTest(index: number) {
data.tests.splice(index, 1)
data.tests = data.tests
}
function onChanceChanged(test: Test) {
// Adjust other enabled chances to make them add up to 1
const tests = data.tests.filter((t) => t.enabled)
const otherTests = tests.filter((t) => t.id !== test.id)
const totalChance = tests.reduce((acc, t) => acc + t.chance, 0)
const diff = 1 - totalChance
const diffPerTest = diff / otherTests.length
otherTests.forEach((t) => (t.chance = Math.round(Math.max(0, Math.min(t.chance + diffPerTest, 1) * 100)) / 100))
}
function preventSubmit(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
}
}
</script>
<div class="container mx-auto mt-16 w-full max-w-screen-lg">
<h1 class="text-4xl font-bold">Dashboard</h1>
<p class="mt-2">Manage your A/B tests here.</p>
<form method="POST" on:keydown={preventSubmit} use:enhance>
<div class="mt-6">
<div class="flex flex-col gap-6">
{#each data.tests as test, index (test.id)}
<AB i={index} bind:data={test} on:delete={() => deleteTest(index)} on:chanceChanged={() => onChanceChanged(test)} />
{/each}
</div>
</div>
{#if form?.success}
<div class="alert alert-success mt-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>Changes saved successfully.</span>
</div>
{/if}
{#if $page.status === 400 && form?.message}
<div class="alert alert-warning mt-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>{form?.message}</span>
</div>
{/if}
<div class="mt-6 flex justify-end">
<button class="btn-bordered btn-primary btn">Save</button>
<button class="btn-bordered btn-secondary btn ml-2" on:click|preventDefault={addNew}>Add New</button>
</div>
</form>
</div>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import ColorPicker from 'svelte-awesome-color-picker'
import Editor from '@tinymce/tinymce-svelte'
import { createEventDispatcher } from 'svelte'
import type { Test } from '$lib/server/tests'
import { tick } from 'svelte'
const dispatch = createEventDispatcher()
export let i: number
export let data: Test & { analytics?: { views: number; duration: number } }
function deleteSelf(this: HTMLButtonElement) {
if (this.innerHTML !== 'Delete?') {
this.innerHTML = 'Delete?'
} else {
this.disabled = true
dispatch('delete')
}
}
function chanceChanged(this: HTMLInputElement) {
if (data.enabled) {
dispatch('chanceChanged', data.chance)
}
}
let chancePercent: number
$: chancePercent = Math.round(data.chance * 100)
// On enable, send a chanceChanged event so that the parent can adjust other enabled chances to make them add up to 1
$: if (data.enabled) {
dispatch('chanceChanged', data.chance)
}
const enabled = data.enabled
</script>
<details class="collapse collapse-arrow rounded-lg" open={enabled} >
<summary class="collapse-title text-xl font-medium leading-tight bg-base-300 min-h-min">
<div class="flex items-center gap-2">
<input type="checkbox" name="test[{i}][enabled]" class="toggle toggle-accent" bind:checked={data.enabled} />
<input type="hidden" name="test[{i}][enabled]" value='off' />
<span style="color: {data.color}">{data.title}</span>
<!-- <span>{data.id}</span> -->
<input type="range" name="test[{i}][chance]" class="range max-w-xs ml-20" min="0" max="1" step="0.01" bind:value={data.chance} on:input={() => chancePercent = Math.round(data.chance * 100)} on:input={chanceChanged} />
<input type="text" class="input input-ghost w-16" bind:value={chancePercent} on:input={() => data.chance = chancePercent / 100} on:input={chanceChanged} />
<!-- <span>{Math.round(data.chance * 100)}%</span> -->
<!-- <span>{data.chance}</span> -->
</div>
</summary>
<div class="collapse-content flex w-full flex-col flex-wrap gap-4 bg-base-200">
<input type="hidden" name="test[{i}][id]" value={data.id} />
<div class="flex flex-row gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Background Colour</span>
</label>
<input type="hidden" name="test[{i}][color]" value={data.color} />
<ColorPicker bind:hex={data.color} isOpen={true} isPopup={false} isInput={false} />
</div>
<div class="grow">
<div class="form-control w-full">
<label class="label">
<span class="label-text">Title
{#if data.title.length == 0}
<span class="text-red-500"> (required)</span>
{/if}
</span>
</label>
<input type="text" name="test[{i}][title]" bind:value={data.title} class="input-bordered input" class:input-error={data.title.length === 0} />
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text">Body
{#if data.body.length == 0}
<span class="text-red-500"> (required)</span>
{/if}
</span>
</label>
<input type="hidden" name="test[{i}][body]" value={data.body} />
<Editor scriptSrc="tinymce/tinymce.min.js" conf={{}} bind:value={data.body} />
</div>
<div class="mt-4 flex justify-end">
<button
class="btn-error btn-sm btn"
on:click|preventDefault={deleteSelf}
on:blur={function () {
this.innerHTML = 'Delete'
}}>Delete</button>
</div>
</div>
</div>
{#if data.analytics}
<div>
<h1 class="text-2xl font-bold">Analytics</h1>
<p>Page views: {data.analytics.views}</p>
<p>Average duration: {isNaN(data.analytics.duration) ? '???' : Math.round(data.analytics.duration)} sec</p>
</div>
{/if}
</div>
</details>
<style>
:global(.color-picker .wrapper) {
margin: 0;
}
</style>
@@ -0,0 +1,13 @@
import { metricsDb } from "$lib/server/metrics";
import { json } from "@sveltejs/kit";
export async function POST({ params, cookies, request }) {
// Update metrics page view end time
const { requestId } = params
const metrics = metricsDb.get(requestId)
if (!metrics) {
throw new Error('No metrics found for request ID')
}
metrics.end = new Date()
return new Response(null, { status: 200 })
}
+13
View File
@@ -0,0 +1,13 @@
import { userTests } from '$lib/server/tests.js'
export async function GET({ cookies }) {
userTests.delete(cookies.get('sessionId')!)
cookies.delete('sessionId')
return new Response(null, {
status: 302,
headers: {
Location: '/',
},
})
}
+4 -1
View File
@@ -4,5 +4,8 @@ export default {
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [], plugins: [require('daisyui')],
daisyui: {
themes: ['light', 'dark', 'retro']
}
} }
+7 -1
View File
@@ -1,8 +1,14 @@
import { sveltekit } from '@sveltejs/kit/vite' import { sveltekit } from '@sveltejs/kit/vite'
import copy from 'rollup-plugin-copy'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import { nodeLoaderPlugin } from '@vavite/node-loader/plugin'
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [
// nodeLoaderPlugin(),
sveltekit(),
copy({ targets: [{ src: 'node_modules/tinymce/*', dest: 'static/tinymce' }] }),
],
test: { test: {
include: ['src/**/*.{test,spec}.{js,ts}'], include: ['src/**/*.{test,spec}.{js,ts}'],
}, },