milestone: switch to graphql

This commit is contained in:
0x1eef 2024-01-09 22:13:17 -03:00
parent afcdfc344e
commit f2a54c4fb8
54 changed files with 4995 additions and 402 deletions

View file

@ -0,0 +1,12 @@
require "bundler/setup"
require "fileutils"
namespace :schema do
desc "Generate share/twenty-backend/schema.graphql"
task :regen do
require "twenty-backend"
schema = File.join(__dir__, "share", "twenty-backend", "schema.graphql")
FileUtils.mkdir_p File.dirname(schema)
File.binwrite schema, Twenty::GraphQL::Schema.to_definition
end
end

View file

@ -4,6 +4,7 @@ module Twenty
require "fileutils" require "fileutils"
require "webrick" require "webrick"
require "active_record" require "active_record"
require_relative "twenty-backend/graphql"
require_relative "twenty-backend/servlet" require_relative "twenty-backend/servlet"
require_relative "twenty-backend/migration" require_relative "twenty-backend/migration"
require_relative "twenty-backend/model" require_relative "twenty-backend/model"

View file

@ -0,0 +1,7 @@
module Twenty::GraphQL
require "graphql"
require_relative "graphql/input"
require_relative "graphql/type"
require_relative "graphql/mutation"
require_relative "graphql/schema"
end

View file

@ -0,0 +1,4 @@
module Twenty::GraphQL::Input
include GraphQL::Types
require_relative "input/task_input"
end

View file

@ -0,0 +1,7 @@
module Twenty::GraphQL::Input
class TaskInput < GraphQL::Schema::InputObject
argument :title, String
argument :content, String
argument :project_id, Int
end
end

View file

@ -0,0 +1,5 @@
module Twenty::GraphQL
module Mutation
require_relative "mutation/destroy_task"
end
end

View file

@ -0,0 +1,15 @@
module Twenty::GraphQL::Mutation
class CompleteTask < GraphQL::Schema::Mutation
argument :task_id, Int
field :ok, Boolean
field :errors, [String]
def resolve(task_id:)
task = Twenty::Task.find(task_id)
task.update!(status: :complete)
{ok: true, errors: []}
rescue => ex
{ok: false, errors: [ex.message]}
end
end
end

View file

@ -0,0 +1,13 @@
module Twenty::GraphQL::Mutation
class CreateTask < GraphQL::Schema::Mutation
field :errors, [String], null: false
argument :input, Twenty::GraphQL::Input::TaskInput
def resolve(input:)
Twenty::Task.new(input.to_h).save!
{"errors" => []}
rescue => ex
{"errors" => [ex.message]}
end
end
end

View file

@ -0,0 +1,15 @@
module Twenty::GraphQL::Mutation
class DestroyTask < GraphQL::Schema::Mutation
argument :task_id, Int
field :ok, Boolean
field :errors, [String]
def resolve(task_id:)
task = Twenty::Task.find(task_id)
task.destroy!
{ok: true, errors: []}
rescue => ex
{ok: false, errors: [ex.message]}
end
end
end

View file

@ -0,0 +1,15 @@
module Twenty::GraphQL::Mutation
class UpdateTask < GraphQL::Schema::Mutation
field :errors, [String], null: false
argument :task_id, Int
argument :input, Twenty::GraphQL::Input::TaskInput
def resolve(task_id:, input:)
task = Twenty::Task.find_by(id: task_id)
task.update!(input.to_h)
{"errors" => []}
rescue => ex
{"errors" => [ex.message]}
end
end
end

View file

@ -0,0 +1,6 @@
module Twenty::GraphQL
class Schema < GraphQL::Schema
query Type::Query
mutation Type::Mutation
end
end

View file

@ -0,0 +1,10 @@
module Twenty::GraphQL
module Type
include ::GraphQL::Types
end
require_relative "type/task_status"
require_relative "type/project"
require_relative "type/task"
require_relative "type/query"
require_relative "type/mutation"
end

View file

@ -0,0 +1,12 @@
module Twenty::GraphQL::Type
class Mutation < GraphQL::Schema::Object
require_relative "../mutation/destroy_task"
require_relative "../mutation/complete_task"
require_relative "../mutation/create_task"
require_relative "../mutation/update_task"
field :destroy_task, mutation: Twenty::GraphQL::Mutation::DestroyTask
field :complete_task, mutation: Twenty::GraphQL::Mutation::CompleteTask
field :create_task, mutation: Twenty::GraphQL::Mutation::CreateTask
field :update_task, mutation: Twenty::GraphQL::Mutation::UpdateTask
end
end

View file

@ -0,0 +1,10 @@
module Twenty::GraphQL::Type
class Project < GraphQL::Schema::Object
require_relative "task"
field :id, ID, null: false
field :name, String, null: false
field :path, String, null: false
field :color, String, null: false
field :tasks, [Task], null: false
end
end

View file

@ -0,0 +1,23 @@
module Twenty::GraphQL::Type
class Query < GraphQL::Schema::Object
field :find_task, Task, null: true do
argument :task_id, Int
end
field :tasks, [Task], null: false
field :projects, [Project], null: false
def find_task(task_id:)
Twenty::Task.find_by(id: task_id)
end
def tasks
Twenty::Task
.ready
.order(updated_at: :desc)
end
def projects
Twenty::Project.all
end
end
end

View file

@ -0,0 +1,11 @@
module Twenty::GraphQL::Type
class Task < GraphQL::Schema::Object
require_relative "project"
field :id, Int, null: false
field :title, String, null: false
field :status, TaskStatus, null: false
field :content, String, null: false
field :project, Project, null: false
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
end
end

View file

@ -0,0 +1,7 @@
module Twenty::GraphQL::Type
class TaskStatus < GraphQL::Schema::Enum
value :ready
value :in_progress
value :complete
end
end

View file

@ -13,10 +13,6 @@ class Twenty::Project < Twenty::Model
# Associations # Associations
has_many :tasks, class_name: "Twenty::Task" has_many :tasks, class_name: "Twenty::Task"
def to_json(options = {})
{id:, name:, path:, color:}.to_json(options)
end
## ##
# @return [String] # @return [String]
# The path to a project. # The path to a project.

View file

@ -15,9 +15,4 @@ class Twenty::Task < Twenty::Model
## ##
# Associations # Associations
belongs_to :project, class_name: "Twenty::Project" belongs_to :project, class_name: "Twenty::Project"
def as_json(options = {})
{id:, title:, content:, status:,
project:, created_at:, updated_at:}.as_json(options)
end
end end

View file

@ -1,20 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class Twenty::Servlet < WEBrick::HTTPServlet::AbstractServlet class Twenty::Servlet < WEBrick::HTTPServlet::AbstractServlet
require_relative "servlet/response" ##
require_relative "servlet/projects" # servlets
require_relative "servlet/tasks" require_relative "servlet/graphql"
##
# mixins # mixins
require_relative "servlet/mixin/server_mixin" require_relative "servlet/mixin/server_mixin"
require_relative "servlet/mixin/response_mixin"
extend ServerMixin extend ServerMixin
include ResponseMixin
def parse_body(req, only: [], except: [])
body = JSON.parse(req.body)
body = only.size > 0 ? body.slice(*only) : body
body = except.size > 0 ? body.except(*except) : body
body
end
end end

View file

@ -0,0 +1,15 @@
class Twenty::Servlet::GraphQL < Twenty::Servlet
##
# POST /servlet/graphql/
def do_POST(req, res)
params = JSON.parse(req.body)
result = Twenty::GraphQL::Schema.execute(
params['query'],
variables: params['variables'],
context: {}
)
res.headers['content_type'] = 'application/json'
res.status = 200
res.body = result.to_json
end
end

View file

@ -1,42 +0,0 @@
# frozen_string_literal: true
module Twenty::Servlet::ResponseMixin
Response = Twenty::Servlet::Response
##
# Sets 200 response.
# @param [WEBrick::HTTPResponse] res
# An instance of {WEBrick::HTTPResponse WEBrick::HTTPResponse}
# @param [#to_json] body
# The response body.
# @return [void]
def ok(res, body = {})
Response.new(res)
.set_status(200)
.set_body(body)
end
##
# Sets 400 response.
# @param [WEBrick::HTTPResponse] res
# An instance of {WEBrick::HTTPResponse WEBrick::HTTPResponse}
# @param [#to_json] body
# The response body.
# @return [void]
def bad_request(res, body = {})
Response.new(res)
.set_status(400)
.set_body({errors: ["Bad request"]}.merge(body))
end
##
# Sets 404 response.
# @param [WEBrick::HTTPResponse] res
# An instance of {WEBrick::HTTPResponse WEBrick::HTTPResponse}
# @return [void]
def not_found(res)
Response.new(res)
.set_status(404)
.set_body({errors: ["The requested path was not found"]})
end
end

View file

@ -10,8 +10,7 @@ module Twenty::Servlet::ServerMixin
# Returns an instance of WEBrick::HTTPServer. # Returns an instance of WEBrick::HTTPServer.
def server(options = {}) def server(options = {})
server = WEBrick::HTTPServer.new server_options.merge(options) server = WEBrick::HTTPServer.new server_options.merge(options)
server.mount "/servlet/projects", Twenty::Servlet::Projects server.mount "/servlet/graphql", Twenty::Servlet::GraphQL
server.mount "/servlet/tasks", Twenty::Servlet::Tasks
server server
end end

View file

@ -1,14 +0,0 @@
# frozen_string_literal: true
class Twenty::Servlet::Projects < Twenty::Servlet
##
# GET /servlet/projects
def do_GET(req, res)
case req.path_info
when ""
ok(res, projects: Twenty::Project.all)
else
not_found(res)
end
end
end

View file

@ -1,67 +0,0 @@
# frozen_string_literal: true
class Twenty::Servlet::Response
##
# @param [WEBrick::HTTPResponse] res
# An instance of WEBrick::HTTPResponse.
# @return [Twenty::Servlet::Response]
# Returns an instance of Twenty::Servlet::Response.
def initialize(res)
@res = res
set_headers({"content-type" => "application/json"})
end
##
# Marks a response as a 404 (Not Found).
# @return [Twenty::Servlet::Response]
# Returns self.
def not_found
set_status(404)
set_body({errors: ["The requested path was not found"]})
end
##
# Sets the response status.
# @param [Integer] status
# A status code.
# @return [Twenty::Servlet::Response]
# Returns self.
def set_status(status)
res.status = status
self
end
##
# Sets the response body.
# @param [#to_json] body
# The response body.
# @return [Twenty::Servlet::Response]
# Returns self.
def set_body(body)
res.body = default_body_for(res.status).merge(body).to_json
self
end
##
# Sets the response headers.
# @param [#each] headers
# The response headers.
# @return [Twenty::Servlet::Response]
# Returns self.
def set_headers(headers)
headers.each { res[_1] = _2 }
self
end
private
attr_reader :res
def default_body_for(status)
case status
when 200
{ok: true, errors: []}
else
{ok: false, errors: ["Internal server error"]}
end
end
end

View file

@ -1,63 +0,0 @@
# frozen_string_literal: true
class Twenty::Servlet::Tasks < Twenty::Servlet
##
# GET /servlet/tasks/
# GET /servlet/tasks/<id>/
def do_GET(req, res)
case req.path_info
when ""
tasks = Twenty::Task.ready.order(updated_at: :desc)
ok(res, tasks:)
when %r{\A/([\d]+)/?\z}
task = Twenty::Task.find_by(id: $1)
task ? ok(res, task:) : not_found(res)
else
not_found(res)
end
end
##
# POST /servlet/tasks/
def do_POST(req, res)
case req.path_info
when ""
body = parse_body(req, only: ["title", "content", "project_id"])
task = Twenty::Task.new(body)
if task.save
ok(res, task:)
else
errors = task.errors.full_messages
bad_request(res, errors:)
end
else
not_found(res)
end
end
##
# PUT /servlet/tasks
def do_PUT(req, res)
case req.path_info
when ""
body = parse_body(req, except: ["id"])
id = parse_body(req, only: ["id"]).fetch("id", nil)
task = Twenty::Task.find_by(id:)
task&.update(body) ? ok(res, task:) : not_found(res)
else
not_found(res)
end
end
##
# DELETE /servlet/tasks/<id>/
def do_DELETE(req, res)
case req.path_info
when %r{\A/([\d]+)/?\z}
task = Twenty::Task.find_by(id: $1)
task.destroy ? ok(res) : not_found(res)
else
not_found(res)
end
end
end

View file

@ -0,0 +1,76 @@
"""
Autogenerated return type of CompleteTask.
"""
type CompleteTaskPayload {
errors: [String!]
ok: Boolean
}
"""
Autogenerated return type of CreateTask.
"""
type CreateTaskPayload {
errors: [String!]!
}
"""
Autogenerated return type of DestroyTask.
"""
type DestroyTaskPayload {
errors: [String!]
ok: Boolean
}
"""
An ISO 8601-encoded datetime
"""
scalar ISO8601DateTime @specifiedBy(url: "https://tools.ietf.org/html/rfc3339")
type Mutation {
completeTask(taskId: Int!): CompleteTaskPayload
createTask(input: TaskInput!): CreateTaskPayload
destroyTask(taskId: Int!): DestroyTaskPayload
updateTask(input: TaskInput!, taskId: Int!): UpdateTaskPayload
}
type Project {
color: String!
id: ID!
name: String!
path: String!
tasks: [Task!]!
}
type Query {
findTask(taskId: Int!): Task
projects: [Project!]!
tasks: [Task!]!
}
type Task {
content: String!
id: Int!
project: Project!
status: TaskStatus!
title: String!
updatedAt: ISO8601DateTime!
}
input TaskInput {
content: String!
projectId: Int!
title: String!
}
enum TaskStatus {
complete
in_progress
ready
}
"""
Autogenerated return type of UpdateTask.
"""
type UpdateTaskPayload {
errors: [String!]!
}

View file

@ -14,6 +14,7 @@ Gem::Specification.new do |gem|
gem.add_runtime_dependency "activerecord", "~> 7.1" gem.add_runtime_dependency "activerecord", "~> 7.1"
gem.add_runtime_dependency "sqlite3", "~> 1.6" gem.add_runtime_dependency "sqlite3", "~> 1.6"
gem.add_runtime_dependency "webrick", "~> 1.8" gem.add_runtime_dependency "webrick", "~> 1.8"
gem.add_runtime_dependency "graphql", "~> 2.2"
gem.add_development_dependency "test-unit", "~> 3.5.7" gem.add_development_dependency "test-unit", "~> 3.5.7"
gem.add_development_dependency "standard", "~> 1.13" gem.add_development_dependency "standard", "~> 1.13"
gem.add_development_dependency "rake", "~> 13.1" gem.add_development_dependency "rake", "~> 13.1"

View file

@ -90,6 +90,7 @@ GEM
rack (>= 3) rack (>= 3)
webrick (~> 1.8) webrick (~> 1.8)
rainpress (1.0.1) rainpress (1.0.1)
rake (13.1.0)
rb-fsevent (0.11.2) rb-fsevent (0.11.2)
rb-inotify (0.10.1) rb-inotify (0.10.1)
ffi (~> 1.0) ffi (~> 1.0)
@ -124,6 +125,7 @@ DEPENDENCIES
nanoc-live (~> 1.0) nanoc-live (~> 1.0)
nanoc-webpack.rb (~> 0.4) nanoc-webpack.rb (~> 0.4)
rainpress (~> 1.0) rainpress (~> 1.0)
rake (~> 13.0)
sass (~> 3.7) sass (~> 3.7)
twenty-frontend! twenty-frontend!

View file

@ -0,0 +1,11 @@
require "bundler/setup"
namespace :schema do
desc "Generate src/js/types/schema.ts"
task :regen do
path = File.join "..", "twenty-backend"
Bundler.with_unbundled_env {
Dir.chdir(path) { sh "bundle exec rake schema:regen" }
}
sh "npm exec graphql-codegen"
end
end

View file

@ -0,0 +1,7 @@
schema:
- ../twenty-backend/share/twenty-backend/schema.graphql
generates:
src/js/types/schema.ts:
plugins:
- typescript

File diff suppressed because it is too large Load diff

View file

@ -6,18 +6,24 @@
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@apollo/client": "^3.3.21",
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/typescript": "^4.0.1",
"@graphql-codegen/typescript-resolvers": "^4.0.1",
"@types/luxon": "^3.3.7", "@types/luxon": "^3.3.7",
"@types/react": "^18.0.18", "@types/react": "^18.0.18",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@types/showdown": "^2.0.6", "@types/showdown": "^2.0.6",
"eslint": "^8.26.0", "eslint": "^8.26.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"graphql": "^16.8.1",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"react-hook-form": "^7.49.2", "react-hook-form": "^7.49.2",
"showdown": "^2.1.0", "showdown": "^2.1.0",
"ts-loader": "^9.3.1", "ts-loader": "^9.3.1",
"ts-standard": "^12.0.1", "ts-standard": "^12.0.1",
"tslib": "^2.2.0",
"typescript": "^4.8.2", "typescript": "^4.8.2",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-cli": "^4.10.0" "webpack-cli": "^4.10.0"

View file

@ -1,14 +1,20 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { NavBar } from "/components/NavBar"; import { NavBar } from "/components/NavBar";
import { useProjects } from "/hooks/useProjects"; import { useProjects } from "/hooks/queries/useProjects";
import { Project } from "/types/schema";
export function Projects() { export function Projects() {
const projects = useProjects(); const { data, loading } = useProjects();
const projects = data?.projects;
useEffect(() => { useEffect(() => {
document.title = "Projects"; document.title = "Projects";
}, []); }, []);
if (loading) {
return;
}
return ( return (
<div className="two-columns"> <div className="two-columns">
<div className="column-1"> <div className="column-1">
@ -19,7 +25,7 @@ export function Projects() {
<h1>Projects</h1> <h1>Projects</h1>
<div className="panel-body"> <div className="panel-body">
<ul className="collection"> <ul className="collection">
{projects.map((project, i) => { {projects.map((project: Project, i: number) => {
return ( return (
<li className="item" key={i}> <li className="item" key={i}>
<a href={`/tasks#project_id=${project.id}`}> <a href={`/tasks#project_id=${project.id}`}>

View file

@ -1,19 +1,14 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Select } from "/components/forms/Select"; import { Select } from "/components/forms/Select";
import { useUpsertTask } from "/hooks/useUpsertTask"; import { useCreateTask } from "/hooks/mutations/useCreateTask";
import { useProjects } from "/hooks/useProjects"; import { useUpdateTask } from "/hooks/mutations/useUpdateTask";
import { Task } from "/types/schema"; import { useFindTask } from "/hooks/queries/useFindTask";
import { useProjects } from "/hooks/queries/useProjects";
import { Task, Project, TaskInput } from "/types/schema";
import { rendermd } from "/lib/markdown-utils"; import { rendermd } from "/lib/markdown-utils";
import classnames from "classnames"; import classnames from "classnames";
type Inputs = {
id?: number;
title: string;
content: string;
projectId: number;
};
const DEFAULT_TASK_CONTENT = [ const DEFAULT_TASK_CONTENT = [
"## Subtasks", "## Subtasks",
"", "",
@ -26,27 +21,38 @@ const DEFAULT_TASK_CONTENT = [
"Add a description here....", "Add a description here....",
].join("\n"); ].join("\n");
export function Task({ task }: { task?: Task }) { export function Task({ taskId }: { taskId?: number }) {
const { register, handleSubmit, watch, setValue: set } = useForm<Inputs>(); const { register, handleSubmit, watch, setValue: set } = useForm<TaskInput>();
const [isEditable, setIsEditable] = useState<boolean>(!task); const [isEditable, setIsEditable] = useState<boolean>(!taskId);
const upsert = useUpsertTask(); const [createTask] = useCreateTask();
const projects = useProjects(); const [updateTask] = useUpdateTask();
const { data: taskData, loading: findingTask } = useFindTask(Number(taskId));
const { data: projectsData, loading: findingProjects } = useProjects();
const task = taskData?.findTask;
const projects = projectsData?.projects;
const content = watch("content"); const content = watch("content");
const onSave = (input: Inputs) => { const onSave = (input: TaskInput) => {
upsert({ input }).then(() => { if (taskId) {
location.href = "/tasks/"; updateTask({ variables: { taskId, input } })
}); .then(() => location.href = '/tasks');
} else {
createTask({ variables: { input } })
.then(() => location.href = '/tasks');
}
}; };
useEffect(() => { useEffect(() => {
const title = task ? task.title : 'New task'; const title = task ? task.title : "New task";
document.title = title; document.title = title;
set("projectId", 1); set("projectId", 1);
}, []); }, []);
if (findingProjects || findingTask) {
return null;
}
return ( return (
<form className="task" onSubmit={handleSubmit(onSave)}> <form className="task" onSubmit={handleSubmit(onSave)}>
<input type="hidden" value={task?.id} {...register("id")} />
<div className="panel"> <div className="panel">
<div className="panel-header panel-tabs"> <div className="panel-header panel-tabs">
<ul className="tabs"> <ul className="tabs">
@ -67,7 +73,7 @@ export function Task({ task }: { task?: Task }) {
<div className="panel-body"> <div className="panel-body">
<div> <div>
<Select {...register("projectId")} className="form"> <Select {...register("projectId")} className="form">
{projects.map((project, key) => { {projects.map((project: Project, key: number) => {
return ( return (
<option key={key} value={project.id}> <option key={key} value={project.id}>
{project.name} {project.name}
@ -84,6 +90,7 @@ export function Task({ task }: { task?: Task }) {
defaultValue={task?.title} defaultValue={task?.title}
{...register("title", { required: true })} {...register("title", { required: true })}
/> />
<input type="hidden" name="projectId" {...register("projectId")} />
</div> </div>
{isEditable ? ( {isEditable ? (
<> <>

View file

@ -1,54 +1,29 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useTasks } from "/hooks/useTasks"; import { useTasks } from "/hooks/queries/useTasks";
import { useDestroyTask } from "/hooks/useDestroyTask"; import { useDestroyTask } from "/hooks/mutations/useDestroyTask";
import { TrashIcon, DoneIcon } from "/components/Icons"; import { TrashIcon, DoneIcon } from "/components/Icons";
import { NavBar } from "/components/NavBar"; import { NavBar } from "/components/NavBar";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { Task, TASK_COMPLETE } from "/types/schema"; import { Task } from "/types/schema";
import { useUpsertTask } from "/hooks/useUpsertTask";
import classnames from "classnames"; import classnames from "classnames";
import { useCompleteTask } from "/hooks/mutations/useCompleteTask";
type Action = () => Promise<unknown>;
type ActionContext = {
on: Task;
tasks: Task[];
setTask: (t: Task | null) => unknown;
};
export function Tasks() { export function Tasks() {
const [destroyedTask, setDestroyedTask] = useState<Task | null>(null); const { refetch, loading, data } = useTasks();
const [completedTask, setCompletedTask] = useState<Task | null>(null); const tasks = data?.tasks;
const { tasks, setTasks } = useTasks(); const [destroyTask] = useDestroyTask();
const upsertTask = useUpsertTask(); const [completeTask] = useCompleteTask();
const destroyTask = useDestroyTask(); const [destroyedTask, setDestroyedTask] = useState<Task>(null);
const perform = ( const [completedTask, setCompletedTask] = useState<Task>(null);
action: Action,
{ on: task, tasks, setTask }: ActionContext,
) => {
action()
.then(() => tasks.filter((t: Task) => t.id !== task.id))
.then((tasks: Task[]) => {
setTask(task);
setTimeout(() => {
setTasks(tasks);
setTask(null);
}, 500);
});
};
const onDestroy = (task: Task) => {
const action = () => destroyTask({ id: task.id });
perform(action, { on: task, tasks, setTask: setDestroyedTask });
};
const onComplete = (task: Task) => {
const action = () =>
upsertTask({ input: { id: task.id, status: TASK_COMPLETE } });
perform(action, { on: task, tasks, setTask: setCompletedTask });
};
useEffect(() => { useEffect(() => {
document.title = "Tasks"; document.title = "Tasks";
}, []); }, []);
if (loading) {
return null;
}
return ( return (
<div className="two-columns"> <div className="two-columns">
<div className="column-1"> <div className="column-1">
@ -60,11 +35,14 @@ export function Tasks() {
<div className="panel-body"> <div className="panel-body">
<ul className="collection"> <ul className="collection">
{tasks.map((task: Task, key: number) => { {tasks.map((task: Task, key: number) => {
const { updated_at: updatedAt } = task; const { updatedAt } = task;
const datetime = DateTime.fromISO(updatedAt); const datetime = DateTime.fromISO(updatedAt);
const wasDestroyed = task === destroyedTask; const wasDestroyed = task === destroyedTask;
const wasCompleted = task === completedTask; const wasCompleted = task === completedTask;
const classes = { completed: wasCompleted, removed: wasDestroyed }; const classes = {
completed: wasCompleted,
removed: wasDestroyed,
};
const editHref = `/tasks/edit#id=${task.id}`; const editHref = `/tasks/edit#id=${task.id}`;
return ( return (
<li className={classnames("item", classes)} key={key}> <li className={classnames("item", classes)} key={key}>
@ -78,9 +56,12 @@ export function Tasks() {
</span> </span>
</span> </span>
</a> </a>
<span className="break"></span> <span className="break" />
<span className="tags"> <span className="tags">
<span style={{backgroundColor: task.project.color}} className="tag"> <span
style={{ backgroundColor: task.project.color }}
className="tag"
>
{task.project.name} {task.project.name}
</span> </span>
</span> </span>
@ -89,19 +70,25 @@ export function Tasks() {
<li> <li>
<DoneIcon <DoneIcon
title="Complete task" title="Complete task"
onClick={(e: React.MouseEvent) => [ onClick={async (_e: React.MouseEvent) => {
e.stopPropagation(), await completeTask({
onComplete(task), variables: { taskId: task.id },
]} });
setCompletedTask(task);
setTimeout(refetch, 500);
}}
/> />
</li> </li>
<li> <li>
<TrashIcon <TrashIcon
title="Delete task" title="Delete task"
onClick={(e: React.MouseEvent) => [ onClick={async (_e: React.MouseEvent) => {
e.stopPropagation(), await destroyTask({
onDestroy(task), variables: { taskId: task.id },
]} });
setDestroyedTask(task);
setTimeout(refetch, 500);
}}
/> />
</li> </li>
</ul> </ul>

View file

@ -0,0 +1,15 @@
import { gql, useMutation } from "@apollo/client";
import { CompleteTaskPayload, MutationCompleteTaskArgs } from "/types/schema";
const GQL = gql`
mutation CompleteTask($taskId: Int!) {
completeTask(taskId: $taskId) {
ok
errors
}
}
`;
export function useCompleteTask() {
return useMutation<CompleteTaskPayload, MutationCompleteTaskArgs>(GQL);
}

View file

@ -0,0 +1,14 @@
import { CreateTaskPayload, MutationCreateTaskArgs } from "/types/schema";
import { gql, useMutation } from "@apollo/client";
const GQL = gql`
mutation CreateTask($input: TaskInput!) {
createTask(input: $input) {
errors
}
}
`;
export function useCreateTask() {
return useMutation<CreateTaskPayload, MutationCreateTaskArgs>(GQL);
}

View file

@ -0,0 +1,15 @@
import { gql, useMutation } from "@apollo/client";
import { DestroyTaskPayload, MutationDestroyTaskArgs } from "/types/schema";
const GQL = gql`
mutation DestroyTask($taskId: Int!) {
destroyTask(taskId: $taskId) {
ok
errors
}
}
`;
export function useDestroyTask() {
return useMutation<DestroyTaskPayload, MutationDestroyTaskArgs>(GQL);
}

View file

@ -0,0 +1,14 @@
import { UpdateTaskPayload, MutationUpdateTaskArgs } from "/types/schema";
import { gql, useMutation } from "@apollo/client";
const GQL = gql`
mutation UpdateTask($taskId: Int!, $input: TaskInput!) {
updateTask(taskId: $taskId, input: $input) {
errors
}
}
`;
export function useUpdateTask() {
return useMutation<UpdateTaskPayload, MutationUpdateTaskArgs>(GQL);
}

View file

@ -0,0 +1,29 @@
import { gql, useQuery } from "@apollo/client";
import { Maybe, Task } from "/types/schema";
const GQL = gql`
query Query($taskId: Int!) {
findTask(taskId: $taskId) {
id
title
content
status
project {
id
name
}
}
}
`;
export function useFindTask(taskId: Maybe<number>) {
if (taskId) {
return useQuery(GQL, { variables: { taskId } });
} else {
const result: { data: Maybe<Task>; loading: boolean } = {
data: null,
loading: false,
};
return result;
}
}

View file

@ -0,0 +1,16 @@
import { useQuery, gql } from "@apollo/client";
const GQL = gql`
query Query {
projects {
id
name
path
color
}
}
`;
export function useProjects() {
return useQuery(GQL);
}

View file

@ -0,0 +1,19 @@
import { useQuery, gql } from "@apollo/client";
const GQL = gql`
query Query {
tasks {
id
title
status
updatedAt
project {
color
}
}
}
`;
export function useTasks() {
return useQuery(GQL);
}

View file

@ -1,17 +0,0 @@
import fetch from "/lib/fetch";
type Params = {
id: number;
};
export function useDestroyTask() {
return function ({ id }: Params) {
return new Promise((resolve, reject) => {
const reqinit = { method: "DELETE" };
return fetch(`/servlet/tasks/${id}`, reqinit)
.then(res => res.json())
.then(resolve)
.catch(reject);
});
};
}

View file

@ -1,14 +0,0 @@
import { useState, useEffect } from "react";
import { Project } from "/types/schema";
export function useProjects() {
const [projects, setProjects] = useState<Project[]>([]);
useEffect(() => {
fetch("/servlet/projects")
.then(res => res.json())
.then(res => setProjects(res.projects));
}, []);
return projects;
}

View file

@ -1,27 +0,0 @@
import { useState, useEffect } from "react";
import { Task } from "/types/schema";
type Result = {
setTasks: (tasks: Task[]) => unknown;
tasks: Task[];
req: () => Promise<Task[]>;
};
export function useTasks(): Result {
const [tasks, setTasks] = useState<Task[]>([]);
const set = (ary: Task[]) => {
setTasks(ary);
return ary;
};
const req = async function (): Promise<Task[]> {
return await fetch("/servlet/tasks")
.then((res: Response) => res.json())
.then((res: { tasks: Task[] }) => set(res.tasks))
.catch(() => null);
};
useEffect(() => {
req();
}, []);
return { tasks, setTasks: set, req };
}

View file

@ -1,29 +0,0 @@
import { TASK_STATUS } from "/types/schema";
import fetch from "/lib/fetch";
type Params = {
id?: number;
status?: TASK_STATUS;
title?: string;
content?: string;
projectId?: number;
};
export function useUpsertTask() {
const normalize = (input: Params) => {
const { id, title, content, status, projectId } = input;
return { id, title, content, status, project_id: projectId };
};
return function ({ input }: { input: Params }) {
return new Promise((resolve, reject) => {
const reqinit = {
method: input.id ? "PUT" : "POST",
body: JSON.stringify(normalize(input)),
};
return fetch("/servlet/tasks", reqinit)
.then(res => res.json())
.then(resolve)
.catch(reject);
});
};
}

View file

@ -2,7 +2,7 @@ export default async function (
path: string, path: string,
reqinit: RequestInit, reqinit: RequestInit,
): Promise<Response> { ): Promise<Response> {
return fetch(path, reqinit).then(res => { return await fetch(path, reqinit).then(res => {
if (res.status === 200) { if (res.status === 200) {
return res; return res;
} else { } else {

View file

@ -1,8 +1,17 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { Projects } from "/components/Projects"; import { Projects } from "/components/Projects";
import { ApolloProvider, ApolloClient, InMemoryCache } from "@apollo/client";
(function () { (function () {
const root = document.querySelector(".react-mount.projects")!; const root = document.querySelector(".react-mount.projects")!;
ReactDOM.createRoot(root).render(<Projects />); const client = new ApolloClient({
uri: "/servlet/graphql",
cache: new InMemoryCache(),
});
ReactDOM.createRoot(root).render(
<ApolloProvider client={client}>
<Projects />
</ApolloProvider>,
);
})(); })();

View file

@ -1,7 +1,9 @@
import { ApolloProvider } from "@apollo/client";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { Task as Component } from "/components/Task"; import { Task as Component } from "/components/Task";
import { Task } from "/types/schema"; import { Task } from "/types/schema";
import { ApolloClient, InMemoryCache } from "@apollo/client";
(function () { (function () {
const params = Object.fromEntries( const params = Object.fromEntries(
@ -10,10 +12,14 @@ import { Task } from "/types/schema";
.split(",") .split(",")
.map(e => e.split("=")), .map(e => e.split("=")),
); );
fetch(`/servlet/tasks/${params.id}`) const client = new ApolloClient({
.then(res => res.json()) uri: "/servlet/graphql",
.then(({ task }: { task: Task }) => { cache: new InMemoryCache(),
const root = document.querySelector(".react-mount.edit-task")!;
ReactDOM.createRoot(root).render(<Component task={task} />);
}); });
const root = document.querySelector(".react-mount.edit-task")!;
ReactDOM.createRoot(root).render(
<ApolloProvider client={client}>
<Component taskId={Number(params.id)} />
</ApolloProvider>,
);
})(); })();

View file

@ -1,8 +1,17 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { Task } from "/components/Task"; import { Task } from "/components/Task";
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
(function () { (function () {
const root = document.querySelector(".react-mount.new-task")!; const root = document.querySelector(".react-mount.new-task")!;
ReactDOM.createRoot(root).render(<Task />); const client = new ApolloClient({
uri: "/servlet/graphql",
cache: new InMemoryCache(),
});
ReactDOM.createRoot(root).render(
<ApolloProvider client={client}>
<Task />
</ApolloProvider>,
);
})(); })();

View file

@ -1,8 +1,17 @@
import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { Tasks } from "/components/Tasks"; import { Tasks } from "/components/Tasks";
(function () { (function () {
const n1 = document.querySelector(".react-mount.tasks")!; const root = document.querySelector(".react-mount.tasks")!;
ReactDOM.createRoot(n1).render(<Tasks />); const client = new ApolloClient({
uri: "/servlet/graphql",
cache: new InMemoryCache(),
});
ReactDOM.createRoot(root).render(
<ApolloProvider client={client}>
<Tasks />
</ApolloProvider>,
);
})(); })();

View file

@ -1,21 +1,123 @@
export const TASK_READY = "ready"; export type Maybe<T> = T | null;
export const TASK_INPROGRESS = "in_progress"; export type InputMaybe<T> = Maybe<T>;
export const TASK_COMPLETE = "complete"; export type Exact<T extends { [key: string]: unknown }> = {
export type TASK_STATUS = "ready" | "in_progress" | "complete"; [K in keyof T]: T[K];
};
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & {
[SubKey in K]?: Maybe<T[SubKey]>;
};
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & {
[SubKey in K]: Maybe<T[SubKey]>;
};
export type MakeEmpty<
T extends { [key: string]: unknown },
K extends keyof T,
> = { [_ in K]?: never };
export type Incremental<T> =
| T
| {
[P in keyof T]?: P extends " $fragmentName" | "__typename" ? T[P] : never;
};
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: { input: string; output: string };
String: { input: string; output: string };
Boolean: { input: boolean; output: boolean };
Int: { input: number; output: number };
Float: { input: number; output: number };
/** An ISO 8601-encoded datetime */
ISO8601DateTime: { input: any; output: any };
};
/** Autogenerated return type of CompleteTask. */
export type CompleteTaskPayload = {
__typename?: "CompleteTaskPayload";
errors?: Maybe<Array<Scalars["String"]["output"]>>;
ok?: Maybe<Scalars["Boolean"]["output"]>;
};
/** Autogenerated return type of CreateTask. */
export type CreateTaskPayload = {
__typename?: "CreateTaskPayload";
errors: Array<Scalars["String"]["output"]>;
};
/** Autogenerated return type of DestroyTask. */
export type DestroyTaskPayload = {
__typename?: "DestroyTaskPayload";
errors?: Maybe<Array<Scalars["String"]["output"]>>;
ok?: Maybe<Scalars["Boolean"]["output"]>;
};
export type Mutation = {
__typename?: "Mutation";
completeTask?: Maybe<CompleteTaskPayload>;
createTask?: Maybe<CreateTaskPayload>;
destroyTask?: Maybe<DestroyTaskPayload>;
updateTask?: Maybe<UpdateTaskPayload>;
};
export type MutationCompleteTaskArgs = {
taskId: Scalars["Int"]["input"];
};
export type MutationCreateTaskArgs = {
input: TaskInput;
};
export type MutationDestroyTaskArgs = {
taskId: Scalars["Int"]["input"];
};
export type MutationUpdateTaskArgs = {
input: TaskInput;
taskId: Scalars["Int"]["input"];
};
export type Project = { export type Project = {
id: number; __typename?: "Project";
name: string; color: Scalars["String"]["output"];
path: string; id: Scalars["ID"]["output"];
color: string; name: Scalars["String"]["output"];
path: Scalars["String"]["output"];
tasks: Array<Task>;
};
export type Query = {
__typename?: "Query";
findTask?: Maybe<Task>;
projects: Array<Project>;
tasks: Array<Task>;
};
export type QueryFindTaskArgs = {
taskId: Scalars["Int"]["input"];
}; };
export type Task = { export type Task = {
id: number; __typename?: "Task";
title: string; content: Scalars["String"]["output"];
content: string; id: Scalars["Int"]["output"];
status: TASK_STATUS;
project: Project; project: Project;
created_at: string; status: TaskStatus;
updated_at: string; title: Scalars["String"]["output"];
updatedAt: Scalars["ISO8601DateTime"]["output"];
};
export type TaskInput = {
content: Scalars["String"]["input"];
projectId: Scalars["Int"]["input"];
title: Scalars["String"]["input"];
};
export enum TaskStatus {
Complete = "complete",
InProgress = "in_progress",
Ready = "ready",
}
/** Autogenerated return type of UpdateTask. */
export type UpdateTaskPayload = {
__typename?: "UpdateTaskPayload";
errors: Array<Scalars["String"]["output"]>;
}; };

View file

@ -13,4 +13,5 @@ Gem::Specification.new do |gem|
gem.require_paths = ["lib"] gem.require_paths = ["lib"]
gem.summary = "twenty: frontend" gem.summary = "twenty: frontend"
gem.description = gem.summary gem.description = gem.summary
gem.add_development_dependency "rake", "~> 13.0"
end end