Merge branch 'main' into develop

This commit is contained in:
0x1eef 2024-01-12 23:39:48 -03:00
commit 04af5bad1e
25 changed files with 486 additions and 407 deletions

View file

@ -34,7 +34,7 @@ module Twenty
ActiveRecord::Base.establish_connection(
adapter: "sqlite3",
database: path,
pool: 3
pool: 16
)
end

View file

@ -1,7 +1,9 @@
module Twenty::GraphQL::Input
class TaskInput < GraphQL::Schema::InputObject
argument :title, String
argument :content, String
argument :project_id, Int
require_relative "../type/task_status"
argument :title, String, required: false
argument :content, String, required: false
argument :project_id, Int, required: false
argument :status, Twenty::GraphQL::Type::TaskStatus, required: false
end
end

View file

@ -7,5 +7,9 @@ module Twenty::GraphQL::Type
field :content, String, null: false
field :project, Project, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
field :is_ready, Boolean, null: false, method: :ready?
field :is_backlogged, Boolean, null: false, method: :backlog?
field :is_complete, Boolean, null: false, method: :complete?
field :in_progress, Boolean, null: false, method: :in_progress?
end
end

View file

@ -1,5 +1,6 @@
module Twenty::GraphQL::Type
class TaskStatus < GraphQL::Schema::Enum
value :backlog
value :ready
value :in_progress
value :complete

View file

@ -3,8 +3,8 @@
class Twenty::Task < Twenty::Model
self.table_name = "tasks"
STATUS = {ready: 0, in_progress: 1, complete: 2}
enum :status, STATUS, default: :ready
STATUS = {backlog: 0, ready: 1, in_progress: 2, complete: 3}
enum :status, STATUS, default: :backlog
##
# Validations

View file

@ -50,6 +50,10 @@ type Query {
type Task {
content: String!
id: Int!
inProgress: Boolean!
isBacklogged: Boolean!
isComplete: Boolean!
isReady: Boolean!
project: Project!
status: TaskStatus!
title: String!
@ -57,12 +61,14 @@ type Task {
}
input TaskInput {
content: String!
projectId: Int!
title: String!
content: String
projectId: Int
status: TaskStatus
title: String
}
enum TaskStatus {
backlog
complete
in_progress
ready

View file

@ -8,6 +8,9 @@ body .wrapper {
color: $accent-color;
text-decoration: none;
}
&:hover {
text-decoration: underline;
}
}
ul {
margin: 0;

View file

@ -0,0 +1,116 @@
body .wrapper .group {
@import "colors";
width: 100%;
margin-bottom: 10px;
.group-name {
margin: 10px 0 10px 0;
padding: 10px;
background: $secondary-color;
color: $primary-color;
border-radius: 5px;
ul.tabs {
list-style-type: none;
display: flex;
grid-gap: 15px;
font-size: medium;
li.tab {
height: 24px;
width: 100px;
a {
display: block;
background: $primary-color;
color: $accent-color;
border-radius: 5px;
height: 100%;
&:focus {
text-decoration: none;
}
}
}
li.tab.active {
a {
text-decoration: underline;
}
}
}
}
.group-items {
min-height: 75px;
p.empty-group {
padding-top: 2.5%;
}
ul.items {
li.item {
display: flex;
padding: 7.5px;
border: 1px solid $primary-color;
select {
height: 35px;
padding: 10px;
background: $primary-color;
}
&:hover {
background: $secondary-color;
color: $primary-color;
border-radius: 10px;
border: 1px solid $secondary-color;
a {
color: $primary-color;
text-decoration: none;
span {
color: $primary-color;
text-decoration: none;
}
}
}
a {
display: flex;
flex-direction: column;
height: 50px;
justify-content: space-between;
padding: 10px 10px 5px 0px;
.title {
display: flex;
padding-bottom: 5px;
}
.subtitle {
color: $secondary-color;
}
}
.tags {
.tag {
color: $primary-color;
border: 1px solid $secondary-color;
border-radius: 5px;
font-weight: bold;
}
}
}
}
}
}
.markdown {
ul ,
ul li,
ul li.task-list-item input[type='checkbox']
{
padding: 0 !important;
margin: 0 !important;
}
h1, h2, h3, h4, p {
}
}

View file

@ -1,28 +1,75 @@
.two-columns {
display: flex;
flex-direction: space-between;
.column-1 {
width: 20%;
}
.column-2 {
width: 100%;
}
}
.m-0-auto { margin: 0 auto; }
.maxw-1024 { max-width: 1024px; }
.max-width {
width: 75%;
max-width: 1024px;
}
/* flex */
.flex { display: flex; }
.flex-wrap { flex-wrap: wrap; }
.flex-wrap-reverse { flex-wrap: wrap-reverse; }
.flex-nowrap { flex-wrap: nowrap; }
.flex-row { flex-direction: row; }
.flex-column { flex-direction: column; }
.justify-content-start { justify-content: flex-start; }
.justify-content-center { justify-content: center; }
.justify-content-end { justify-content: flex-end; }
.align-center {
margin: 0 auto;
}
/* colors */
.bg-primary { background: $primary-color; }
.bg-secondary { background: $secondary-color; }
.bg-accent { background: $accent-color; }
.text-primary { color: $primary-color; }
.text-secondary { color: $secondary-color; }
.text-accent { color: $accent-color; }
/* font sizes */
.text-xxsmall { font-size: xx-small; }
.text-xsmall { font-size: x-small; }
.text-small { font-size: small; }
.text-smaller { font-size: smaller; }
.text-medium { font-size: medium; }
.text-large { font-size: large; }
.text-larger { font-size: larger; }
/* width */
.w-100 { width: 100%; }
.w-95 { width: 95%; }
.w-90 { width: 90%; }
.w-85 { width: 85%; }
.w-80 { width: 80%; }
.w-75 { width: 75%; }
.w-70 { width: 70%; }
.w-65 { width: 65%; }
.w-60 { width: 60%; }
.w-55 { width: 55%; }
.w-50 { width: 50%; }
.w-45 { width: 45%; }
.w-40 { width: 40%; }
.w-35 { width: 35%; }
.w-30 { width: 30%; }
.w-25 { width: 25%; }
.w-20 { width: 20%; }
.w-15 { width: 15%; }
.w-10 { width: 10%; }
.w-5 { width: 5%; }
/* height */
.h-100 { height: 100%; }
.h-95 { height: 95%; }
.h-90 { height: 90%; }
.h-85 { height: 85%; }
.h-80 { height: 80%; }
.h-50 { height: 50%; }
.h-75 { height: 75%; }
.h-70 { height: 70%; }
.h-65 { height: 65%; }
.h-60 { height: 60%; }
.h-55 { height: 55%; }
.h-50 { height: 50%; }
.h-45 { height: 45%; }
.h-40 { height: 40%; }
.h-35 { height: 35%; }
.h-30 { height: 30%; }
.h-25 { height: 25%; }
.h-20 { height: 20%; }
.h-15 { height: 15%; }
.h-10 { height: 10%; }
.h-5 { height: 5%; }

View file

@ -4,8 +4,8 @@
-----------------
**/
ul.collection {
h1 {
ul {
h1, h2 {
display: flex;
align-items: center;
margin: 0;
@ -14,85 +14,15 @@ ul.collection {
color: $secondary-color;
height: 25px;
width: 100%;
font-size: medium;
}
}
ul.collection li.item {
display: flex;
flex-wrap: wrap;
height: 85px;
padding: 0px 2.5px 0 2.5px;
border-left: 1px solid $primary-color;
border-right: 1px solid $primary-color;
&:hover {
border-left: 1px solid $secondary-color;
border-right: 1px solid $secondary-color;
}
a {
display: flex;
flex-direction: column;
height: 50px;
justify-content: space-between;
padding: 10px 10px 5px 0px;
.title {
display: flex;
padding-bottom: 5px;
font-size: smaller;
}
.subtitle {
font-size: small;
color: $secondary-color;
}
}
.tags {
.tag {
color: $primary-color;
border: 1px solid $secondary-color;
border-radius: 5px;
font-size: small;
font-weight: bold;
}
}
.break {
display: flex;
width: 100%;
}
ul.actions {
list-style-type: none;
width: 5%;
display: flex;
place-content: flex-end;
li {
display: flex;
align-items: flex-start;
}
}
}
ul.collection li.item.removed {
animation: bounceOutDown;
animation-duration: 0.5s;
}
ul.collection li.item.completed {
animation: bounceOutUp;
animation-duration: 0.5s;
}
ul.items {
@import "colors";
margin: 0;
padding: 0;
list-style-type: none;
}
ul.items.nav {
@ -100,12 +30,11 @@ ul.items.nav {
border-radius: 5px;
background: $secondary-color;
color: $primary-color;
width: 85%;
width: 90%;
padding: 10px;
h1 {
height: unset;
font-size: smaller;
align-items: center;
background: $secondary-color;
color: $primary-color;
@ -122,7 +51,6 @@ ul.items.nav {
color: $secondary-color;
display: flex;
align-items: center;
font-size: smaller;
height: 25px;
a {
display: inline;

View file

@ -1,76 +0,0 @@
.panel h1 {
@import "colors";
display: flex;
align-items: center;
margin: 0;
padding: 0;
background: $primary-color;
color: $secondary-color;
height: 25px;
width: 100%;
font-size: medium;
}
.panel .panel-header.panel-tabs {
@import "colors";
padding: 5px 5px 0 0px;
ul.tabs {
list-style-type: none;
display: flex;
height: 100%;
margin: 0;
padding: 0;
li:first-child {
border-left: none;
}
li {
font-size: small;
height: 100%;
margin-right: 5px;
border: 1px solid $accent-color;
border-bottom: none;
padding: 10px 5px 5px 10px;
border-radius: 5px;
min-width: 120px;
text-align: center;
background: $primary-color;
color: $secondary-color;
cursor: pointer;
opacity: 0.5;
}
li.active, li:hover {
font-weight: bold;
color: $secondary-color;
opacity: 1;
}
}
}
.panel .panel-body {
@import "colors";
width: 100%;
.task.content {
padding: 10px;
ul {
padding: revert;
}
ul li {
line-height: 1.7em;
}
h3,h4,h5 {
&:first-child {
margin: 0 0 5px 0;
}
margin: 15px 0 5px 0;
}
code {
padding: 0px 5px 0px 5px;
font-family: "Noto Sans Mono Regular";
font-size: smaller;
font-weight: bold;
color: lighten(#FF0000, 25%);
border-radius: 5px;
border: 1px solid $accent-color;
}
}
}

View file

@ -26,7 +26,6 @@
border-left: none;
}
li {
font-size: small;
height: 100%;
margin-right: 5px;
border: 1px solid $gray3;
@ -68,7 +67,6 @@
code {
padding: 0px 5px 0px 5px;
font-family: "Noto Sans Mono Regular";
font-size: smaller;
font-weight: bold;
color: lighten(#FF0000, 25%);
border-radius: 5px;

View file

@ -2,7 +2,7 @@
@import "colors";
@import "global";
@import "layout";
@import "panels";
@import "groups";
@import "lists";
@import "vendor/forms";
@ -14,6 +14,7 @@
html, html body, .wrapper {
height: 100%;
font-size: medium;
}
body {
@ -33,15 +34,7 @@ body {
.react-mount {
padding-top: 25px;
}
.trash.icon, .done.icon {
height: 20px;
width: 20px;
cursor: pointer;
}
.trash.icon {
g {
fill: #d9534f;
}
h1, h2, h3, h4, h5 {
font-size: medium;
}
}

View file

@ -1,4 +1,4 @@
<div class='wrapper align-center max-width'>
<div class='wrapper m-0-auto maxw-1024'>
<div class="react-mount projects"></div>
<script src="/js/main/projects.js"></script>
</div>

View file

@ -1,4 +1,4 @@
<div class="wrapper align-center max-width h-100">
<div class="wrapper m-0-auto maxw-1024 h-100">
<div class="react-mount edit-task h-100"></div>
<script src="/js/main/task/edit.js"></script>
</div>

View file

@ -1,4 +1,4 @@
<div class="wrapper align-center max-width">
<div class="wrapper m-0-auto maxw-1024">
<div class="react-mount tasks"></div>
<script src="/js/main/tasks.js"></script>
</div>

View file

@ -1,4 +1,4 @@
<div class="wrapper align-center max-width h-100">
<div class="wrapper m-0-auto maxw-1024 h-100">
<div class="react-mount new-task h-100"></div>
<script src="/js/main/task/new.js"></script>
</div>

View file

@ -0,0 +1,74 @@
import React from "react";
import type { Task } from "/types/schema";
import classnames from "classnames";
import { DateTime } from "luxon";
import { QueryResult } from "@apollo/client";
import { TaskStatusSelect } from "/components/TaskStatusSelect";
type Props = {
groupName: string;
getItems: () => QueryResult;
};
export function Group({ groupName, getItems }: Props) {
const { data, loading } = getItems();
const items = data?.tasks;
if (loading) {
return null;
}
return (
<div className="group">
<h1 className="group-name w-100">{groupName}</h1>
<div className="group-items">
{items?.length ? (
<ul className="items">
{items.map((task: Task, key: number) => {
const { updatedAt } = task;
const datetime = DateTime.fromISO(updatedAt);
const classes = {};
const editHref = `/tasks/edit#id=${task.id}`;
return (
<li className={classnames("item", classes)} key={key}>
<div className="flex flex-wrap w-75">
<div className="w-100">
<a href={editHref}>
<span>{task.title}</span>
<span className="text-smaller text-secondary">
{datetime.toFormat("dd LLL, yyyy")} at{" "}
{datetime.toFormat("HH:mm")}
</span>
</a>
</div>
<div className="w-100">
<span className="tags">
<span
style={{ backgroundColor: task.project.color }}
className="tag"
>
{task.project.name}
</span>
</span>
</div>
</div>
<div className="flex justify-content-end w-25">
<TaskStatusSelect task={task} />
</div>
</li>
);
})}
</ul>
) : (
<p className="empty-group">
There are no {groupName.toLowerCase()} tasks.
<br />
<a className="w-100" href="/tasks/new">
Add a task
</a> .
</p>
)}
</div>
</div>
);
}

View file

@ -1,43 +1,32 @@
import React from "react";
type IconProps = {
onClick: (e: React.MouseEvent) => void;
title?: string;
};
type Props = { name: IconName; onClick: (e: React.MouseEvent) => void };
type IconName = "start-task" | "complete-task" | "destroy-task";
type IconSet = Record<IconName, (props: Props) => JSX.Element>;
export function TrashIcon({ onClick, title }: IconProps) {
return (
const icons: IconSet = {
"start-task": ({ name, onClick }: Props) => (
<svg
height="512"
viewBox="0 0 128 128"
width="512"
xmlns="http://www.w3.org/2000/svg"
onClick={e => onClick(e)}
className="trash icon"
aria-labelledby="trash-icon-title"
viewBox="0 0 64 64"
onClick={onClick}
className={`${name} icon`}
>
<title id="trash-icon-title">{title}</title>
<g>
<path d="m95.331 27.471h-14.248v-4.516a5.712 5.712 0 0 0 -5.7-5.7h-22.762a5.712 5.712 0 0 0 -5.705 5.7v4.516h-14.248a5.425 5.425 0 0 0 -5.419 5.419v5.857a5.426 5.426 0 0 0 5.419 5.419h.071v59.041a7.551 7.551 0 0 0 7.542 7.543h47.437a7.551 7.551 0 0 0 7.542-7.543v-59.041h.071a5.425 5.425 0 0 0 5.418-5.419v-5.857a5.424 5.424 0 0 0 -5.418-5.419zm-44.915-4.516a2.207 2.207 0 0 1 2.205-2.2h22.757a2.207 2.207 0 0 1 2.2 2.2v4.516h-27.162zm41.344 80.252a4.047 4.047 0 0 1 -4.042 4.043h-47.437a4.047 4.047 0 0 1 -4.042-4.043v-59.041h55.521zm5.489-64.46a1.92 1.92 0 0 1 -1.918 1.919h-62.663a1.921 1.921 0 0 1 -1.919-1.919v-5.857a1.921 1.921 0 0 1 1.919-1.919h62.663a1.92 1.92 0 0 1 1.918 1.919z" />
<path d="m64 95.541a1.749 1.749 0 0 0 1.75-1.75v-36.166a1.75 1.75 0 1 0 -3.5 0v36.166a1.75 1.75 0 0 0 1.75 1.75z" />
<path d="m79.333 95.541a1.75 1.75 0 0 0 1.75-1.75v-36.166a1.75 1.75 0 0 0 -3.5 0v36.166a1.75 1.75 0 0 0 1.75 1.75z" />
<path d="m48.666 95.541a1.75 1.75 0 0 0 1.75-1.75v-36.166a1.75 1.75 0 0 0 -3.5 0v36.166a1.75 1.75 0 0 0 1.75 1.75z" />
<g id="_x31_78">
<path d="m13.2 7.6c-.8 4.3-.6 5.5-.1 8.2.4 2.3.9 4.9-.1 6.8-.6 1.2-1.5 2.3-1.9 3.8-.6 2.5.7 5.4 0 7.5-.2.8-1.1 1.7-.8 3.1 0 .7.3 1.3.8 1.8 1.6 1.7 8.9 4.5 16.6 3.5 4-.5 7.9-1.9 11.9-2.6 3.8-.7 8-.6 11.3 1.4-1.4 7.7-3.3 16.2-5.1 22.2-.3 1 .1 2.1 1 2.6.2.2.5.4.8.5.9.3 1.9 0 2.5-.7.5-.6.7-1.3.9-2.1 4.1-15.9 7-32.1 8.6-48.4.2-2.5.1-5.1.6-7.4.1-.5.1-1 0-1.5.2-2-1.4-3.4-3.1-3.5-1.3-.1-2.5.7-3.1 1.8-.4.7-.5 1.7-.4 2.5-9.6-3.2-15.6 1-21.2 2.6-6 1.7-12.8.8-18.2-2.5-.3-.4-.9-.1-1 .4zm1.9 11.5c3.9.6 7.9 1.2 11.9 1.4-.3 3.3-.8 6.6-1.6 9.8-4.8.2-9.3-.7-12.8-2.5 0-3.4 2.8-3.7 2.5-8.7zm10.3 21.1c-.5 0-1 0-1.5 0 1.1-2.7 1.9-5.5 2.7-8.4.5 0 .9-.1 1.4-.1 4-.5 7.3-1.3 11.7-1.8-.7 2.7-1.5 5.3-2.5 7.9-3.9.8-7.9 2.1-11.8 2.4zm-3.1-.1c-4.2-.3-6.4-1.5-8.9-2.3-.5-.1-1.5-.6-1.5-1.4 0-1.1 1.4-1.5 1-5.4-.1-.4-.1-.5-.2-1.4 3.2 1.5 7.2 2.4 11.4 2.4h1c-1.3 4.5-2.4 7.1-2.8 8.1zm29.2-2c-2.2-1.1-4-1.2-6.6-1.2-2.1 0-4.2.2-6 .6.9-2.6 1.7-5.2 2.4-7.8 3.9-.3 7.7-.1 11.5.8-.5 3.6-.6 3.8-1.3 7.6zm1.5-9.1c-3.7-.8-7.5-1.1-11.3-.8.7-3.1 1.3-6.2 1.7-9.3 3.6-.6 7.2-.9 10.7-.1-.4 3.4-.7 6.8-1.1 10.2zm-12.9-.7c-4.3.5-8.1 1.4-12.2 1.9-.3 0-.6 0-.8.1.7-3.2 1.2-6.4 1.5-9.7 5.4.1 8.1-.5 13.3-1.4-.5 3-1.1 6.1-1.8 9.1zm7.2 35.3c6.2-20.8 9-48.7 8.8-54.8.6.1 1.2.1 2-.1-.7 15.9-4.5 38.9-9.2 55.4-.1.2-.2.4-.4.5-.1.1-.3.1-.6 0l-.1-.1c-.4-.1-.6-.5-.5-.9zm8.2-58.5c.3-.6 1-1 1.7-1 1.4.1 2.2 1.8 1.2 2.9h-.1c-.9.5-2.1.4-3-.1 0 0 0 0-.1 0 0-.1 0-.2 0-.4 0-.4.1-1 .3-1.4zm-.9 3.8c0 .1-.1 3.9-.5 8.4-3.5-.8-7.1-.5-10.6.1.3-2.9.7-8.4.6-9.8 2.4-.4 8.3.1 10.5 1.3zm-12-1.1c0 3.3-.2 6.6-.6 9.8-5.3.9-7.9 1.6-13.3 1.5.2-2.5.2-4.9.1-7.4 5.9-.5 8.7-2.9 13.8-3.9zm-28.1 1.1c3.9 2.1 8.3 3.1 12.7 2.9.1 2.4 0 4.8-.1 7.2-4.1-.2-8.2-.8-12.1-1.5-.5-3.5-1.2-4.3-.5-8.6z" />
<path d="m7.7 6.8c-.2.4 0 .8.4 1l2.8 1.3c.4.2.8 0 1-.4s0-.8-.4-1l-2.8-1.3c-.4-.1-.8 0-1 .4z" />
<path d="m11.6 2.9c-.2-.4-.7-.5-1-.3-.4.2-.5.7-.3 1l1.6 2.8c.1.2.4.4.7.4.6 0 .9-.6.7-1.1z" />
<path d="m15.1 5.9c.4 0 .7-.3.7-.6l.5-2.8c.1-.4-.2-.8-.6-.9s-.8.2-.9.6l-.5 2.8c0 .5.3.9.8.9z" />
</g>
</svg>
);
}
export function DoneIcon({ onClick, title }: IconProps) {
return (
),
"complete-task": ({ name, onClick }: Props) => (
<svg
enableBackground="new 0 0 24 24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
onClick={e => onClick(e)}
className="done icon"
aria-labelledby="complete-icon-title"
viewBox="0 0 24 24"
onClick={onClick}
className={`${name} icon`}
>
<title id="complete-icon-title">{title}</title>
<switch>
<g>
<path
@ -47,5 +36,25 @@ export function DoneIcon({ onClick, title }: IconProps) {
</g>
</switch>
</svg>
);
),
"destroy-task": ({ name, onClick }: Props) => (
<svg
viewBox="0 0 128 128"
xmlns="http://www.w3.org/2000/svg"
onClick={onClick}
className={`${name} icon`}
>
<g>
<path d="m95.331 27.471h-14.248v-4.516a5.712 5.712 0 0 0 -5.7-5.7h-22.762a5.712 5.712 0 0 0 -5.705 5.7v4.516h-14.248a5.425 5.425 0 0 0 -5.419 5.419v5.857a5.426 5.426 0 0 0 5.419 5.419h.071v59.041a7.551 7.551 0 0 0 7.542 7.543h47.437a7.551 7.551 0 0 0 7.542-7.543v-59.041h.071a5.425 5.425 0 0 0 5.418-5.419v-5.857a5.424 5.424 0 0 0 -5.418-5.419zm-44.915-4.516a2.207 2.207 0 0 1 2.205-2.2h22.757a2.207 2.207 0 0 1 2.2 2.2v4.516h-27.162zm41.344 80.252a4.047 4.047 0 0 1 -4.042 4.043h-47.437a4.047 4.047 0 0 1 -4.042-4.043v-59.041h55.521zm5.489-64.46a1.92 1.92 0 0 1 -1.918 1.919h-62.663a1.921 1.921 0 0 1 -1.919-1.919v-5.857a1.921 1.921 0 0 1 1.919-1.919h62.663a1.92 1.92 0 0 1 1.918 1.919z" />
<path d="m64 95.541a1.749 1.749 0 0 0 1.75-1.75v-36.166a1.75 1.75 0 1 0 -3.5 0v36.166a1.75 1.75 0 0 0 1.75 1.75z" />
<path d="m79.333 95.541a1.75 1.75 0 0 0 1.75-1.75v-36.166a1.75 1.75 0 0 0 -3.5 0v36.166a1.75 1.75 0 0 0 1.75 1.75z" />
<path d="m48.666 95.541a1.75 1.75 0 0 0 1.75-1.75v-36.166a1.75 1.75 0 0 0 -3.5 0v36.166a1.75 1.75 0 0 0 1.75 1.75z" />
</g>
</svg>
),
};
export function Icon({ name, onClick }: Props) {
const getIcon = icons[name];
return getIcon({ name, onClick });
}

View file

@ -66,82 +66,78 @@ export function Task({ taskId }: { taskId?: number }) {
}
return (
<div className="two-columns h-100">
<div className="column-1">
<div className="flex w-100 h-100">
<div className="w-25">
<NavBar />
</div>
<div className="column-2 h-100">
<form className="task h-100" onSubmit={handleSubmit(onSave)}>
<div className="panel h-100">
<h1>{task ? "Edit task" : "New task"}</h1>
<div className="panel-header panel-tabs">
<ul className="tabs">
<li
className={classnames({ active: isEditable })}
onClick={() => setIsEditable(true)}
>
Write
</li>
<li
className={classnames({ active: !isEditable })}
onClick={() => setIsEditable(false)}
>
<div className="w-75 h-100">
<h1>{task ? "Edit task" : "New task"}</h1>
<form className="group h-100" onSubmit={handleSubmit(onSave)}>
<div className="group-name">
<ul className="tabs">
<li className={classnames("tab", { active: isEditable })}>
<a href="#" onClick={() => setIsEditable(true)}>
Editor
</a>
</li>
<li className={classnames("tab", { active: !isEditable })}>
<a href="#" onClick={() => setIsEditable(false)}>
Preview
</li>
</ul>
</a>
</li>
</ul>
</div>
<div className="group-items h-80">
<div>
<select
{...register("projectId")}
className="form"
value={projectId}
onChange={event => {
const v: string = event.target.value;
set("projectId", Number(v));
}}
>
{projects.map((project: Project, key: number) => {
return (
<option key={key} value={project.id}>
{project.name}
</option>
);
})}
</select>
</div>
<div className="panel-body h-80">
<div>
<select
{...register("projectId")}
className="form"
value={projectId}
onChange={event => {
const v: string = event.target.value;
set("projectId", Number(v));
}}
>
{projects.map((project: Project, key: number) => {
return (
<option key={key} value={project.id}>
{project.name}
</option>
);
})}
</select>
</div>
<div>
<input
className="form"
type="text"
placeholder="Title"
defaultValue={task?.title}
{...register("title", { required: true })}
/>
</div>
{isEditable ? (
<>
<div className="row textarea h-70">
<textarea
className="form h-100"
placeholder="Add your description heren"
defaultValue={task?.content || DEFAULT_TASK_CONTENT}
{...register("content", { required: true })}
/>
</div>
<div className="row">
<input className="form" type="submit" value="Save" />
</div>
</>
) : (
<div
className="task content h-50"
dangerouslySetInnerHTML={{
__html: rendermd(content || task?.content),
}}
/>
)}
<div>
<input
className="form"
type="text"
placeholder="Title"
defaultValue={task?.title}
{...register("title", { required: true })}
/>
</div>
{isEditable ? (
<>
<div className="row textarea h-70">
<textarea
className="form h-100"
placeholder="Add your description heren"
defaultValue={task?.content || DEFAULT_TASK_CONTENT}
{...register("content", { required: true })}
/>
</div>
<div className="row">
<input className="form" type="submit" value="Save" />
</div>
</>
) : (
<div
className="markdown h-50"
dangerouslySetInnerHTML={{
__html: rendermd(content || task?.content),
}}
/>
)}
</div>
</form>
</div>

View file

@ -0,0 +1,29 @@
import React from "react";
import { Task, TaskStatus } from "/types/schema";
import { useUpdateTask } from "hooks/mutations/useUpdateTask";
import { GET_TASKS } from "/hooks/queries/useTasks";
type Props = {
task: Task;
};
export function TaskStatusSelect({ task }: Props) {
const updateTask = useUpdateTask();
const onChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const status = event.target.value as TaskStatus;
updateTask({
awaitRefetchQueries: true,
refetchQueries: [GET_TASKS, "GetTasks"],
variables: { taskId: task.id, input: { status } },
});
};
return (
<select value={task.status} onChange={onChange}>
<option value={TaskStatus.Backlog}>Backlog</option>
<option value={TaskStatus.Ready}>Ready</option>
<option value={TaskStatus.InProgress}>Working on it</option>
<option value={TaskStatus.Complete}>Complete</option>
</select>
);
}

View file

@ -1,103 +1,33 @@
import React, { useState, useEffect } from "react";
import { useTasks } from "/hooks/queries/useTasks";
import { useDestroyTask } from "/hooks/mutations/useDestroyTask";
import { TrashIcon, DoneIcon } from "/components/Icons";
import React, { useEffect } from "react";
import { NavBar } from "/components/NavBar";
import { DateTime } from "luxon";
import { Task, TaskStatus } from "/types/schema";
import classnames from "classnames";
import { useCompleteTask } from "/hooks/mutations/useCompleteTask";
import { Group } from "/components/Group";
import { TaskStatus } from "/types/schema";
import { useTasks } from "/hooks/queries/useTasks";
export function Tasks() {
const { refetch, loading, data } = useTasks({variables: {status: TaskStatus.Ready}});
const tasks = data?.tasks;
const [destroyTask] = useDestroyTask();
const [completeTask] = useCompleteTask();
const [destroyedTask, setDestroyedTask] = useState<Task>(null);
const [completedTask, setCompletedTask] = useState<Task>(null);
useEffect(() => {
document.title = "Tasks";
}, []);
if (loading) {
return null;
}
const getTasks = (status: TaskStatus) => {
return () => {
return useTasks({ variables: { status } });
};
};
return (
<div className="two-columns">
<div className="column-1">
<div className="flex">
<div className="w-25">
<NavBar />
</div>
<div className="column-2">
<div className="panel">
<h1>Tasks</h1>
<div className="panel-body">
<ul className="collection">
{tasks.map((task: Task, key: number) => {
const { updatedAt } = task;
const datetime = DateTime.fromISO(updatedAt);
const wasDestroyed = task === destroyedTask;
const wasCompleted = task === completedTask;
const classes = {
completed: wasCompleted,
removed: wasDestroyed,
};
const editHref = `/tasks/edit#id=${task.id}`;
return (
<li className={classnames("item", classes)} key={key}>
<div className="w-95">
<a className="w-100" href={editHref}>
<span className="title">{task.title}</span>
<span className="subtitle">
<span className="datetime">
{datetime.toFormat("dd LLL, yyyy")} at{" "}
{datetime.toFormat("HH:mm")}
</span>
</span>
</a>
<span className="break" />
<span className="tags">
<span
style={{ backgroundColor: task.project.color }}
className="tag"
>
{task.project.name}
</span>
</span>
</div>
<ul className="actions">
<li>
<DoneIcon
title="Complete task"
onClick={async (_e: React.MouseEvent) => {
await completeTask({
variables: { taskId: task.id },
});
setCompletedTask(task);
setTimeout(refetch, 500);
}}
/>
</li>
<li>
<TrashIcon
title="Delete task"
onClick={async (_e: React.MouseEvent) => {
await destroyTask({
variables: { taskId: task.id },
});
setDestroyedTask(task);
setTimeout(refetch, 500);
}}
/>
</li>
</ul>
</li>
);
})}
</ul>
</div>
</div>
<div className="w-75">
<h1>Tasks</h1>
<Group
groupName="Working on it"
getItems={getTasks(TaskStatus.InProgress)}
/>
<Group groupName="Ready" getItems={getTasks(TaskStatus.Ready)} />
<Group groupName="Backlog" getItems={getTasks(TaskStatus.Backlog)} />
</div>
</div>
);

View file

@ -3,7 +3,7 @@ import {
MutationUpdateTaskArgs,
TaskInput,
} from "/types/schema";
import { gql, useMutation } from "@apollo/client";
import { gql, useMutation, DocumentNode } from "@apollo/client";
const GQL = gql`
mutation UpdateTask($taskId: Int!, $input: TaskInput!) {
@ -15,16 +15,24 @@ const GQL = gql`
type TArgs = {
variables: { taskId: number; input: TaskInput };
refetchQueries?: Array<string | DocumentNode>;
awaitRefetchQueries?: boolean;
};
export function useUpdateTask() {
const [update] = useMutation<UpdateTaskPayload, MutationUpdateTaskArgs>(GQL);
return ({ variables: { taskId, input }, ...rest }: TArgs) => {
const projectId = Number(input.projectId);
const variables = {
taskId,
input: { ...input, ...{ projectId } },
};
return update({ variables, ...rest });
return ({
awaitRefetchQueries,
refetchQueries,
variables: { taskId, input },
...rest
}: TArgs) => {
const projectId = input.projectId ? Number(input.projectId) : null;
const variables = { taskId, input: {} };
Object.assign(
variables,
projectId ? { input: { ...input, projectId } } : { input },
);
return update({ variables, awaitRefetchQueries, refetchQueries, ...rest });
};
}

View file

@ -1,12 +1,13 @@
import { useQuery, gql } from "@apollo/client";
import { Task, TaskStatus } from "/types/schema";
import { Task } from "/types/schema";
const GQL = gql`
query Query($status: TaskStatus!) {
export const GET_TASKS = gql`
query GetTasks($status: TaskStatus!) {
tasks(status: $status) {
id
title
status
isReady
updatedAt
project {
name
@ -16,6 +17,6 @@ const GQL = gql`
}
`;
export function useTasks({...rest}) {
return useQuery<{tasks: Task[]}>(GQL, rest);
export function useTasks({ ...rest }) {
return useQuery<{ tasks: Task[] }>(GET_TASKS, rest);
}

View file

@ -87,14 +87,22 @@ export type QueryFindTaskArgs = {
};
export type QueryTasksArgs = {
status: TaskStatus;
};
export type QueryTasksArgs = {
status: TaskStatus;
};
export type Task = {
__typename?: 'Task';
content: Scalars['String']['output'];
id: Scalars['Int']['output'];
__typename?: "Task";
content: Scalars["String"]["output"];
id: Scalars["Int"]["output"];
inProgress: Scalars["Boolean"]["output"];
isBacklogged: Scalars["Boolean"]["output"];
isComplete: Scalars["Boolean"]["output"];
isReady: Scalars["Boolean"]["output"];
project: Project;
status: TaskStatus;
title: Scalars['String']['output'];
@ -102,15 +110,17 @@ export type Task = {
};
export type TaskInput = {
content: Scalars['String']['input'];
projectId: Scalars['Int']['input'];
title: Scalars['String']['input'];
content?: InputMaybe<Scalars["String"]["input"]>;
projectId?: InputMaybe<Scalars["Int"]["input"]>;
status?: InputMaybe<TaskStatus>;
title?: InputMaybe<Scalars["String"]["input"]>;
};
export enum TaskStatus {
Complete = 'complete',
InProgress = 'in_progress',
Ready = 'ready'
Backlog = "backlog",
Complete = "complete",
InProgress = "in_progress",
Ready = "ready",
}
/** Autogenerated return type of UpdateTask. */