667 lines
30 KiB
TypeScript
667 lines
30 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Formik, Form, Field, ErrorMessage, FieldArray } from 'formik';
|
|
import formatDate from 'dateformat';
|
|
import VSCodeApi from './VSCodeApi';
|
|
import { paramCase } from 'param-case';
|
|
import gitUsername from 'git-user-name';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import TextareaAutosize from 'react-textarea-autosize';
|
|
|
|
interface KanbnTaskValidationOutput {
|
|
name: string,
|
|
metadata: {
|
|
tags: string[]
|
|
},
|
|
subTasks: Array<{
|
|
text: string
|
|
}>,
|
|
comments: Array<{
|
|
text: string
|
|
}>
|
|
}
|
|
|
|
interface KanbnTaskValidationInput extends KanbnTaskValidationOutput {
|
|
id: string
|
|
}
|
|
|
|
const TaskEditor = ({ task, tasks, columnName, columnNames, dateFormat, panelUuid, vscode }: {
|
|
task: KanbnTask | null,
|
|
tasks: Record<string, KanbnTask>,
|
|
columnName: string,
|
|
columnNames: string[],
|
|
dateFormat: string,
|
|
panelUuid: string,
|
|
vscode: VSCodeApi
|
|
}) => {
|
|
const editing = task !== null;
|
|
const [taskData, setTaskData] = useState({
|
|
id: task ? task.id : '',
|
|
name: task ? task.name : '',
|
|
description: task ? task.description : '',
|
|
column: columnName,
|
|
progress: task ? task.progress : 0,
|
|
metadata: {
|
|
created: (task && 'created' in task.metadata) ? task.metadata.created : new Date(),
|
|
updated: (task && 'updated' in task.metadata) ? task.metadata.updated : null,
|
|
started: (task && 'started' in task.metadata) ? formatDate(task.metadata.started!, 'yyyy-mm-dd') : '',
|
|
due: (task && 'due' in task.metadata) ? formatDate(task.metadata.due!, 'yyyy-mm-dd') : '',
|
|
completed: (task && 'completed' in task.metadata) ? formatDate(task.metadata.completed!, 'yyyy-mm-dd') : '',
|
|
assigned: (task && 'assigned' in task.metadata) ? task.metadata.assigned : (gitUsername() || ''),
|
|
tags: (task && 'tags' in task.metadata) ? (task.metadata.tags || []) : []
|
|
},
|
|
relations: task ? task.relations : [],
|
|
subTasks: task ? task.subTasks : [],
|
|
comments: task ? task.comments : []
|
|
});
|
|
const [editingDescription, setEditingDescription] = useState(!editing);
|
|
const [editingComment, setEditingComment] = useState(-1);
|
|
|
|
// Called when the name field is changed
|
|
const handleUpdateName = ({ target: { value } }, values) => {
|
|
const id = paramCase(value);
|
|
|
|
// Update the id preview
|
|
setTaskData({
|
|
...taskData,
|
|
id
|
|
});
|
|
|
|
// Update values
|
|
values.id = id;
|
|
|
|
// Update the webview panel title
|
|
vscode.postMessage({
|
|
command: 'kanbn.updatePanelTitle',
|
|
title: value || 'Untitled task'
|
|
});
|
|
};
|
|
|
|
// Called when the form is submitted
|
|
const handleSubmit = (values, setSubmitting, resetForm) => {
|
|
if (editing) {
|
|
vscode.postMessage({
|
|
command: 'kanbn.update',
|
|
taskId: task!.id,
|
|
taskData: values,
|
|
panelUuid
|
|
});
|
|
} else {
|
|
vscode.postMessage({
|
|
command: 'kanbn.create',
|
|
taskData: values,
|
|
panelUuid
|
|
});
|
|
}
|
|
setTaskData(values);
|
|
resetForm({ values });
|
|
setSubmitting(false);
|
|
};
|
|
|
|
// Called when the delete task button is clicked
|
|
const handleRemoveTask = values => {
|
|
vscode.postMessage({
|
|
command: 'kanbn.delete',
|
|
taskId: task!.id,
|
|
taskData: values,
|
|
panelUuid
|
|
});
|
|
};
|
|
|
|
// Check if a task's due date is in the past
|
|
const checkOverdue = (values: { metadata: { due?: string } }) => {
|
|
if ('due' in values.metadata && values.metadata.due !== undefined) {
|
|
return Date.parse(values.metadata.due) < (new Date()).getTime();
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Validate form data
|
|
const validate = (values: KanbnTaskValidationInput): KanbnTaskValidationOutput | {} => {
|
|
let hasErrors = false;
|
|
const errors: KanbnTaskValidationOutput = {
|
|
name: '',
|
|
metadata: {
|
|
tags: []
|
|
},
|
|
subTasks: [],
|
|
comments: []
|
|
};
|
|
|
|
// Task name cannot be empty
|
|
if (!values.name) {
|
|
errors.name = 'Task name is required.';
|
|
hasErrors = true;
|
|
}
|
|
|
|
// Check if the id is already in use
|
|
if (taskData.id in tasks && tasks[taskData.id].uuid !== (task ? task.uuid : '')) {
|
|
errors.name = 'There is already a task with the same name or id.';
|
|
hasErrors = true;
|
|
}
|
|
|
|
// Tag names cannot be empty
|
|
for (let i = 0; i < values.metadata.tags.length; i++) {
|
|
if (!values.metadata.tags[i]) {
|
|
errors.metadata.tags[i] = 'Tag cannot be empty.';
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
|
|
// Sub-tasks text cannot be empty
|
|
for (let i = 0; i < values.subTasks.length; i++) {
|
|
if (!values.subTasks[i].text) {
|
|
errors.subTasks[i] = {
|
|
text: 'Sub-task text cannot be empty.'
|
|
};
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
|
|
// Comments text cannot be empty
|
|
for (let i = 0; i < values.comments.length; i++) {
|
|
if (!values.comments[i].text) {
|
|
errors.comments[i] = {
|
|
text: 'Comment text cannot be empty.'
|
|
};
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
|
|
return hasErrors ? errors : {};
|
|
};
|
|
|
|
return (
|
|
<div className="kanbn-task-editor">
|
|
<Formik
|
|
initialValues={taskData}
|
|
validate={validate}
|
|
onSubmit={(values, { setSubmitting, resetForm }) => {
|
|
handleSubmit(values, setSubmitting, resetForm);
|
|
}}
|
|
>
|
|
{({
|
|
dirty,
|
|
values,
|
|
handleChange,
|
|
isSubmitting
|
|
}) => (
|
|
<React.Fragment>
|
|
<h1 className="kanbn-task-editor-title">
|
|
{editing ? 'Update task' : 'Create new task'}
|
|
{dirty && <span className="kanbn-task-editor-dirty">*</span>}
|
|
{editing && <span className="kanbn-task-editor-dates">
|
|
{
|
|
[
|
|
'created' in task!.metadata ? `Created ${formatDate(task!.metadata.created, dateFormat)}` : null,
|
|
'updated' in task!.metadata ? `Updated ${formatDate(task!.metadata.updated, dateFormat)}` : null
|
|
].filter(i => i).join(', ')
|
|
}
|
|
</span>}
|
|
</h1>
|
|
<Form>
|
|
<div className="kanbn-task-editor-form">
|
|
<div className="kanbn-task-editor-column-left">
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-name">
|
|
<label className="kanbn-task-editor-field-label">
|
|
<p>Name</p>
|
|
<Field
|
|
className="kanbn-task-editor-field-input"
|
|
name="name"
|
|
placeholder="Name"
|
|
onChange={e => {
|
|
handleChange(e);
|
|
handleUpdateName(e, values);
|
|
}}
|
|
/>
|
|
</label>
|
|
<div className="kanbn-task-editor-id">{taskData.id}</div>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name="name"
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-description">
|
|
<label
|
|
className="kanbn-task-editor-field-label kanbn-task-editor-field-label-description"
|
|
htmlFor="description-input"
|
|
>
|
|
<p>Description</p>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-edit-description"
|
|
title="Edit description"
|
|
onClick={() => {
|
|
setEditingDescription(!editingDescription);
|
|
}}
|
|
>
|
|
{
|
|
editingDescription
|
|
? <React.Fragment><i className="codicon codicon-preview"></i> Preview</React.Fragment>
|
|
: <React.Fragment><i className="codicon codicon-edit"></i> Edit</React.Fragment>
|
|
}
|
|
</button>
|
|
{
|
|
editingDescription
|
|
? <Field
|
|
className="kanbn-task-editor-field-textarea"
|
|
id="description-input"
|
|
as={TextareaAutosize}
|
|
name="description"
|
|
/>
|
|
: <ReactMarkdown className="kanbn-task-editor-description-preview">
|
|
{values.description}
|
|
</ReactMarkdown>
|
|
}
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name="description"
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-subtasks">
|
|
<h2 className="kanbn-task-editor-title">Sub-tasks</h2>
|
|
<FieldArray name="subTasks">
|
|
{({ insert, remove, push }) => (
|
|
<div>
|
|
{values.subTasks.length > 0 && values.subTasks.map((subTask, index) => (
|
|
<div className="kanbn-task-editor-row kanbn-task-editor-row-subtask" key={index}>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-field-subtask-completed">
|
|
<Field
|
|
className="kanbn-task-editor-field-checkbox"
|
|
type="checkbox"
|
|
name={`subTasks.${index}.completed`}
|
|
/>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name={`subTasks.${index}.completed`}
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-field-subtask-text">
|
|
<Field
|
|
className="kanbn-task-editor-field-input"
|
|
name={`subTasks.${index}.text`}
|
|
placeholder="Sub-task text"
|
|
/>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name={`subTasks.${index}.text`}
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-column-buttons">
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-delete"
|
|
title="Remove sub-task"
|
|
onClick={() => remove(index)}
|
|
>
|
|
<i className="codicon codicon-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="kanbn-task-editor-buttons">
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-add"
|
|
title="Add sub-task"
|
|
onClick={() => push({ completed: false, text: '' })}
|
|
>
|
|
<i className="codicon codicon-tasklist"></i>Add sub-task
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</FieldArray>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-relations">
|
|
<h2 className="kanbn-task-editor-title">Relations</h2>
|
|
<FieldArray name="relations">
|
|
{({ insert, remove, push }) => (
|
|
<div>
|
|
{values.relations.length > 0 && values.relations.map((relation, index) => (
|
|
<div className="kanbn-task-editor-row kanbn-task-editor-row-relation" key={index}>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-field-relation-type">
|
|
<Field
|
|
className="kanbn-task-editor-field-input"
|
|
name={`relations.${index}.type`}
|
|
placeholder="Relation type"
|
|
/>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name={`relations.${index}.type`}
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-field-relation-task">
|
|
<Field
|
|
className="kanbn-task-editor-field-select"
|
|
as="select"
|
|
name={`relations.${index}.task`}
|
|
>
|
|
{Object.keys(tasks).map(t => <option value={t}>{t}</option>)}
|
|
</Field>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name={`relations.${index}.task`}
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-column-buttons">
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-delete"
|
|
title="Remove relation"
|
|
onClick={() => remove(index)}
|
|
>
|
|
<i className="codicon codicon-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="kanbn-task-editor-buttons">
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-add"
|
|
title="Add relation"
|
|
onClick={() => push({ type: '', task: '' })}
|
|
>
|
|
<i className="codicon codicon-link"></i>Add relation
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</FieldArray>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-comments">
|
|
<h2 className="kanbn-task-editor-title">Comments</h2>
|
|
<FieldArray name="comments">
|
|
{({ insert, remove, push }) => (
|
|
<div>
|
|
{values.comments.length > 0 && values.comments.map((comment, index) => (
|
|
<div className="kanbn-task-editor-row-comment" key={index}>
|
|
<div className="kanbn-task-editor-row">
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-field-comment-author">
|
|
{
|
|
editingComment === index
|
|
? <React.Fragment>
|
|
<Field
|
|
className="kanbn-task-editor-field-input"
|
|
name={`comments.${index}.author`}
|
|
placeholder="Comment author"
|
|
/>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name={`comments.${index}.author`}
|
|
/>
|
|
</React.Fragment>
|
|
: <div className="kanbn-task-editor-field-comment-author-value">
|
|
<i className="codicon codicon-account"></i>
|
|
{comment.author || 'Anonymous'}
|
|
</div>
|
|
}
|
|
</div>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-field-comment-date">
|
|
{formatDate(comment.date, dateFormat)}
|
|
</div>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-column-buttons">
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-delete"
|
|
title="Remove comment"
|
|
onClick={() => remove(index)}
|
|
>
|
|
<i className="codicon codicon-trash"></i>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-edit"
|
|
title={editingComment === index ? "View comment" : "Edit comment"}
|
|
onClick={() => {
|
|
setEditingComment(editingComment !== index ? index : -1);
|
|
}}
|
|
>
|
|
{
|
|
editingComment === index
|
|
? <i className="codicon codicon-preview"></i>
|
|
: <i className="codicon codicon-edit"></i>
|
|
}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="kanbn-task-editor-row">
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-field-comment-text">
|
|
{
|
|
editingComment === index
|
|
? <React.Fragment>
|
|
<Field
|
|
className="kanbn-task-editor-field-textarea"
|
|
as={TextareaAutosize}
|
|
name={`comments.${index}.text`}
|
|
/>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name={`comments.${index}.text`}
|
|
/>
|
|
</React.Fragment>
|
|
: <ReactMarkdown className="kanbn-task-editor-comment-text">
|
|
{comment.text}
|
|
</ReactMarkdown>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="kanbn-task-editor-buttons">
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-add"
|
|
title="Add comment"
|
|
onClick={() => {
|
|
push({ text: '', date: new Date(), author: gitUsername() || '' });
|
|
setEditingComment(values.comments.length);
|
|
}}
|
|
>
|
|
<i className="codicon codicon-comment"></i>Add comment
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</FieldArray>
|
|
</div>
|
|
</div>
|
|
<div className="kanbn-task-editor-column-right">
|
|
<div className="kanbn-task-editor-buttons">
|
|
{editing && <button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-delete"
|
|
title="Delete task"
|
|
onClick={() => {
|
|
handleRemoveTask(values);
|
|
}}
|
|
>
|
|
<i className="codicon codicon-trash"></i>Delete
|
|
</button>}
|
|
<button
|
|
type="submit"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-submit"
|
|
title="Save task"
|
|
disabled={isSubmitting}
|
|
>
|
|
<i className="codicon codicon-save"></i>Save
|
|
</button>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-column">
|
|
<label className="kanbn-task-editor-field-label">
|
|
<p>Column</p>
|
|
<Field
|
|
className="kanbn-task-editor-field-select"
|
|
as="select"
|
|
name="column"
|
|
>
|
|
{columnNames.map(c => <option value={c}>{c}</option>)}
|
|
</Field>
|
|
</label>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name="column"
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-assigned">
|
|
<label className="kanbn-task-editor-field-label">
|
|
<p>Assigned to</p>
|
|
<Field
|
|
className="kanbn-task-editor-field-input"
|
|
name="metadata.assigned"
|
|
placeholder="Assigned to"
|
|
/>
|
|
</label>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name="metadata.assigned"
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-started">
|
|
<label className="kanbn-task-editor-field-label">
|
|
<p>Started date</p>
|
|
<Field
|
|
className="kanbn-task-editor-field-input"
|
|
type="date"
|
|
name="metadata.started"
|
|
/>
|
|
</label>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name="metadata.started"
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-due">
|
|
<label className="kanbn-task-editor-field-label">
|
|
<p>Due date</p>
|
|
<Field
|
|
className={[
|
|
'kanbn-task-editor-field-input',
|
|
checkOverdue(values) ? 'kanbn-task-overdue' : null
|
|
].filter(i => i).join(' ')}
|
|
type="date"
|
|
name="metadata.due"
|
|
/>
|
|
</label>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name="metadata.due"
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-completed">
|
|
<label className="kanbn-task-editor-field-label">
|
|
<p>Completed date</p>
|
|
<Field
|
|
className="kanbn-task-editor-field-input"
|
|
type="date"
|
|
name="metadata.completed"
|
|
/>
|
|
</label>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name="metadata.completed"
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-progress">
|
|
<label className="kanbn-task-editor-field-label">
|
|
<p>Progress</p>
|
|
<Field
|
|
className="kanbn-task-editor-field-input"
|
|
type="number"
|
|
name="progress"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
/>
|
|
<div className="kanbn-task-progress" style={{
|
|
width: `${Math.min(1, Math.max(0, values.progress || 0)) * 100}%`
|
|
}}></div>
|
|
</label>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name="progress"
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-field kanbn-task-editor-field-tags">
|
|
<label className="kanbn-task-editor-field-label">
|
|
<p>Tags</p>
|
|
</label>
|
|
<FieldArray name="metadata.tags">
|
|
{({ insert, remove, push }) => (
|
|
<div>
|
|
{(
|
|
'tags' in values.metadata &&
|
|
values.metadata.tags!.length > 0
|
|
) && values.metadata.tags!.map((tag, index) => (
|
|
<div className="kanbn-task-editor-row kanbn-task-editor-row-tag" key={index}>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-field-tag">
|
|
<Field
|
|
className="kanbn-task-editor-field-input"
|
|
name={`metadata.tags.${index}`}
|
|
placeholder="Tag name"
|
|
/>
|
|
<div
|
|
className={[
|
|
'kanbn-task-editor-tag-highlight',
|
|
`kanbn-task-tag-${paramCase(values.metadata.tags![index])}`
|
|
].join(' ')}
|
|
></div>
|
|
<ErrorMessage
|
|
className="kanbn-task-editor-field-errors"
|
|
component="div"
|
|
name={`metadata.tags.${index}`}
|
|
/>
|
|
</div>
|
|
<div className="kanbn-task-editor-column kanbn-task-editor-column-buttons">
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-delete"
|
|
title="Remove tag"
|
|
onClick={() => remove(index)}
|
|
>
|
|
<i className="codicon codicon-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="kanbn-task-editor-buttons">
|
|
<button
|
|
type="button"
|
|
className="kanbn-task-editor-button kanbn-task-editor-button-add"
|
|
title="Add tag"
|
|
onClick={() => push('')}
|
|
>
|
|
<i className="codicon codicon-tag"></i>Add tag
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</FieldArray>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Form>
|
|
</React.Fragment>
|
|
)}
|
|
</Formik>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TaskEditor;
|