Add enhanced poll insert modal with list + quick create to docs editor

Replace the bare text input modal with a two-tab PollInsertModal that lets
users browse/search existing polls or create a new one inline, following
the same pattern as AdPickerModal.

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-03 12:25:05 -07:00
parent 2390820e41
commit 31e46af493
2 changed files with 245 additions and 27 deletions

View File

@ -0,0 +1,237 @@
import { useState, useEffect } from 'react';
import { Modal, Tabs, Table, Button, Input, Tag, Form, Select, DatePicker, TimePicker, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
import { POLL_STATUS_COLORS, POLL_STATUS_LABELS } from '@/types/api';
const { Text } = Typography;
const TIMEZONE_OPTIONS = [
'America/Vancouver',
'America/Edmonton',
'America/Regina',
'America/Winnipeg',
'America/Toronto',
'America/Halifax',
'America/St_Johns',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
];
interface PollInsertModalProps {
open: boolean;
onCancel: () => void;
onInsert: (slug: string) => void;
}
export function PollInsertModal({ open, onCancel, onInsert }: PollInsertModalProps) {
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
// Create tab state
const [form] = Form.useForm();
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!open) return;
setLoading(true);
setSearch('');
api.get<PollsListResponse>('/meeting-planner', { params: { limit: 100 } })
.then(({ data }) => setPolls(data.polls || []))
.catch(() => setPolls([]))
.finally(() => setLoading(false));
}, [open]);
const filtered = search
? polls.filter((p) => {
const q = search.toLowerCase();
return p.title.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q);
})
: polls;
const handleCreate = async (values: any) => {
setCreating(true);
try {
const options = values.options.map((opt: any) => ({
date: opt.date.format('YYYY-MM-DD'),
startTime: opt.startTime.format('HH:mm'),
endTime: opt.endTime.format('HH:mm'),
}));
const { data } = await api.post('/meeting-planner', {
title: values.title,
description: values.description,
timezone: values.timezone,
allowAnonymous: true,
notifyOnVote: true,
options,
});
message.success('Poll created');
form.resetFields();
onInsert(data.slug);
} catch {
message.error('Failed to create poll');
} finally {
setCreating(false);
}
};
const columns = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: 'Slug',
dataIndex: 'slug',
key: 'slug',
width: 160,
ellipsis: true,
render: (slug: string) => <Text copyable code style={{ fontSize: 12 }}>{slug}</Text>,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 90,
render: (status: SchedulingPollStatus) => (
<Tag color={POLL_STATUS_COLORS[status]}>{POLL_STATUS_LABELS[status]}</Tag>
),
},
{
title: 'Options',
key: 'options',
width: 70,
align: 'center' as const,
render: (_: unknown, r: SchedulingPoll) => r._count?.options ?? '',
},
{
title: 'Votes',
key: 'votes',
width: 60,
align: 'center' as const,
render: (_: unknown, r: SchedulingPoll) => r._count?.votes ?? '',
},
{
title: '',
key: 'action',
width: 80,
render: (_: unknown, record: SchedulingPoll) => (
<Button type="primary" size="small" onClick={() => onInsert(record.slug)}>
Insert
</Button>
),
},
];
return (
<Modal
open={open}
onCancel={onCancel}
title="Insert Scheduling Poll"
footer={null}
width={700}
destroyOnClose
>
<Tabs
items={[
{
key: 'existing',
label: 'Select Existing',
children: (
<>
<Input.Search
placeholder="Search by title or slug…"
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
style={{ marginBottom: 12 }}
/>
{loading ? (
<div style={{ textAlign: 'center', padding: 32 }}><Spin /></div>
) : (
<Table
dataSource={filtered}
columns={columns}
rowKey="id"
size="small"
pagination={false}
scroll={{ y: 300 }}
locale={{ emptyText: search ? 'No polls match your search' : 'No polls found — create one in the "Create New" tab' }}
/>
)}
</>
),
},
{
key: 'create',
label: 'Create New',
children: (
<Form
form={form}
layout="vertical"
onFinish={handleCreate}
initialValues={{ timezone: 'America/Edmonton', options: [{}] }}
size="small"
>
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
<Input placeholder="e.g. Team standup scheduling" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={2} placeholder="Optional description" />
</Form.Item>
<Form.Item name="timezone" label="Timezone" rules={[{ required: true }]}>
<Select options={TIMEZONE_OPTIONS.map((tz) => ({ label: tz, value: tz }))} />
</Form.Item>
<Text strong style={{ display: 'block', marginBottom: 8 }}>Date/Time Options</Text>
<Form.List name="options" rules={[{
validator: async (_, opts) => {
if (!opts || opts.length < 1) throw new Error('At least one option');
},
}]}>
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...rest }) => (
<Space key={key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...rest} name={[name, 'date']} rules={[{ required: true, message: 'Date' }]} style={{ marginBottom: 0 }}>
<DatePicker placeholder="Date" />
</Form.Item>
<Form.Item {...rest} name={[name, 'startTime']} rules={[{ required: true, message: 'Start' }]} style={{ marginBottom: 0 }}>
<TimePicker format="HH:mm" placeholder="Start" minuteStep={15} />
</Form.Item>
<Form.Item {...rest} name={[name, 'endTime']} rules={[{ required: true, message: 'End' }]} style={{ marginBottom: 0 }}>
<TimePicker format="HH:mm" placeholder="End" minuteStep={15} />
</Form.Item>
{fields.length > 1 && (
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => remove(name)} />
)}
</Space>
))}
<Form.Item style={{ marginBottom: 12 }}>
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
Add Option
</Button>
</Form.Item>
</>
)}
</Form.List>
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
<Button type="primary" htmlType="submit" loading={creating}>
Create &amp; Insert
</Button>
</Form.Item>
</Form>
),
},
]}
/>
</Modal>
);
}

View File

@ -82,6 +82,7 @@ import { ProductInsertModal } from '@/components/payments/ProductInsertModal';
import type { ProductInsertResult } from '@/components/payments/ProductInsertModal';
import { AdPickerModal } from '@/components/media/AdPickerModal';
import type { AdInsertResult } from '@/components/media/AdPickerModal';
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
type LayoutMode = 'split' | 'editor' | 'preview';
type PreviewMode = 'desktop' | 'mobile';
@ -593,7 +594,6 @@ export default function DocsPage() {
const [productInsertOpen, setProductInsertOpen] = useState(false);
const [adPickerOpen, setAdPickerOpen] = useState(false);
const [pollInsertOpen, setPollInsertOpen] = useState(false);
const [pollSlugInput, setPollSlugInput] = useState('');
const [dragOver, setDragOver] = useState(false);
const dragCounter = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -820,9 +820,8 @@ export default function DocsPage() {
return;
}
// Scheduling poll — opens slug input modal
// Scheduling poll — opens picker modal
if (snippetId === 'scheduling-poll') {
setPollSlugInput('');
setPollInsertOpen(true);
return;
}
@ -1069,10 +1068,7 @@ export default function DocsPage() {
setAdPickerOpen(false);
}, []);
const handlePollInsert = useCallback(() => {
const slug = pollSlugInput.trim();
if (!slug) return;
const handlePollInsert = useCallback((slug: string) => {
const html = `<div class="scheduling-poll-block" data-poll-slug="${slug}" data-show-comments="true" data-title="Vote on a Meeting Time">\n Loading poll...\n</div>`;
const ed = monacoEditorRef.current;
@ -1083,8 +1079,7 @@ export default function DocsPage() {
}
}
setPollInsertOpen(false);
setPollSlugInput('');
}, [pollSlugInput]);
}, []);
const handleCtxMenuClick = useCallback((snippetId: string) => {
setCtxMenu(null);
@ -2219,25 +2214,11 @@ export default function DocsPage() {
/>
{/* Scheduling Poll Insert Modal */}
<Modal
title="Insert Scheduling Poll"
<PollInsertModal
open={pollInsertOpen}
onOk={handlePollInsert}
onCancel={() => { setPollInsertOpen(false); setPollSlugInput(''); }}
okText="Insert"
okButtonProps={{ disabled: !pollSlugInput.trim() }}
>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Enter the slug of the scheduling poll to embed. You can find poll slugs in the Meeting Planner admin page.
</Typography.Text>
<Input
placeholder="e.g. team-meeting-march"
value={pollSlugInput}
onChange={(e) => setPollSlugInput(e.target.value)}
onPressEnter={handlePollInsert}
autoFocus
/>
</Modal>
onCancel={() => setPollInsertOpen(false)}
onInsert={handlePollInsert}
/>
{/* Custom right-click context menu with submenus */}
{ctxMenu && (