mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-26 10:31:10 -04:00
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import {RadioGroup} from '@headlessui/react'
|
|
import {UserIcon} from '@heroicons/react/24/solid'
|
|
import clsx from 'clsx'
|
|
import {Row as rowFor} from 'common/supabase/utils'
|
|
import {User} from 'common/user'
|
|
import {shortenNumber} from 'common/util/format'
|
|
import {sortBy} from 'lodash'
|
|
import {useState} from 'react'
|
|
import toast from 'react-hot-toast'
|
|
import {Button} from 'web/components/buttons/button'
|
|
import {CompatibilitySort, CompatibilitySortWidget} from 'web/components/compatibility/sort-widget'
|
|
import {Col} from 'web/components/layout/col'
|
|
import {SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal'
|
|
import {Row} from 'web/components/layout/row'
|
|
import {ExpandingInput} from 'web/components/widgets/expanding-input'
|
|
import {RadioToggleGroup} from 'web/components/widgets/radio-toggle-group'
|
|
import {Tooltip} from 'web/components/widgets/tooltip'
|
|
import {QuestionWithCountType} from 'web/hooks/use-questions'
|
|
import {api} from 'web/lib/api'
|
|
import {useT} from 'web/lib/locale'
|
|
import {track} from 'web/lib/service/analytics'
|
|
|
|
import {filterKeys} from '../questions-form'
|
|
|
|
export type CompatibilityAnswerSubmitType = Omit<
|
|
rowFor<'compatibility_answers'>,
|
|
'created_time' | 'id'
|
|
>
|
|
|
|
export const IMPORTANCE_CHOICES = {
|
|
'Not Important': 0,
|
|
'Somewhat Important': 1,
|
|
Important: 2,
|
|
'Very Important': 3,
|
|
} as const
|
|
|
|
type ImportanceColorsType = {
|
|
[key: number]: string
|
|
}
|
|
|
|
export const IMPORTANCE_RADIO_COLORS: ImportanceColorsType = {
|
|
0: `bg-teal-700 ring-teal-200`,
|
|
1: `bg-teal-800 ring-teal-200`,
|
|
2: `bg-teal-900 ring-teal-300`,
|
|
3: `bg-teal-950 ring-teal-400`,
|
|
}
|
|
|
|
export const IMPORTANCE_DISPLAY_COLORS: ImportanceColorsType = {
|
|
0: `bg-stone-300 dark:bg-stone-600`,
|
|
1: `bg-yellow-500/20`,
|
|
2: `bg-yellow-500/50`,
|
|
3: `bg-yellow-400/80`,
|
|
}
|
|
|
|
export const submitCompatibilityAnswer = async (newAnswer: CompatibilityAnswerSubmitType) => {
|
|
if (!newAnswer) return
|
|
const input = {
|
|
...filterKeys(newAnswer, (key, _) => !['id', 'created_time'].includes(key)),
|
|
} as CompatibilityAnswerSubmitType
|
|
|
|
try {
|
|
await api('set-compatibility-answer', {
|
|
questionId: input.question_id,
|
|
multipleChoice: input.multiple_choice,
|
|
prefChoices: input.pref_choices ?? [],
|
|
importance: input.importance,
|
|
explanation: input.explanation ?? null,
|
|
})
|
|
|
|
// Track only if upsert succeeds
|
|
track('answer compatibility question', {
|
|
...newAnswer,
|
|
})
|
|
} catch (error) {
|
|
console.error('Failed to set compatibility answer:', error)
|
|
// Note: toast not localized here due to lack of hook; callers may handle UI feedback
|
|
toast.error('Error submitting. Try again?')
|
|
}
|
|
}
|
|
|
|
export const deleteCompatibilityAnswer = async (id: number, userId: string) => {
|
|
if (!userId || !id) return
|
|
try {
|
|
await api('delete-compatibility-answer', {id})
|
|
await track('delete compatibility question', {id})
|
|
} catch (error) {
|
|
console.error('Failed to delete prompt answer:', error)
|
|
// Note: toast not localized here due to lack of hook; callers may handle UI feedback
|
|
toast.error('Error deleting. Try again?')
|
|
}
|
|
}
|
|
|
|
export function getEmptyAnswer(userId: string, questionId: number) {
|
|
return {
|
|
creator_id: userId,
|
|
explanation: null,
|
|
multiple_choice: -1,
|
|
pref_choices: [],
|
|
question_id: questionId,
|
|
importance: -1,
|
|
}
|
|
}
|
|
|
|
export function AnswerCompatibilityQuestionContent(props: {
|
|
compatibilityQuestion: QuestionWithCountType
|
|
user: User
|
|
index?: number
|
|
total?: number
|
|
answer?: CompatibilityAnswerSubmitType | null
|
|
onSubmit: () => void
|
|
onNext?: () => void
|
|
isLastQuestion: boolean
|
|
noSkip?: boolean
|
|
sort?: CompatibilitySort
|
|
setSort?: (sort: CompatibilitySort) => void
|
|
}) {
|
|
const {
|
|
compatibilityQuestion,
|
|
user,
|
|
onSubmit,
|
|
isLastQuestion,
|
|
onNext,
|
|
noSkip,
|
|
index,
|
|
total,
|
|
sort,
|
|
setSort,
|
|
} = props
|
|
const t = useT()
|
|
const [answer, setAnswer] = useState<CompatibilityAnswerSubmitType>(
|
|
(props.answer as CompatibilityAnswerSubmitType) ??
|
|
getEmptyAnswer(user.id, compatibilityQuestion.id),
|
|
)
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
const [skipLoading, setSkipLoading] = useState(false)
|
|
if (
|
|
compatibilityQuestion.answer_type !== 'compatibility_multiple_choice' ||
|
|
!compatibilityQuestion.multiple_choice_options
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const optionOrder = sortBy(Object.entries(compatibilityQuestion.multiple_choice_options), 1).map(
|
|
([label]) => label,
|
|
)
|
|
|
|
const multipleChoiceValid = answer.multiple_choice != null && answer.multiple_choice !== -1
|
|
|
|
const prefChoicesValid = answer.pref_choices && answer.pref_choices.length > 0
|
|
|
|
const importanceValid = answer.importance !== null && answer.importance !== -1
|
|
|
|
const shortenedPopularity = compatibilityQuestion.answer_count
|
|
? shortenNumber(compatibilityQuestion.answer_count)
|
|
: null
|
|
return (
|
|
<Col className="min-h-0 w-full gap-4">
|
|
<Col className="gap-1 shrink-0">
|
|
<Row>
|
|
{shortenedPopularity && (
|
|
<Tooltip
|
|
text={t(
|
|
'answers.content.people_answered',
|
|
'{count} people have answered this question',
|
|
{count: String(shortenedPopularity)},
|
|
)}
|
|
>
|
|
<Row className="text-ink-500 select-none items-center text-sm">
|
|
{shortenedPopularity}
|
|
<UserIcon className="h-4 w-4" />
|
|
</Row>
|
|
</Tooltip>
|
|
)}
|
|
{isFinite(index!) && isFinite(total!) && (
|
|
<span className={'ml-16 text-sm'}>
|
|
<span className="text-ink-600 font-semibold">{index! + 1}</span> / {total}
|
|
</span>
|
|
)}
|
|
{sort && setSort && (
|
|
<CompatibilitySortWidget
|
|
className="text-sm sm:flex ml-auto"
|
|
sort={sort}
|
|
setSort={setSort}
|
|
user={user}
|
|
ignore={['your_important']}
|
|
/>
|
|
)}
|
|
</Row>
|
|
<div data-testid="compatibility-question">{compatibilityQuestion.question}</div>
|
|
</Col>
|
|
<Col className={clsx(SCROLLABLE_MODAL_CLASS, 'w-full gap-4 flex-1 min-h-0 pr-2')}>
|
|
<Col className="gap-2">
|
|
<span className="text-ink-500 text-sm">
|
|
{t('answers.preferred.your_answer', 'Your answer')}
|
|
</span>
|
|
<SelectAnswer
|
|
value={answer.multiple_choice}
|
|
setValue={(choice) => setAnswer({...answer, multiple_choice: choice})}
|
|
options={optionOrder}
|
|
/>
|
|
</Col>
|
|
<Col className="gap-2">
|
|
<span className="text-ink-500 text-sm">
|
|
{t('answers.content.answers_you_accept', "Answers you'll accept")}
|
|
</span>
|
|
<MultiSelectAnswers
|
|
values={answer.pref_choices ?? []}
|
|
setValue={(choice) => setAnswer({...answer, pref_choices: choice})}
|
|
options={optionOrder}
|
|
/>
|
|
</Col>
|
|
<Col className="gap-2">
|
|
<span className="text-ink-500 text-sm">
|
|
{t('answers.content.importance', 'Importance')}
|
|
</span>
|
|
<RadioToggleGroup
|
|
currentChoice={answer.importance ?? -1}
|
|
choicesMap={Object.fromEntries(
|
|
Object.entries(IMPORTANCE_CHOICES).map(([k, v]) => [
|
|
t(`answers.importance.${v}`, k),
|
|
v,
|
|
]),
|
|
)}
|
|
setChoice={(choice: number) => setAnswer({...answer, importance: choice})}
|
|
indexColors={IMPORTANCE_RADIO_COLORS}
|
|
/>
|
|
</Col>
|
|
<Col className="-mt-6 gap-2">
|
|
<span className="text-ink-500 text-sm">
|
|
{t('answers.content.your_thoughts', 'Your thoughts (optional, but recommended)')}
|
|
</span>
|
|
<ExpandingInput
|
|
className={'w-full'}
|
|
data-testid="compatibility-question-thoughts"
|
|
rows={3}
|
|
value={answer.explanation ?? ''}
|
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
setAnswer({...answer, explanation: e.target.value})
|
|
}
|
|
/>
|
|
</Col>
|
|
</Col>
|
|
<Row className="w-full justify-between gap-4 shrink-0">
|
|
{noSkip ? (
|
|
<div />
|
|
) : (
|
|
<button
|
|
disabled={loading || skipLoading}
|
|
onClick={() => {
|
|
setSkipLoading(true)
|
|
submitCompatibilityAnswer(getEmptyAnswer(user.id, compatibilityQuestion.id))
|
|
.then(() => {
|
|
if (isLastQuestion) {
|
|
onSubmit()
|
|
} else if (onNext) {
|
|
onNext()
|
|
}
|
|
})
|
|
.finally(() => setSkipLoading(false))
|
|
}}
|
|
className={clsx(
|
|
'text-ink-500 disabled:text-ink-300 text-sm hover:underline disabled:cursor-not-allowed',
|
|
skipLoading && 'animate-pulse',
|
|
)}
|
|
>
|
|
{t('answers.menu.skip', 'Skip')}
|
|
</button>
|
|
)}
|
|
<Button
|
|
disabled={
|
|
!multipleChoiceValid || !prefChoicesValid || !importanceValid || loading || skipLoading
|
|
}
|
|
loading={loading}
|
|
onClick={() => {
|
|
setLoading(true)
|
|
submitCompatibilityAnswer(answer)
|
|
.then(() => {
|
|
if (isLastQuestion) {
|
|
onSubmit()
|
|
} else if (onNext) {
|
|
onNext()
|
|
}
|
|
})
|
|
.finally(() => setLoading(false))
|
|
}}
|
|
>
|
|
{isLastQuestion ? t('answers.finish', 'Finish') : t('answers.next', 'Next')}
|
|
</Button>
|
|
</Row>
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
export const SelectAnswer = (props: {
|
|
value: number
|
|
setValue: (value: number) => void
|
|
options: string[]
|
|
}) => {
|
|
const {value, setValue, options} = props
|
|
return (
|
|
<RadioGroup
|
|
data-testid="compatibility-question-your-answer"
|
|
className={
|
|
'border-ink-300 text-ink-400 bg-canvas-0 inline-flex flex-col gap-2 rounded-md border p-1 text-sm shadow-sm'
|
|
}
|
|
value={value}
|
|
onChange={setValue}
|
|
>
|
|
{options.map((label, i) => (
|
|
<RadioGroup.Option
|
|
key={i}
|
|
value={i}
|
|
data-testid={`compatibility-your-answer-${i}`}
|
|
className={({disabled}) =>
|
|
clsx(
|
|
disabled
|
|
? 'text-ink-300 aria-checked:bg-ink-300 aria-checked:text-ink-0 cursor-not-allowed'
|
|
: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-primary-100 aria-checked:text-primary-900 aria-checked:hover:bg-primary-50 cursor-pointer',
|
|
'ring-primary-500 flex items-center rounded-md p-2 outline-none transition-all focus-visible:ring-2 sm:px-3',
|
|
)
|
|
}
|
|
>
|
|
{label}
|
|
</RadioGroup.Option>
|
|
))}
|
|
</RadioGroup>
|
|
)
|
|
}
|
|
|
|
// redo with checkbox semantics
|
|
export const MultiSelectAnswers = (props: {
|
|
values: number[]
|
|
setValue: (value: number[]) => void
|
|
options: string[]
|
|
}) => {
|
|
const {values, setValue, options} = props
|
|
|
|
return (
|
|
<Col
|
|
data-testid="compatibility-answers-you-accept"
|
|
className={
|
|
'border-ink-300 text-ink-400 bg-canvas-0 inline-flex flex-col gap-2 rounded-md border p-1 text-sm shadow-sm main-font'
|
|
}
|
|
>
|
|
{options.map((label, i) => (
|
|
<button
|
|
key={i}
|
|
data-testid={`compatibility-answers-you-accept-${i}`}
|
|
className={clsx(
|
|
values.includes(i)
|
|
? 'text-primary-700 bg-primary-100 hover:bg-primary-50'
|
|
: 'text-ink-700 hover:bg-ink-50',
|
|
'ring-primary-500 flex cursor-pointer items-center rounded-md p-2 outline-none transition-all focus-visible:ring-2 disabled:cursor-not-allowed sm:px-3',
|
|
)}
|
|
onClick={() =>
|
|
setValue(values.includes(i) ? values.filter((v) => v !== i) : [...values, i])
|
|
}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
//Exported types for test files to use when referencing the keys of the choices objects
|
|
export type ImportanceTuple = {
|
|
[K in keyof typeof IMPORTANCE_CHOICES]: [K, (typeof IMPORTANCE_CHOICES)[K]]
|
|
}[keyof typeof IMPORTANCE_CHOICES]
|