milestone: switch to graphql
This commit is contained in:
parent
afcdfc344e
commit
f2a54c4fb8
54 changed files with 4995 additions and 402 deletions
|
@ -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
|
|
@ -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"
|
||||||
|
|
7
twenty-backend/lib/twenty-backend/graphql.rb
Normal file
7
twenty-backend/lib/twenty-backend/graphql.rb
Normal 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
|
4
twenty-backend/lib/twenty-backend/graphql/input.rb
Normal file
4
twenty-backend/lib/twenty-backend/graphql/input.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
module Twenty::GraphQL::Input
|
||||||
|
include GraphQL::Types
|
||||||
|
require_relative "input/task_input"
|
||||||
|
end
|
|
@ -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
|
5
twenty-backend/lib/twenty-backend/graphql/mutation.rb
Normal file
5
twenty-backend/lib/twenty-backend/graphql/mutation.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
module Twenty::GraphQL
|
||||||
|
module Mutation
|
||||||
|
require_relative "mutation/destroy_task"
|
||||||
|
end
|
||||||
|
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
6
twenty-backend/lib/twenty-backend/graphql/schema.rb
Normal file
6
twenty-backend/lib/twenty-backend/graphql/schema.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module Twenty::GraphQL
|
||||||
|
class Schema < GraphQL::Schema
|
||||||
|
query Type::Query
|
||||||
|
mutation Type::Mutation
|
||||||
|
end
|
||||||
|
end
|
10
twenty-backend/lib/twenty-backend/graphql/type.rb
Normal file
10
twenty-backend/lib/twenty-backend/graphql/type.rb
Normal 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
|
12
twenty-backend/lib/twenty-backend/graphql/type/mutation.rb
Normal file
12
twenty-backend/lib/twenty-backend/graphql/type/mutation.rb
Normal 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
|
10
twenty-backend/lib/twenty-backend/graphql/type/project.rb
Normal file
10
twenty-backend/lib/twenty-backend/graphql/type/project.rb
Normal 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
|
23
twenty-backend/lib/twenty-backend/graphql/type/query.rb
Normal file
23
twenty-backend/lib/twenty-backend/graphql/type/query.rb
Normal 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
|
11
twenty-backend/lib/twenty-backend/graphql/type/task.rb
Normal file
11
twenty-backend/lib/twenty-backend/graphql/type/task.rb
Normal 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
|
|
@ -0,0 +1,7 @@
|
||||||
|
module Twenty::GraphQL::Type
|
||||||
|
class TaskStatus < GraphQL::Schema::Enum
|
||||||
|
value :ready
|
||||||
|
value :in_progress
|
||||||
|
value :complete
|
||||||
|
end
|
||||||
|
end
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
15
twenty-backend/lib/twenty-backend/servlet/graphql.rb
Normal file
15
twenty-backend/lib/twenty-backend/servlet/graphql.rb
Normal 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
|
|
@ -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
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
76
twenty-backend/share/twenty-backend/schema.graphql
Normal file
76
twenty-backend/share/twenty-backend/schema.graphql
Normal 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!]!
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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!
|
||||||
|
|
||||||
|
|
11
twenty-frontend/Rakefile.rb
Normal file
11
twenty-frontend/Rakefile.rb
Normal 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
|
7
twenty-frontend/codegen.yml
Normal file
7
twenty-frontend/codegen.yml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
schema:
|
||||||
|
- ../twenty-backend/share/twenty-backend/schema.graphql
|
||||||
|
|
||||||
|
generates:
|
||||||
|
src/js/types/schema.ts:
|
||||||
|
plugins:
|
||||||
|
- typescript
|
4341
twenty-frontend/package-lock.json
generated
4341
twenty-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||||
|
|
|
@ -1,25 +1,31 @@
|
||||||
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">
|
||||||
<NavBar/>
|
<NavBar />
|
||||||
</div>
|
</div>
|
||||||
<div className="column-2">
|
<div className="column-2">
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<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}`}>
|
||||||
|
|
|
@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,58 +1,33 @@
|
||||||
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">
|
||||||
<NavBar/>
|
<NavBar />
|
||||||
</div>
|
</div>
|
||||||
<div className="column-2">
|
<div className="column-2">
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
|
@ -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>
|
||||||
|
|
15
twenty-frontend/src/js/hooks/mutations/useCompleteTask.ts
Normal file
15
twenty-frontend/src/js/hooks/mutations/useCompleteTask.ts
Normal 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);
|
||||||
|
}
|
14
twenty-frontend/src/js/hooks/mutations/useCreateTask.ts
Normal file
14
twenty-frontend/src/js/hooks/mutations/useCreateTask.ts
Normal 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);
|
||||||
|
}
|
15
twenty-frontend/src/js/hooks/mutations/useDestroyTask.ts
Normal file
15
twenty-frontend/src/js/hooks/mutations/useDestroyTask.ts
Normal 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);
|
||||||
|
}
|
14
twenty-frontend/src/js/hooks/mutations/useUpdateTask.ts
Normal file
14
twenty-frontend/src/js/hooks/mutations/useUpdateTask.ts
Normal 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);
|
||||||
|
}
|
29
twenty-frontend/src/js/hooks/queries/useFindTask.ts
Normal file
29
twenty-frontend/src/js/hooks/queries/useFindTask.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
16
twenty-frontend/src/js/hooks/queries/useProjects.ts
Normal file
16
twenty-frontend/src/js/hooks/queries/useProjects.ts
Normal 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);
|
||||||
|
}
|
19
twenty-frontend/src/js/hooks/queries/useTasks.ts
Normal file
19
twenty-frontend/src/js/hooks/queries/useTasks.ts
Normal 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);
|
||||||
|
}
|
|
@ -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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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 };
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>,
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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>,
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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>,
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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>,
|
||||||
|
);
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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"]>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue