mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(collab): markdown poll questions, multiline inputs and wrapping options (#2177)
This commit is contained in:
@@ -732,3 +732,123 @@ describe('CollabPolls details', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// FE-COMP-POLLS-016 to FE-COMP-POLLS-022
|
||||
// Markdown questions, sanitization and multiline input (#2177).
|
||||
|
||||
describe('CollabPolls markdown & multiline', () => {
|
||||
it('FE-COMP-POLLS-016: a markdown question renders headings and bold text', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: '## Day trip\n\n**Vote** carefully' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const heading = await screen.findByText('Day trip');
|
||||
expect(heading.closest('h2')).not.toBeNull();
|
||||
expect(screen.getByText('Vote').closest('strong')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-017: a script/onerror injection attempt produces no live DOM nodes', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({
|
||||
polls: [buildPoll({ question: 'Safe title <script>alert("xss")</script> <img src=x onerror=alert(1)>' })],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/Safe title/);
|
||||
expect(document.querySelector('script')).toBeNull();
|
||||
expect(document.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(document.querySelector('[onerror]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-018: blank lines split the question into separate paragraphs', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'First paragraph\n\nSecond paragraph' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const first = await screen.findByText('First paragraph');
|
||||
const second = screen.getByText('Second paragraph');
|
||||
expect(first.tagName).toBe('P');
|
||||
expect(second.tagName).toBe('P');
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-019: a plain-text question renders identically, without formatting nodes', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Best destination?' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const question = await screen.findByText('Best destination?');
|
||||
expect(question.textContent).toBe('Best destination?');
|
||||
// The question sits in a paragraph now, but no formatting was invented
|
||||
expect(question.parentElement!.querySelector('strong, em, h1, h2, a')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-020: the question field is a textarea — Enter adds a line instead of submitting', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ poll: buildPoll({ id: 60, question: 'Line one\nLine two' }) });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
|
||||
const questionField = screen.getByPlaceholderText(/what should we do/i);
|
||||
expect(questionField.tagName).toBe('TEXTAREA');
|
||||
await user.type(questionField, 'Line one{enter}Line two');
|
||||
// Enter stayed in the field — the form was not submitted without options
|
||||
expect(body).toBeNull();
|
||||
expect(questionField).toHaveValue('Line one\nLine two');
|
||||
|
||||
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
expect(optionInputs[0].tagName).toBe('TEXTAREA');
|
||||
await user.type(optionInputs[0], 'A');
|
||||
await user.type(optionInputs[1], 'B');
|
||||
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
||||
await waitFor(() => expect(body).toMatchObject({ question: 'Line one\nLine two' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-021: a link in the question opens in a new tab with rel protection', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Where? [Our hotel](https://example.com)' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const link = await screen.findByRole('link', { name: 'Our hotel' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-022: a long multiline option wraps instead of being clipped', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({
|
||||
polls: [buildPoll({
|
||||
options: [
|
||||
{ id: 1, text: 'Stay at the beach house\nwith the long unpronounceable name', voters: [] },
|
||||
{ id: 2, text: 'Rome', voters: [] },
|
||||
],
|
||||
})],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const label = (await screen.findByText(/Stay at the beach house/)).closest('span')!;
|
||||
expect(label.style.whiteSpace).toBe('pre-wrap');
|
||||
expect(label.style.overflowWrap).toBe('anywhere');
|
||||
expect(label.style.minWidth).toBe('0px');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -35,6 +40,33 @@ interface Poll {
|
||||
|
||||
const FONT = "var(--font-system)"
|
||||
|
||||
// Block styling for the markdown question (#2177). Tailwind's preflight resets
|
||||
// headings/lists, so the sizes are set here — in em, on purpose: they scale with
|
||||
// the surrounding calc(13px * var(--fs-scale-body)) base, so the user's
|
||||
// text-size setting keeps working. Links come from markdownLinkComponents
|
||||
// (#1629): new tab plus rel protection, this is cross-user content.
|
||||
const pollMarkdownComponents: Components = {
|
||||
...markdownLinkComponents,
|
||||
p: ({ children }) => (
|
||||
<p style={{ margin: '0 0 0.55em' }}>{children}</p>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1 style={{ margin: '0 0 0.45em', fontSize: '1.4em', lineHeight: 1.2 }}>{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 style={{ margin: '0 0 0.45em', fontSize: '1.25em', lineHeight: 1.25 }}>{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 style={{ margin: '0 0 0.45em', fontSize: '1.1em', lineHeight: 1.3 }}>{children}</h3>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'disc' }}>{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'decimal' }}>{children}</ol>
|
||||
),
|
||||
}
|
||||
|
||||
function timeRemaining(deadline) {
|
||||
if (!deadline) return null
|
||||
const diff = new Date(deadline).getTime() - Date.now()
|
||||
@@ -98,7 +130,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
{/* Question */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
|
||||
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<textarea autoFocus rows={5} value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', minHeight: 110, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.4, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('collab.polls.markdownHint')}</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -107,8 +140,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
<textarea rows={2} value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, minWidth: 0, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.35, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
{options.length > 2 && (
|
||||
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
|
||||
)}
|
||||
@@ -223,7 +256,9 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
{poll.question}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]} components={pollMarkdownComponents}>
|
||||
{poll.question}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{isClosed && (
|
||||
@@ -281,7 +316,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
|
||||
disabled={isClosed}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
|
||||
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
|
||||
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
|
||||
overflow: 'hidden', transition: 'transform 0.1s',
|
||||
@@ -308,9 +343,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
{/* Label — options stay plain text, but long or multiline ones
|
||||
must wrap instead of being clipped by the button's
|
||||
overflow:hidden (#2177) */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
|
||||
flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word',
|
||||
lineHeight: 1.35, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
|
||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.text}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { BarChart3, Check, Clock, Lock, Plus, Trash2, X } from 'lucide-react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import rehypeSanitize from 'rehype-sanitize'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { markdownLinkComponents } from '../../../../components/shared/markdownLink'
|
||||
import { collabApi } from '../../../../api/client'
|
||||
import { addListener, removeListener } from '../../../../api/websocket'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import ToggleSwitch from '../../../../components/Settings/ToggleSwitch'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { Eyebrow, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from '../sheets/PlSheetChrome'
|
||||
import MConfirmSheet from '../../settings/MConfirmSheet'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { SectionHeader, TabScroller } from './tabChrome'
|
||||
@@ -253,7 +258,14 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
|
||||
<div className="mt-2 overflow-hidden rounded-2xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
|
||||
<div className="flex items-start gap-2 px-3 py-[10px]" style={closed ? { background: 'var(--m-card)' } : undefined}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.8125rem] font-bold leading-[1.35] text-m-ink">{poll.question}</div>
|
||||
{/* Question is cross-user markdown (#2177): sanitized, raw HTML stays
|
||||
inert, links open in a new tab with rel protection (#1629).
|
||||
Heading/list sizes are em-based so they follow the text scale. */}
|
||||
<div className="break-words text-[0.8125rem] font-bold leading-[1.35] text-m-ink [&_a]:underline [&_h1]:mb-1 [&_h1]:text-[1.35em] [&_h1]:leading-[1.2] [&_h2]:mb-1 [&_h2]:text-[1.2em] [&_h2]:leading-[1.25] [&_h3]:mb-1 [&_h3]:text-[1.1em] [&_h3]:leading-[1.3] [&_ol]:list-decimal [&_ol]:pl-4 [&_p]:mb-1 [&_ul]:list-disc [&_ul]:pl-4">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeSanitize]} components={markdownLinkComponents}>
|
||||
{poll.question}
|
||||
</Markdown>
|
||||
</div>
|
||||
<div className="mt-[5px] flex flex-wrap items-center gap-[6px]">
|
||||
{closed ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold uppercase tracking-[.02em] text-m-faint">
|
||||
@@ -316,7 +328,7 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
|
||||
type="button"
|
||||
disabled={closed || !canEdit}
|
||||
onClick={() => onVote(poll.id, idx)}
|
||||
className="relative flex items-center gap-[8px] overflow-hidden rounded-[10px] bg-m-card px-[12px] py-[10px] text-left disabled:cursor-default"
|
||||
className="relative flex items-start gap-[8px] overflow-hidden rounded-[10px] bg-m-card px-[12px] py-[10px] text-left disabled:cursor-default"
|
||||
>
|
||||
<span className="absolute inset-y-0 left-0" style={{ width: `${pct}%`, background: fillTint }} />
|
||||
<span
|
||||
@@ -327,7 +339,8 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
|
||||
>
|
||||
{myVote && <Check size={11} strokeWidth={3} className="text-m-actfg" />}
|
||||
</span>
|
||||
<span className={`relative min-w-0 flex-1 truncate text-[0.8125rem] ${myVote || isWinner ? 'font-bold' : 'font-medium'} text-m-ink`}>
|
||||
{/* No truncate: long or multiline options wrap fully (#2177) */}
|
||||
<span className={`relative min-w-0 flex-1 whitespace-pre-wrap break-words text-[0.8125rem] [overflow-wrap:anywhere] ${myVote || isWinner ? 'font-bold' : 'font-medium'} text-m-ink`}>
|
||||
{opt.text}
|
||||
</span>
|
||||
{(voted || closed) && count > 0 && (
|
||||
@@ -406,26 +419,27 @@ function PollFormSheet({ open, onClose, onSubmit, t }: {
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[6px] pt-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('collab.polls.question')} *</Eyebrow>
|
||||
<input
|
||||
type="text"
|
||||
<textarea
|
||||
rows={4}
|
||||
value={question}
|
||||
onChange={e => setQuestion(e.target.value)}
|
||||
maxLength={300}
|
||||
placeholder={t('collab.polls.questionPlaceholder')}
|
||||
className={FIELD_CLS}
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
<p className="mt-1 font-geist text-[0.625rem] text-m-faint">{t('collab.polls.markdownHint')}</p>
|
||||
|
||||
<Eyebrow className="mb-[6px] mt-3 uppercase">{t('collab.polls.options')}</Eyebrow>
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-[6px]">
|
||||
<input
|
||||
type="text"
|
||||
<textarea
|
||||
rows={2}
|
||||
value={opt}
|
||||
onChange={e => setOptions(prev => prev.map((o, j) => (j === i ? e.target.value : o)))}
|
||||
placeholder={t('collab.polls.optionPlaceholder', { n: i + 1 })}
|
||||
maxLength={200}
|
||||
className={FIELD_CLS}
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
{options.length > 2 && (
|
||||
<button
|
||||
|
||||
@@ -412,4 +412,52 @@ describe('MCollabPolls', () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('collab.polls.questionPlaceholder')).toHaveValue(''))
|
||||
})
|
||||
|
||||
// Markdown questions, sanitization and multiline input (#2177)
|
||||
|
||||
it('FE-MOB-POLLS-027: renders the question as sanitized markdown with protected links', async () => {
|
||||
setup([poll({ question: '**Bold plan** <script>alert("xss")</script> [site](https://example.com)' })])
|
||||
|
||||
const bold = await screen.findByText('Bold plan')
|
||||
expect(bold.closest('strong')).not.toBeNull()
|
||||
expect(document.querySelector('script')).toBeNull()
|
||||
expect(document.querySelector('[onerror]')).toBeNull()
|
||||
const link = screen.getByRole('link', { name: 'site' })
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow')
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-028: option labels wrap instead of truncating', async () => {
|
||||
setup([poll({ options: [
|
||||
{ text: 'A very long option label\nacross two lines', label: 'long', voters: [] },
|
||||
{ text: 'Sushi', label: 'Sushi', voters: [] },
|
||||
] })])
|
||||
|
||||
const label = (await screen.findByText(/A very long option label/)).closest('span')!
|
||||
expect(label.className).toContain('whitespace-pre-wrap')
|
||||
expect(label.className).toContain('break-words')
|
||||
expect(label.className).not.toContain('truncate')
|
||||
})
|
||||
|
||||
it('FE-MOB-POLLS-029: the form uses textareas and keeps inner line breaks on submit', async () => {
|
||||
const createPoll = vi.spyOn(collabApi, 'createPoll').mockResolvedValue({ poll: poll({ id: 9, question: 'Multi\nline?' }) })
|
||||
setup([])
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'collab.polls.new' }))
|
||||
const question = await screen.findByPlaceholderText('collab.polls.questionPlaceholder')
|
||||
expect(question.tagName).toBe('TEXTAREA')
|
||||
expect(screen.getByText('collab.polls.markdownHint')).toBeInTheDocument()
|
||||
const option = screen.getByPlaceholderText('collab.polls.optionPlaceholder:1')
|
||||
expect(option.tagName).toBe('TEXTAREA')
|
||||
|
||||
fireEvent.change(question, { target: { value: ' Multi\nline? ' } })
|
||||
fireEvent.change(option, { target: { value: 'A' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: 'B' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
|
||||
|
||||
await waitFor(() => expect(createPoll).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||
question: 'Multi\nline?',
|
||||
options: ['A', 'B'],
|
||||
})))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,6 +66,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'اسأل المجموعة وصوّتوا معًا',
|
||||
'collab.polls.question': 'السؤال',
|
||||
'collab.polls.questionPlaceholder': 'ماذا ينبغي أن نفعل؟',
|
||||
'collab.polls.markdownHint': 'يدعم تنسيق Markdown',
|
||||
'collab.polls.addOption': '+ إضافة خيار',
|
||||
'collab.polls.optionPlaceholder': 'الخيار {n}',
|
||||
'collab.polls.create': 'إنشاء استطلاع',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Pergunte ao grupo e votem juntos',
|
||||
'collab.polls.question': 'Pergunta',
|
||||
'collab.polls.questionPlaceholder': 'O que vamos fazer?',
|
||||
'collab.polls.markdownHint': 'Suporta Markdown',
|
||||
'collab.polls.addOption': '+ Adicionar opção',
|
||||
'collab.polls.optionPlaceholder': 'Opção {n}',
|
||||
'collab.polls.create': 'Criar enquete',
|
||||
|
||||
@@ -59,6 +59,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Pregunta al grup i voteu plegats',
|
||||
'collab.polls.question': 'Pregunta',
|
||||
'collab.polls.questionPlaceholder': 'Què hauríem de fer?',
|
||||
'collab.polls.markdownHint': 'Compatible amb Markdown',
|
||||
'collab.polls.addOption': '+ Afegeix una opció',
|
||||
'collab.polls.optionPlaceholder': 'Opció {n}',
|
||||
'collab.polls.create': "Crea l'enquesta",
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Zeptejte se skupiny a hlasujte společně',
|
||||
'collab.polls.question': 'Otázka',
|
||||
'collab.polls.questionPlaceholder': 'Co bychom měli dělat?',
|
||||
'collab.polls.markdownHint': 'Podporuje Markdown',
|
||||
'collab.polls.addOption': '+ Přidat možnost',
|
||||
'collab.polls.optionPlaceholder': 'Možnost {n}',
|
||||
'collab.polls.create': 'Vytvořit hlasování',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Frage die Gruppe und stimmt gemeinsam ab',
|
||||
'collab.polls.question': 'Frage',
|
||||
'collab.polls.questionPlaceholder': 'Was sollen wir machen?',
|
||||
'collab.polls.markdownHint': 'Markdown wird unterstützt',
|
||||
'collab.polls.addOption': '+ Option hinzufügen',
|
||||
'collab.polls.optionPlaceholder': 'Option {n}',
|
||||
'collab.polls.create': 'Umfrage erstellen',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Ask the group and vote together',
|
||||
'collab.polls.question': 'Question',
|
||||
'collab.polls.questionPlaceholder': 'What should we do?',
|
||||
'collab.polls.markdownHint': 'Markdown supported',
|
||||
'collab.polls.addOption': '+ Add option',
|
||||
'collab.polls.optionPlaceholder': 'Option {n}',
|
||||
'collab.polls.create': 'Create Poll',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Pregunta al grupo y votad juntos',
|
||||
'collab.polls.question': 'Pregunta',
|
||||
'collab.polls.questionPlaceholder': '¿Qué deberíamos hacer?',
|
||||
'collab.polls.markdownHint': 'Compatible con Markdown',
|
||||
'collab.polls.addOption': '+ Añadir opción',
|
||||
'collab.polls.optionPlaceholder': 'Opción {n}',
|
||||
'collab.polls.create': 'Crear encuesta',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Posez des questions au groupe et votez ensemble',
|
||||
'collab.polls.question': 'Question',
|
||||
'collab.polls.questionPlaceholder': 'Que devrait-on faire ?',
|
||||
'collab.polls.markdownHint': 'Markdown pris en charge',
|
||||
'collab.polls.addOption': '+ Ajouter une option',
|
||||
'collab.polls.optionPlaceholder': 'Option {n}',
|
||||
'collab.polls.create': 'Créer le sondage',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Ρωτήστε την ομάδα και ψηφίστε μαζί',
|
||||
'collab.polls.question': 'Ερώτηση',
|
||||
'collab.polls.questionPlaceholder': 'Τι πρέπει να κάνουμε;',
|
||||
'collab.polls.markdownHint': 'Υποστηρίζεται Markdown',
|
||||
'collab.polls.addOption': '+ Προσθήκη επιλογής',
|
||||
'collab.polls.optionPlaceholder': 'Επιλογή {n}',
|
||||
'collab.polls.create': 'Δημιουργία Δημοσκόπησης',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Kérdezd meg a csoportot és szavazzatok együtt',
|
||||
'collab.polls.question': 'Kérdés',
|
||||
'collab.polls.questionPlaceholder': 'Mit csináljunk?',
|
||||
'collab.polls.markdownHint': 'Markdown támogatott',
|
||||
'collab.polls.addOption': 'Opció hozzáadása',
|
||||
'collab.polls.optionPlaceholder': '{n}. opció',
|
||||
'collab.polls.create': 'Szavazás létrehozása',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Tanyakan ke grup dan voting bersama',
|
||||
'collab.polls.question': 'Pertanyaan',
|
||||
'collab.polls.questionPlaceholder': 'Apa yang harus kita lakukan?',
|
||||
'collab.polls.markdownHint': 'Mendukung Markdown',
|
||||
'collab.polls.addOption': '+ Tambah pilihan',
|
||||
'collab.polls.optionPlaceholder': 'Pilihan {n}',
|
||||
'collab.polls.create': 'Buat Polling',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Chiedi al gruppo e votate insieme',
|
||||
'collab.polls.question': 'Domanda',
|
||||
'collab.polls.questionPlaceholder': 'Cosa dovremmo fare?',
|
||||
'collab.polls.markdownHint': 'Markdown supportato',
|
||||
'collab.polls.addOption': '+ Aggiungi opzione',
|
||||
'collab.polls.optionPlaceholder': 'Opzione {n}',
|
||||
'collab.polls.create': 'Crea sondaggio',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': '質問してみんなで投票しましょう',
|
||||
'collab.polls.question': '質問',
|
||||
'collab.polls.questionPlaceholder': '何をしますか?',
|
||||
'collab.polls.markdownHint': 'Markdown対応',
|
||||
'collab.polls.addOption': '+ 選択肢を追加',
|
||||
'collab.polls.optionPlaceholder': '選択肢 {n}',
|
||||
'collab.polls.create': '投票を作成',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': '그룹에 질문하고 함께 투표하세요',
|
||||
'collab.polls.question': '질문',
|
||||
'collab.polls.questionPlaceholder': '무엇을 할까요?',
|
||||
'collab.polls.markdownHint': 'Markdown 지원',
|
||||
'collab.polls.addOption': '+ 옵션 추가',
|
||||
'collab.polls.optionPlaceholder': '옵션 {n}',
|
||||
'collab.polls.create': '투표 만들기',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
|
||||
'collab.polls.question': 'Vraag',
|
||||
'collab.polls.questionPlaceholder': 'Wat zullen we doen?',
|
||||
'collab.polls.markdownHint': 'Markdown wordt ondersteund',
|
||||
'collab.polls.addOption': '+ Optie toevoegen',
|
||||
'collab.polls.optionPlaceholder': 'Optie {n}',
|
||||
'collab.polls.create': 'Poll aanmaken',
|
||||
|
||||
@@ -66,6 +66,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Zapytaj grupę i głosujcie razem',
|
||||
'collab.polls.question': 'Pytanie',
|
||||
'collab.polls.questionPlaceholder': 'Co powinniśmy zrobić?',
|
||||
'collab.polls.markdownHint': 'Obsługuje Markdown',
|
||||
'collab.polls.addOption': '+ Dodaj opcję',
|
||||
'collab.polls.optionPlaceholder': 'Opcja {n}',
|
||||
'collab.polls.create': 'Utwórz ankietę',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Задайте вопрос группе и голосуйте вместе',
|
||||
'collab.polls.question': 'Вопрос',
|
||||
'collab.polls.questionPlaceholder': 'Что нам делать?',
|
||||
'collab.polls.markdownHint': 'Поддерживается Markdown',
|
||||
'collab.polls.addOption': '+ Добавить вариант',
|
||||
'collab.polls.optionPlaceholder': 'Вариант {n}',
|
||||
'collab.polls.create': 'Создать опрос',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Fråga gruppen och rösta tillsammans',
|
||||
'collab.polls.question': 'Fråga',
|
||||
'collab.polls.questionPlaceholder': 'Vad ska vi göra?',
|
||||
'collab.polls.markdownHint': 'Markdown stöds',
|
||||
'collab.polls.addOption': '+ Lägg till alternativ',
|
||||
'collab.polls.optionPlaceholder': 'Alternativ {n}',
|
||||
'collab.polls.create': 'Skapa omröstning',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Gruba sorun ve birlikte oy verin',
|
||||
'collab.polls.question': 'Soru',
|
||||
'collab.polls.questionPlaceholder': 'Ne yapalım?',
|
||||
'collab.polls.markdownHint': 'Markdown desteklenir',
|
||||
'collab.polls.addOption': '+ Seçenek ekle',
|
||||
'collab.polls.optionPlaceholder': 'Seçenek {n}',
|
||||
'collab.polls.create': 'Anket Oluştur',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Поставте питання групі і голосуйте разом',
|
||||
'collab.polls.question': 'Питання',
|
||||
'collab.polls.questionPlaceholder': 'Що нам робити?',
|
||||
'collab.polls.markdownHint': 'Підтримується Markdown',
|
||||
'collab.polls.addOption': '+ Додати варіант',
|
||||
'collab.polls.optionPlaceholder': 'Варіант {n}',
|
||||
'collab.polls.create': 'Створити опитування',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': 'Hỏi nhóm và cùng nhau bỏ phiếu',
|
||||
'collab.polls.question': 'Câu hỏi',
|
||||
'collab.polls.questionPlaceholder': 'Chúng ta nên làm gì?',
|
||||
'collab.polls.markdownHint': 'Hỗ trợ Markdown',
|
||||
'collab.polls.addOption': '+ Thêm tùy chọn',
|
||||
'collab.polls.optionPlaceholder': 'Tùy chọn {n}',
|
||||
'collab.polls.create': 'Tạo cuộc thăm dò ý kiến',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': '向團隊提問並一起投票',
|
||||
'collab.polls.question': '問題',
|
||||
'collab.polls.questionPlaceholder': '我們應該做什麼?',
|
||||
'collab.polls.markdownHint': '支援 Markdown',
|
||||
'collab.polls.addOption': '+ 新增選項',
|
||||
'collab.polls.optionPlaceholder': '選項 {n}',
|
||||
'collab.polls.create': '建立投票',
|
||||
|
||||
@@ -67,6 +67,7 @@ const collab: TranslationStrings = {
|
||||
'collab.polls.emptyHint': '向团队提问并一起投票',
|
||||
'collab.polls.question': '问题',
|
||||
'collab.polls.questionPlaceholder': '我们应该做什么?',
|
||||
'collab.polls.markdownHint': '支持 Markdown',
|
||||
'collab.polls.addOption': '+ 添加选项',
|
||||
'collab.polls.optionPlaceholder': '选项 {n}',
|
||||
'collab.polls.create': '创建投票',
|
||||
|
||||
Reference in New Issue
Block a user