mirror of
https://github.com/pikami/scrummie-poker.git
synced 2024-12-11 19:15:43 +00:00
Added ticket content with rich editor
This commit is contained in:
parent
40b1ef6f0c
commit
efeeb10746
@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ckeditor/ckeditor5-react": "^9.3.0",
|
||||||
"@tanstack/react-form": "^0.33.0",
|
"@tanstack/react-form": "^0.33.0",
|
||||||
"@tanstack/react-router": "^1.62.0",
|
"@tanstack/react-router": "^1.62.0",
|
||||||
"appwrite": "^16.0.2",
|
"appwrite": "^16.0.2",
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { PencilIcon } from './icons';
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card: React.FC<CardProps> = ({ title, description, onClick }) => {
|
const Card: React.FC<CardProps> = ({ title, description, onClick, onEdit }) => {
|
||||||
const className = classNames(
|
const className = classNames(
|
||||||
'p-4 border rounded-lg shadow-sm transition',
|
'p-4 border rounded-lg shadow-sm transition',
|
||||||
{
|
{
|
||||||
@ -19,8 +21,22 @@ const Card: React.FC<CardProps> = ({ title, description, onClick }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} onClick={onClick}>
|
<div className={className} onClick={onClick}>
|
||||||
<h3 className="text-lg font-bold">{title}</h3>
|
<div className="flex items-center justify-between">
|
||||||
<p className="mt-2">{description}</p>
|
<h3 className="text-lg font-bold">{title}</h3>
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
className="ml-2 p-1 text-gray-600 hover:text-gray-900 dark:text-nero-400 dark:hover:text-nero-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
aria-label="Edit card"
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && <p className="mt-2">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -32,7 +32,7 @@ const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, children }) => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'relative h-full w-80 transform space-y-6 bg-white p-6 shadow-lg transition-transform dark:bg-nero-900',
|
'relative h-full w-2/3 transform space-y-6 bg-white p-6 shadow-lg transition-transform dark:bg-nero-900',
|
||||||
{
|
{
|
||||||
'translate-x-0': isOpen,
|
'translate-x-0': isOpen,
|
||||||
'translate-x-full': !isOpen,
|
'translate-x-full': !isOpen,
|
||||||
|
72
src/components/HtmlEmbed.tsx
Normal file
72
src/components/HtmlEmbed.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
interface HtmlEmbedProps {
|
||||||
|
body: string;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HtmlEmbed: React.FC<HtmlEmbedProps> = ({ body, className }) => {
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
body {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
body {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.x),body:not(.x){height:auto!important}
|
||||||
|
p:first-child{margin-top:0;}
|
||||||
|
p:last-child{margin-bottom:0;}
|
||||||
|
a[href]{color: #3781B8;text-decoration:none;}
|
||||||
|
a[href]:hover{text-decoration:underline;}
|
||||||
|
blockquote[type=cite] {margin:0 0 0 .8ex;border-left: 1px #ccc solid;padding-left: 1ex;}
|
||||||
|
img { max-width: 100%; }
|
||||||
|
ul, ol { padding: 0; margin: 0 0 10px 25px; }
|
||||||
|
ul { list-style-type: disc; }`;
|
||||||
|
|
||||||
|
const cnt = `
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="script-src 'none'" />
|
||||||
|
<base target="_blank" />
|
||||||
|
<style>
|
||||||
|
${styles}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>${body}</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
csp: "script-src 'none'",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
className={className}
|
||||||
|
srcDoc={cnt}
|
||||||
|
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HtmlEmbed;
|
102
src/components/RichEditor.tsx
Normal file
102
src/components/RichEditor.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { CKEditor } from '@ckeditor/ckeditor5-react';
|
||||||
|
import {
|
||||||
|
ClassicEditor,
|
||||||
|
Bold,
|
||||||
|
Essentials,
|
||||||
|
Italic,
|
||||||
|
Mention,
|
||||||
|
Paragraph,
|
||||||
|
Undo,
|
||||||
|
Heading,
|
||||||
|
Font,
|
||||||
|
Code,
|
||||||
|
Strikethrough,
|
||||||
|
Table,
|
||||||
|
SourceEditing,
|
||||||
|
List,
|
||||||
|
Link,
|
||||||
|
Image,
|
||||||
|
Indent,
|
||||||
|
Subscript,
|
||||||
|
Superscript,
|
||||||
|
BlockQuote,
|
||||||
|
CodeBlock,
|
||||||
|
TodoList,
|
||||||
|
} from 'ckeditor5';
|
||||||
|
import 'ckeditor5/ckeditor5.css';
|
||||||
|
|
||||||
|
interface RichEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (newValue: string) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RichEditor: React.FC<RichEditorProps> = ({ value, onChange, onBlur }) => {
|
||||||
|
return (
|
||||||
|
<CKEditor
|
||||||
|
data={value}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={(_, editor) => onChange(editor.getData())}
|
||||||
|
editor={ClassicEditor}
|
||||||
|
config={{
|
||||||
|
toolbar: {
|
||||||
|
items: [
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
'|',
|
||||||
|
'heading',
|
||||||
|
'|',
|
||||||
|
'fontfamily',
|
||||||
|
'fontsize',
|
||||||
|
'fontColor',
|
||||||
|
'fontBackgroundColor',
|
||||||
|
'|',
|
||||||
|
'bold',
|
||||||
|
'italic',
|
||||||
|
'strikethrough',
|
||||||
|
'subscript',
|
||||||
|
'superscript',
|
||||||
|
'code',
|
||||||
|
'|',
|
||||||
|
'link',
|
||||||
|
'blockQuote',
|
||||||
|
'codeBlock',
|
||||||
|
'|',
|
||||||
|
'bulletedList',
|
||||||
|
'numberedList',
|
||||||
|
'todoList',
|
||||||
|
'outdent',
|
||||||
|
'indent',
|
||||||
|
],
|
||||||
|
shouldNotGroupWhenFull: false,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
Undo,
|
||||||
|
Heading,
|
||||||
|
Font,
|
||||||
|
Code,
|
||||||
|
Bold,
|
||||||
|
Strikethrough,
|
||||||
|
Essentials,
|
||||||
|
Italic,
|
||||||
|
Mention,
|
||||||
|
Paragraph,
|
||||||
|
Table,
|
||||||
|
SourceEditing,
|
||||||
|
List,
|
||||||
|
Link,
|
||||||
|
Image,
|
||||||
|
Indent,
|
||||||
|
Subscript,
|
||||||
|
Superscript,
|
||||||
|
BlockQuote,
|
||||||
|
CodeBlock,
|
||||||
|
TodoList,
|
||||||
|
],
|
||||||
|
initialData: '',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RichEditor;
|
@ -1,6 +1,5 @@
|
|||||||
import { useForm } from '@tanstack/react-form';
|
import { useForm } from '@tanstack/react-form';
|
||||||
import { useEstimationsList } from '../../lib/context/estimationsList';
|
import { useEstimationsList } from '../../lib/context/estimationsList';
|
||||||
import { useUser } from '../../lib/context/user';
|
|
||||||
import Input from '../Input';
|
import Input from '../Input';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
|
|
||||||
@ -11,7 +10,6 @@ interface CreateEstimationSessionFormProps {
|
|||||||
const CreateEstimationSessionForm: React.FC<
|
const CreateEstimationSessionForm: React.FC<
|
||||||
CreateEstimationSessionFormProps
|
CreateEstimationSessionFormProps
|
||||||
> = ({ onCreated }) => {
|
> = ({ onCreated }) => {
|
||||||
const user = useUser();
|
|
||||||
const estimationsList = useEstimationsList();
|
const estimationsList = useEstimationsList();
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -20,7 +18,6 @@ const CreateEstimationSessionForm: React.FC<
|
|||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
await estimationsList?.add({
|
await estimationsList?.add({
|
||||||
name: value.name,
|
name: value.name,
|
||||||
userId: user.current?.$id,
|
|
||||||
});
|
});
|
||||||
onCreated();
|
onCreated();
|
||||||
},
|
},
|
||||||
|
21
src/components/icons/PencilIcon.tsx
Normal file
21
src/components/icons/PencilIcon.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const PencilIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className={`size-6 ${props.className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PencilIcon;
|
3
src/components/icons/index.ts
Normal file
3
src/components/icons/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import PencilIcon from './PencilIcon';
|
||||||
|
|
||||||
|
export { PencilIcon };
|
@ -13,15 +13,17 @@ import {
|
|||||||
} from '../appwrite';
|
} from '../appwrite';
|
||||||
import { DatabaseModels, EntityModels } from '../types';
|
import { DatabaseModels, EntityModels } from '../types';
|
||||||
import { useUser } from './user';
|
import { useUser } from './user';
|
||||||
import { EstimationSessionTicket } from '../types/entityModels';
|
|
||||||
import { mapDatabaseToEntity } from '../mappers/estimationSession';
|
import { mapDatabaseToEntity } from '../mappers/estimationSession';
|
||||||
|
import { EditTicketRequest, CreateTicketRequest } from '../types/requestModels';
|
||||||
|
import { EstimationSessionTicket } from '../types/entityModels';
|
||||||
|
|
||||||
interface EstimationContextType {
|
interface EstimationContextType {
|
||||||
setSessionId: (sessionId: string) => void;
|
setSessionId: (sessionId: string) => void;
|
||||||
setActiveTicket: (ticketId: string) => Promise<void>;
|
setActiveTicket: (ticketId: string) => Promise<void>;
|
||||||
setRevealed: (revealed: boolean) => Promise<void>;
|
setRevealed: (revealed: boolean) => Promise<void>;
|
||||||
setVote: (estimate: string) => Promise<void>;
|
setVote: (estimate: string) => Promise<void>;
|
||||||
createTicket: (ticket: Omit<EstimationSessionTicket, 'id'>) => Promise<void>;
|
createTicket: (ticket: CreateTicketRequest) => Promise<void>;
|
||||||
|
updateTicket: (ticket: EditTicketRequest) => Promise<void>;
|
||||||
currentSessionData?: EntityModels.EstimationSession;
|
currentSessionData?: EntityModels.EstimationSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,14 +120,15 @@ export const EstimationContextProvider = (props: PropsWithChildren) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const createTicket = async (ticket: Omit<EstimationSessionTicket, 'id'>) => {
|
const createTicket = async ({ name, content }: CreateTicketRequest) => {
|
||||||
const newTicketsValue = currentSessionData?.tickets
|
const newTicket: EstimationSessionTicket = {
|
||||||
.concat([
|
id: crypto.randomUUID(),
|
||||||
{
|
name,
|
||||||
...ticket,
|
content,
|
||||||
id: crypto.randomUUID(),
|
};
|
||||||
},
|
|
||||||
])
|
const newTicketsValue = [newTicket]
|
||||||
|
.concat(currentSessionData?.tickets ?? [])
|
||||||
.map((x) => JSON.stringify(x));
|
.map((x) => JSON.stringify(x));
|
||||||
|
|
||||||
await databases.updateDocument<DatabaseModels.EstimationSession>(
|
await databases.updateDocument<DatabaseModels.EstimationSession>(
|
||||||
@ -138,6 +141,29 @@ export const EstimationContextProvider = (props: PropsWithChildren) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateTicket = async ({ id, name, content }: EditTicketRequest) => {
|
||||||
|
const editedTicket = currentSessionData?.tickets.find((x) => x.id === id);
|
||||||
|
if (!editedTicket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editedTicket.name = name;
|
||||||
|
editedTicket.content = content;
|
||||||
|
|
||||||
|
const newTicketsValue = currentSessionData?.tickets.map((x) =>
|
||||||
|
JSON.stringify(x),
|
||||||
|
);
|
||||||
|
|
||||||
|
await databases.updateDocument<DatabaseModels.EstimationSession>(
|
||||||
|
DATABASE_ID,
|
||||||
|
ESTIMATION_SESSION_COLLECTION_ID,
|
||||||
|
sessionId,
|
||||||
|
{
|
||||||
|
tickets: newTicketsValue,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EstimationContext.Provider
|
<EstimationContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@ -146,6 +172,7 @@ export const EstimationContextProvider = (props: PropsWithChildren) => {
|
|||||||
setRevealed,
|
setRevealed,
|
||||||
setVote,
|
setVote,
|
||||||
createTicket,
|
createTicket,
|
||||||
|
updateTicket,
|
||||||
currentSessionData,
|
currentSessionData,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -21,7 +21,7 @@ import { useUser } from './user';
|
|||||||
|
|
||||||
interface EstimationsListContextType {
|
interface EstimationsListContextType {
|
||||||
current: EntityModels.EstimationSession[];
|
current: EntityModels.EstimationSession[];
|
||||||
add: (estimationSession: { name: string; userId?: string }) => Promise<void>;
|
add: (estimationSession: { name: string }) => Promise<void>;
|
||||||
remove: (id: string) => Promise<void>;
|
remove: (id: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ export function EstimationsListContextProvider(props: PropsWithChildren) {
|
|||||||
EntityModels.EstimationSession[]
|
EntityModels.EstimationSession[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const add = async (estimationSession: { name: string; userId?: string }) => {
|
const add = async (estimationSession: { name: string }) => {
|
||||||
if (!userData) {
|
if (!userData) {
|
||||||
throw Error('Tried to create new estimation with no user context');
|
throw Error('Tried to create new estimation with no user context');
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ interface EstimationSession {
|
|||||||
interface EstimationSessionTicket {
|
interface EstimationSessionTicket {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
|
10
src/lib/types/requestModels.ts
Normal file
10
src/lib/types/requestModels.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
interface CreateTicketRequest {
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditTicketRequest extends CreateTicketRequest {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CreateTicketRequest, EditTicketRequest };
|
@ -5,9 +5,9 @@ import TaskSidebar from './components/TaskSidebar';
|
|||||||
import VoteSelection from './components/VoteSelection';
|
import VoteSelection from './components/VoteSelection';
|
||||||
import VoteList from './components/VoteList';
|
import VoteList from './components/VoteList';
|
||||||
import { Button, ButtonColor, Drawer } from '../../components';
|
import { Button, ButtonColor, Drawer } from '../../components';
|
||||||
import CreateTicketForm from './components/CreateTicketForm';
|
import EditTicketForm from './components/EditTicketForm';
|
||||||
import CopyInput from '../../components/CopyInput';
|
|
||||||
import PlayerList from './components/PlayerList';
|
import PlayerList from './components/PlayerList';
|
||||||
|
import HtmlEmbed from '../../components/HtmlEmbed';
|
||||||
|
|
||||||
const fibonacciSequence = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 100];
|
const fibonacciSequence = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 100];
|
||||||
|
|
||||||
@ -16,8 +16,10 @@ const route = getRouteApi('/_authenticated/estimate/session/$sessionId');
|
|||||||
const Estimation: React.FC = () => {
|
const Estimation: React.FC = () => {
|
||||||
const { sessionId } = route.useParams();
|
const { sessionId } = route.useParams();
|
||||||
const estimationState = useEstimationContext();
|
const estimationState = useEstimationContext();
|
||||||
useEffect(() => estimationState?.setSessionId(sessionId), [sessionId]);
|
|
||||||
const [isDrawerOpen, setDrawerOpen] = useState(false);
|
const [isDrawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [editingTicketId, setEditingTicketId] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => estimationState?.setSessionId(sessionId), [sessionId]);
|
||||||
|
|
||||||
if (!estimationState?.currentSessionData) {
|
if (!estimationState?.currentSessionData) {
|
||||||
return null; // TODO: Add a loader
|
return null; // TODO: Add a loader
|
||||||
@ -28,6 +30,7 @@ const Estimation: React.FC = () => {
|
|||||||
setVote,
|
setVote,
|
||||||
setRevealed,
|
setRevealed,
|
||||||
createTicket,
|
createTicket,
|
||||||
|
updateTicket,
|
||||||
currentSessionData: {
|
currentSessionData: {
|
||||||
tickets: tickets,
|
tickets: tickets,
|
||||||
sessionState: {
|
sessionState: {
|
||||||
@ -47,24 +50,25 @@ const Estimation: React.FC = () => {
|
|||||||
tickets={tickets}
|
tickets={tickets}
|
||||||
onSelectTicket={(ticket) => setActiveTicket(ticket.id)}
|
onSelectTicket={(ticket) => setActiveTicket(ticket.id)}
|
||||||
onAddTicket={() => setDrawerOpen(true)}
|
onAddTicket={() => setDrawerOpen(true)}
|
||||||
|
onEditTicket={(ticketId) => {
|
||||||
|
setEditingTicketId(ticketId);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full flex-grow flex-col p-6">
|
<div className="flex w-full flex-grow flex-col p-6">
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<span className="align-middle text-xl font-semibold">
|
|
||||||
Invite others to join your session
|
|
||||||
</span>
|
|
||||||
<CopyInput value={`${window.location.origin}/join/${sessionId}`} />
|
|
||||||
</div>
|
|
||||||
{currentTicket ? (
|
{currentTicket ? (
|
||||||
<>
|
<>
|
||||||
<h1 className="mb-4 text-2xl font-bold">{currentTicket.name}</h1>
|
<h1 className="mb-4 text-2xl font-bold">{currentTicket.name}</h1>
|
||||||
<p className="mb-8 text-gray-700 dark:text-gray-200">
|
<div className="grow">
|
||||||
{currentTicket.id}
|
<HtmlEmbed
|
||||||
</p>
|
className="h-full w-full"
|
||||||
|
body={currentTicket.content}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<VoteSelection
|
<VoteSelection
|
||||||
className="mb-4 mt-auto flex flex-wrap gap-1 space-x-4"
|
className="mb-4 flex flex-wrap gap-1 space-x-4"
|
||||||
onSelect={(vote) => setVote(vote)}
|
onSelect={(vote) => setVote(vote)}
|
||||||
options={fibonacciSequence.map((x) => `${x}`)}
|
options={fibonacciSequence.map((x) => `${x}`)}
|
||||||
value={currentPlayerVote}
|
value={currentPlayerVote}
|
||||||
@ -86,12 +90,26 @@ const Estimation: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlayerList players={players ?? []} />
|
<PlayerList sessionId={sessionId} players={players ?? []} />
|
||||||
|
|
||||||
<Drawer isOpen={isDrawerOpen} onClose={() => setDrawerOpen(false)}>
|
<Drawer
|
||||||
<CreateTicketForm
|
isOpen={isDrawerOpen}
|
||||||
onCreate={async (ticket) => {
|
onClose={() => {
|
||||||
await createTicket(ticket);
|
setDrawerOpen(false);
|
||||||
|
setEditingTicketId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditTicketForm
|
||||||
|
initialData={tickets.find((x) => x.id === editingTicketId)}
|
||||||
|
onSave={async (ticket) => {
|
||||||
|
if (editingTicketId.length > 0) {
|
||||||
|
await updateTicket({
|
||||||
|
...ticket,
|
||||||
|
id: editingTicketId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createTicket(ticket);
|
||||||
|
}
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -1,18 +1,28 @@
|
|||||||
import { useForm } from '@tanstack/react-form';
|
import { useForm } from '@tanstack/react-form';
|
||||||
import { Button, Input } from '../../../components';
|
import { Button, Input } from '../../../components';
|
||||||
import { EstimationSessionTicket } from '../../../lib/types/entityModels';
|
import RichEditor from '../../../components/RichEditor';
|
||||||
|
|
||||||
interface CreateTicketFormProps {
|
interface EditTicketFormData {
|
||||||
onCreate: (ticket: Omit<EstimationSessionTicket, 'id'>) => Promise<void>;
|
name: string;
|
||||||
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateTicketForm: React.FC<CreateTicketFormProps> = ({ onCreate }) => {
|
interface EditTicketFormProps {
|
||||||
const form = useForm({
|
initialData?: EditTicketFormData;
|
||||||
defaultValues: {
|
onSave: (ticket: EditTicketFormData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditTicketForm: React.FC<EditTicketFormProps> = ({
|
||||||
|
initialData,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const form = useForm<EditTicketFormData>({
|
||||||
|
defaultValues: initialData ?? {
|
||||||
name: '',
|
name: '',
|
||||||
|
content: '',
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
await onCreate(value);
|
await onSave(value);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,12 +50,22 @@ const CreateTicketForm: React.FC<CreateTicketFormProps> = ({ onCreate }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<form.Field
|
||||||
|
name="content"
|
||||||
|
children={(field) => (
|
||||||
|
<RichEditor
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(value) => field.handleChange(value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Button type="submit" fullWidth>
|
<Button type="submit" fullWidth>
|
||||||
Create
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateTicketForm;
|
export default EditTicketForm;
|
@ -1,12 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { EntityModels } from '../../../lib/types';
|
import { EntityModels } from '../../../lib/types';
|
||||||
|
import CopyInput from '../../../components/CopyInput';
|
||||||
|
|
||||||
interface PlayerListProps {
|
interface PlayerListProps {
|
||||||
|
sessionId: string;
|
||||||
players: EntityModels.Player[];
|
players: EntityModels.Player[];
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayerList: React.FC<PlayerListProps> = ({
|
const PlayerList: React.FC<PlayerListProps> = ({
|
||||||
|
sessionId,
|
||||||
players,
|
players,
|
||||||
title = 'Players',
|
title = 'Players',
|
||||||
}) => {
|
}) => {
|
||||||
@ -32,6 +35,13 @@ const PlayerList: React.FC<PlayerListProps> = ({
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex flex-row flex-wrap items-center justify-center gap-2">
|
||||||
|
<div className="text-l align-middle font-semibold">
|
||||||
|
Invite others to join your session
|
||||||
|
</div>
|
||||||
|
<CopyInput value={`${window.location.origin}/join/${sessionId}`} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -6,6 +6,7 @@ interface TaskSidebarProps {
|
|||||||
tickets: EstimationSessionTicket[];
|
tickets: EstimationSessionTicket[];
|
||||||
onSelectTicket: (ticket: EstimationSessionTicket) => void;
|
onSelectTicket: (ticket: EstimationSessionTicket) => void;
|
||||||
onAddTicket: () => void;
|
onAddTicket: () => void;
|
||||||
|
onEditTicket: (ticketId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskSidebar: React.FC<TaskSidebarProps> = ({
|
const TaskSidebar: React.FC<TaskSidebarProps> = ({
|
||||||
@ -13,6 +14,7 @@ const TaskSidebar: React.FC<TaskSidebarProps> = ({
|
|||||||
tickets,
|
tickets,
|
||||||
onSelectTicket,
|
onSelectTicket,
|
||||||
onAddTicket,
|
onAddTicket,
|
||||||
|
onEditTicket,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
@ -23,8 +25,8 @@ const TaskSidebar: React.FC<TaskSidebarProps> = ({
|
|||||||
<Card
|
<Card
|
||||||
key={item.id}
|
key={item.id}
|
||||||
title={item.name}
|
title={item.name}
|
||||||
description={item.id}
|
|
||||||
onClick={() => onSelectTicket(item)}
|
onClick={() => onSelectTicket(item)}
|
||||||
|
onEdit={() => onEditTicket(item.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
onAddItem={onAddTicket}
|
onAddItem={onAddTicket}
|
||||||
|
Loading…
Reference in New Issue
Block a user