Added ticket content with rich editor

This commit is contained in:
Pijus Kamandulis 2024-10-13 17:18:12 +03:00
parent 40b1ef6f0c
commit efeeb10746
17 changed files with 348 additions and 48 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -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",

View File

@ -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>
); );
}; };

View File

@ -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,

View 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;

View 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;

View File

@ -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();
}, },

View 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;

View File

@ -0,0 +1,3 @@
import PencilIcon from './PencilIcon';
export { PencilIcon };

View File

@ -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,
}} }}
> >

View File

@ -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');
} }

View File

@ -11,6 +11,7 @@ interface EstimationSession {
interface EstimationSessionTicket { interface EstimationSessionTicket {
id: string; id: string;
name: string; name: string;
content: string;
} }
interface SessionState { interface SessionState {

View File

@ -0,0 +1,10 @@
interface CreateTicketRequest {
name: string;
content: string;
}
interface EditTicketRequest extends CreateTicketRequest {
id: string;
}
export type { CreateTicketRequest, EditTicketRequest };

View File

@ -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);
}} }}
/> />

View File

@ -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;

View File

@ -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>
); );
}; };

View File

@ -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}