Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useProgress } from './shell/Progress'
import { AltGlyph, Button, IconButton, Tag, cx } from './ui'
import { SavedIndicator } from './shell/SavedIndicator'
import { ThemeToggle } from './shell/ThemeToggle'
import { FeedbackButton } from './feedback/Feedback'

// The primary action follows the first step not done yet; Export takes over at the end.
const NEXT_ACTION: Partial<Record<SectionId, { label: string; opts?: GoToOptions }>> = {
Expand Down Expand Up @@ -123,6 +124,7 @@ export default function TopBar({ project }: { project: Project }) {

<div className="ml-auto flex shrink-0 items-center gap-2">
<SavedIndicator className="mr-1.5 hidden lg:inline-flex" />
<FeedbackButton look="button" className="hidden md:inline-flex" />
<ThemeToggle />
{showPreview && (
<>
Expand Down
2 changes: 1 addition & 1 deletion app/components/export/PlatformCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function PlatformCard({
</div>
<div className="flex flex-1 flex-col gap-1.5">
<p className="text-13 leading-[18px] text-ink-3">{description}</p>
{howTo != null && <p className="text-12 leading-[17px] text-ink-3">{howTo}</p>}
{howTo != null && <div className="text-12 leading-[17px] text-ink-3">{howTo}</div>}
</div>
<div className="flex flex-wrap gap-1.5">{children}</div>
{error && (
Expand Down
158 changes: 158 additions & 0 deletions app/components/feedback/Feedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use client'

import { useEffect, useId, useRef, useState } from 'react'
import { describeBrowser, feedbackUrl, MAX_MESSAGE, type FeedbackKind } from '@/lib/feedback'
import { APP_VERSION } from '../shell/Rail'
import { Button, Checkbox, Field, Input, Seg, Textarea, cx } from '../ui'
import { XMark } from '../Icons'

const KINDS: { value: FeedbackKind; label: string }[] = [
{ value: 'bug', label: 'Something’s wrong' },
{ value: 'idea', label: 'An idea' },
{ value: 'question', label: 'A question' },
]

const PLACEHOLDER: Record<FeedbackKind, { title: string; message: string }> = {
bug: {
title: 'e.g. QSF import shows every block',
message: 'What did you do, what did you expect, and what happened instead?',
},
idea: {
title: 'e.g. Presets for travel time and cost',
message: 'What are you trying to do, and where does UtilityLab fall short?',
},
question: { title: 'e.g. How should I set priors for cost?', message: 'Your question' },
}

function FeedbackDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const ref = useRef<HTMLDialogElement>(null)
const uid = useId()
const [kind, setKind] = useState<FeedbackKind>('bug')
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [withContext, setWithContext] = useState(true)

useEffect(() => {
const d = ref.current
if (!d) return
if (open && !d.open) d.showModal()
else if (!open && d.open) d.close()
}, [open])

const send = () => {
const url = feedbackUrl({
kind,
title,
message,
context: withContext ? { version: APP_VERSION, browser: describeBrowser(navigator.userAgent) } : undefined,
})
window.open(url, '_blank', 'noopener,noreferrer')
onClose()
}

return (
<dialog
ref={ref}
aria-labelledby={`${uid}-title`}
onClose={onClose}
className="w-[min(560px,calc(100vw-32px))] rounded-hero border border-line bg-surface p-0 text-ink shadow-raised backdrop:bg-ink/40 backdrop:backdrop-blur-[2px]"
>
<form
method="dialog"
onSubmit={(e) => {
e.preventDefault()
send()
}}
>
<div className="flex items-start justify-between gap-4 px-6 pt-6 sm:px-7">
<div>
<h2 id={`${uid}-title`} className="font-display text-26 font-semibold tracking-title">
Send feedback
</h2>
<p className="mt-1.5 text-13 text-ink-3">
Bugs, ideas and questions all help, especially from people running stated choice experiments.
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close feedback"
className="focus-ring -mr-2 -mt-1 rounded-ctl p-2 text-ink-3 hover:bg-surface-3 hover:text-ink"
>
<XMark size={16} />
</button>
</div>

<div className="mt-5 space-y-4 px-6 sm:px-7">
<Seg ariaLabel="Kind of feedback" options={KINDS} value={kind} onChange={setKind} className="max-w-full flex-wrap" />
<Field label="Title">
{(fid) => (
<Input id={fid} value={title} onChange={(e) => setTitle(e.target.value)} placeholder={PLACEHOLDER[kind].title} required />
)}
</Field>
<Field label="Details" optional hint={`Up to ${MAX_MESSAGE.toLocaleString('en')} characters; you can add more on GitHub.`}>
{(fid, describedBy) => (
<Textarea
id={fid}
aria-describedby={describedBy}
rows={5}
value={message}
maxLength={MAX_MESSAGE}
onChange={(e) => setMessage(e.target.value)}
placeholder={PLACEHOLDER[kind].message}
/>
)}
</Field>
<Checkbox
checked={withContext}
onChange={setWithContext}
label={`Include the app version (v${APP_VERSION}) and your browser`}
/>
</div>

<div className="mt-5 border-t border-line px-6 py-4 sm:px-7">
<p className="text-12 text-ink-3">
This opens GitHub in a new tab with your feedback filled in; nothing is sent until you submit it there, and you
need a free GitHub account. GitHub issues are public, so leave out anything confidential about your study. If a
study file would help with a bug, attach it on GitHub after removing anything sensitive.
</p>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!title.trim()} className="enabled:border-ink">
Continue on GitHub
</Button>
</div>
</div>
</form>
</dialog>
)
}

// A button that opens its own feedback dialog. `look` matches where it sits.
export function FeedbackButton({ look = 'link', className }: { look?: 'link' | 'button'; className?: string }) {
const [open, setOpen] = useState(false)
return (
<>
{look === 'button' ? (
<Button variant="ghost" size="md" onClick={() => setOpen(true)} aria-haspopup="dialog" className={className}>
Feedback
</Button>
) : (
<button
type="button"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
className={cx(
'focus-ring whitespace-nowrap rounded-tick underline decoration-line-2 underline-offset-[3px] transition-colors hover:text-ink hover:decoration-ink-3',
className,
)}
>
Feedback
</button>
)}
<FeedbackDialog open={open} onClose={() => setOpen(false)} />
</>
)
}
2 changes: 2 additions & 0 deletions app/components/landing/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ChevronRight, Upload } from '../Icons'
import { SkipLink } from '../shell/AppShell'
import { APP_VERSION, CreditLink } from '../shell/Rail'
import { LegalLinks } from '../legal/LegalLinks'
import { FeedbackButton } from '../feedback/Feedback'
import { ThemeToggle } from '../shell/ThemeToggle'
import { Button, buttonBase, buttonVariants, cx } from '../ui'
import { Film } from './Film'
Expand Down Expand Up @@ -177,6 +178,7 @@ export function Landing({
<span>
UtilityLab <span className="font-mono text-12">v{APP_VERSION}</span>
</span>
<FeedbackButton />
<LegalLinks />
</span>
<CreditLink />
Expand Down
2 changes: 2 additions & 0 deletions app/components/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useReviewOnDwell } from './Progress'
import { ExampleBanner } from '../library/ExampleBanner'
import { Walkthrough, WalkthroughLink } from './Walkthrough'
import { LegalLinks } from '../legal/LegalLinks'
import { FeedbackButton } from '../feedback/Feedback'

export const canvasClass = 'min-w-0 px-4 pb-24 pt-8 sm:px-8 lg:px-11 lg:pt-10'
export const contentClass = 'mx-auto max-w-[1120px]'
Expand Down Expand Up @@ -59,6 +60,7 @@ export function AppShell({
<footer className="mt-[72px] flex flex-wrap items-center justify-between gap-x-6 gap-y-2 border-t border-line pt-[18px] text-13 text-ink-3">
<span className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span>UtilityLab v{APP_VERSION} · Stated choice experiment designer</span>
<FeedbackButton />
<LegalLinks />
</span>
<span className="flex items-center gap-4 lg:hidden">
Expand Down
7 changes: 7 additions & 0 deletions app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ export default function PrivacyPage() {
them. To limit abuse, it keeps your network address in the server’s memory for about ten minutes, and never writes it anywhere. If you would rather
nothing leave your browser, download the LimeSurvey file (LSS) and import it yourself.
</p>
<p>
<strong className="font-semibold text-ink">Feedback.</strong> The Feedback form does not send anything
itself: it opens GitHub in a new tab with your text filled in, so GitHub receives that text, and the app
version and browser if you leave them ticked, when its page loads. Nothing is published until you submit it
there, under{' '}
<Ext href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement">GitHub’s privacy statement</Ext>.
</p>
<p>
<strong className="font-semibold text-ink">Images you link.</strong> If you add an image address to an
alternative, attribute or level, your browser loads the image from that site, which can then see your IP
Expand Down
50 changes: 50 additions & 0 deletions lib/__tests__/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { MAX_MESSAGE, describeBrowser, feedbackUrl } from '../feedback'

const UA = {
firefoxMac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.6; rv:131.0) Gecko/20100101 Firefox/131.0',
chromeWin: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
edge: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2792.79',
safariIos: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
}

const params = (url: string) => new URL(url).searchParams

describe('describeBrowser', () => {
it('names common browsers and systems', () => {
expect(describeBrowser(UA.firefoxMac)).toBe('Firefox 131 on macOS')
expect(describeBrowser(UA.chromeWin)).toBe('Chrome 129 on Windows')
expect(describeBrowser(UA.edge)).toBe('Edge 129 on Windows')
expect(describeBrowser(UA.safariIos)).toBe('Safari 18 on iOS')
expect(describeBrowser('')).toBe('Unknown browser')
})
})

describe('feedbackUrl', () => {
it('fills the bug report form, with context only when asked', () => {
const p = params(feedbackUrl({ kind: 'bug', title: 'QSF import fails', message: 'Error on import', context: { version: '1.2.0', browser: 'Firefox 131 on macOS' } }))
expect(p.get('template')).toBe('bug_report.yml')
expect(p.get('title')).toBe('QSF import fails')
expect(p.get('what')).toBe('Error on import')
expect(p.get('browser')).toBe('Firefox 131 on macOS')
expect(p.get('version')).toBe('v1.2.0')
const bare = params(feedbackUrl({ kind: 'bug', title: 'x', message: 'y' }))
expect(bare.has('browser')).toBe(false)
})
it('fills the feature request form', () => {
const p = params(feedbackUrl({ kind: 'idea', title: 'Attribute library', message: 'Travel time presets' }))
expect(p.get('template')).toBe('feature_request.yml')
expect(p.get('need')).toBe('Travel time presets')
})
it('opens a labelled blank issue for questions', () => {
const p = params(feedbackUrl({ kind: 'question', title: 'Priors?', message: 'How?', context: { version: '1.2.0', browser: 'Chrome 129 on Windows' } }))
expect(p.get('labels')).toBe('question')
expect(p.get('body')).toContain('How?')
expect(p.get('body')).toContain('UtilityLab v1.2.0 · Chrome 129 on Windows')
})
it('shortens long messages so the link stays usable', () => {
const url = feedbackUrl({ kind: 'idea', title: 't', message: 'a'.repeat(MAX_MESSAGE + 500) })
expect(params(url).get('need')!.endsWith('[shortened to fit the link]')).toBe(true)
expect(url.length).toBeLessThan(8000)
})
})
66 changes: 66 additions & 0 deletions lib/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Feedback goes to GitHub as a pre-filled issue. Nothing is sent from the app: the link opens
// GitHub's issue form, filled in, and the person submits it there.

export const REPO_ISSUES = 'https://github.com/acesonic7/utilitylab/issues/new'

export type FeedbackKind = 'bug' | 'idea' | 'question'

export type FeedbackInput = {
kind: FeedbackKind
title: string
message: string
// Added only when the person ticks "include app version and browser".
context?: { version: string; browser: string }
}

// Keeps the link well under the length browsers and GitHub accept.
export const MAX_MESSAGE = 3000

// A short "Firefox 131 on macOS" from a user agent string.
export function describeBrowser(ua: string): string {
const version = (re: RegExp) => ua.match(re)?.[1]?.split('.')[0]
let browser = 'Unknown browser'
if (/Edg\//.test(ua)) browser = `Edge ${version(/Edg\/([\d.]+)/)}`
else if (/OPR\//.test(ua)) browser = `Opera ${version(/OPR\/([\d.]+)/)}`
else if (/Firefox\//.test(ua)) browser = `Firefox ${version(/Firefox\/([\d.]+)/)}`
else if (/Chrome\//.test(ua)) browser = `Chrome ${version(/Chrome\/([\d.]+)/)}`
else if (/Safari\//.test(ua) && /Version\//.test(ua)) browser = `Safari ${version(/Version\/([\d.]+)/)}`
let os = ''
if (/iPhone|iPad|iPod/.test(ua)) os = 'iOS'
else if (/Android/.test(ua)) os = 'Android'
else if (/Mac OS X|Macintosh/.test(ua)) os = 'macOS'
else if (/Windows/.test(ua)) os = 'Windows'
else if (/CrOS/.test(ua)) os = 'ChromeOS'
else if (/Linux/.test(ua)) os = 'Linux'
return os ? `${browser} on ${os}` : browser
}

function clip(text: string): string {
const t = text.trim()
return t.length > MAX_MESSAGE ? `${t.slice(0, MAX_MESSAGE)}\n\n[shortened to fit the link]` : t
}

export function feedbackUrl(input: FeedbackInput): string {
const params = new URLSearchParams()
const title = input.title.trim()
const message = clip(input.message)
if (input.kind === 'bug') {
params.set('template', 'bug_report.yml')
if (title) params.set('title', title)
if (message) params.set('what', message)
if (input.context) {
params.set('browser', input.context.browser)
params.set('version', `v${input.context.version}`)
}
} else if (input.kind === 'idea') {
params.set('template', 'feature_request.yml')
if (title) params.set('title', title)
if (message) params.set('need', message)
} else {
params.set('labels', 'question')
if (title) params.set('title', title)
const context = input.context ? `\n\n---\nUtilityLab v${input.context.version} · ${input.context.browser}` : ''
if (message || context) params.set('body', `${message}${context}`)
}
return `${REPO_ISSUES}?${params.toString()}`
}
Loading