diff --git a/Gemfile b/Gemfile index 63ab7204..8b2bb232 100644 --- a/Gemfile +++ b/Gemfile @@ -5,42 +5,28 @@ ruby "3.2.0" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "7.1.2" -# The original asset pipeline for Rails [https://github.com/rails/sprockets-rails] gem "sprockets-rails" -# Use postgresql as the database for Active Record gem "pg", "~> 1.1" -# Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" -# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] gem "importmap-rails" - -# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] gem "turbo-rails" - -# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] gem "stimulus-rails" - -# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] gem "tailwindcss-rails" -# Build JSON APIs with ease [https://github.com/rails/jbuilder] -gem "jbuilder" - # Use Redis adapter to run Action Cable in production # gem "redis", ">= 4.0.1" # Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis] # gem "kredis" -# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" - # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] +gem "passwordless", "~> 1.1" +gem "graphql", "~> 2.2" gem "view_component" # Reduces boot times through caching; required in config/boot.rb @@ -66,6 +52,7 @@ group :development do # gem "rack-mini-profiler" gem "letter_opener" + gem "graphiql-rails" end group :test do @@ -77,6 +64,4 @@ group :test do gem "factory_bot_rails" gem 'shoulda-matchers', '~> 5.0' gem "timecop" -end - -gem "passwordless", "~> 1.1" +end \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index ac34571c..ffe24bab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,6 +120,11 @@ GEM i18n (>= 1.8.11, < 2) globalid (1.2.1) activesupport (>= 6.1) + graphiql-rails (1.9.0) + railties + sprockets-rails + graphql (2.2.3) + racc (~> 1.4) i18n (1.14.1) concurrent-ruby (~> 1.0) importmap-rails (1.2.3) @@ -130,9 +135,6 @@ GEM irb (1.11.0) rdoc reline (>= 0.3.8) - jbuilder (2.11.5) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) launchy (2.5.2) addressable (~> 2.8) letter_opener (1.8.1) @@ -305,8 +307,9 @@ DEPENDENCIES dotenv-rails factory_bot_rails faker + graphiql-rails + graphql (~> 2.2) importmap-rails - jbuilder letter_opener passwordless (~> 1.1) pg (~> 1.1) diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb new file mode 100644 index 00000000..6ce0ead2 --- /dev/null +++ b/app/controllers/graphql_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class GraphqlController < ApplicationController + # If accessing from outside this domain, nullify the session + # This allows for outside API access while preventing CSRF attacks, + # but you'll have to authenticate your user separately + # protect_from_forgery with: :null_session + + skip_before_action :verify_authenticity_token + + def execute + variables = prepare_variables(params[:variables]) + query = params[:query] + operation_name = params[:operationName] + context = { + # Query context goes here, for example: + current_user: current_user, + current_company: current_company + } + result = StaffplanReduxSchema.execute(query, variables: variables, context: context, operation_name: operation_name) + render json: result + rescue StandardError => e + raise e unless Rails.env.development? + handle_error_in_development(e) + end + + private + + # Handle variables in form data, JSON body, or a blank value + def prepare_variables(variables_param) + case variables_param + when String + if variables_param.present? + JSON.parse(variables_param) || {} + else + {} + end + when Hash + variables_param + when ActionController::Parameters + variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables. + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{variables_param}" + end + end + + def handle_error_in_development(e) + logger.error e.message + logger.error e.backtrace.join("\n") + + render json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} }, status: 500 + end +end diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb new file mode 100644 index 00000000..4f318d4e --- /dev/null +++ b/app/graphql/mutations/base_mutation.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Mutations + class BaseMutation < GraphQL::Schema::Mutation + null false + end +end diff --git a/app/graphql/mutations/create_work_week.rb b/app/graphql/mutations/create_work_week.rb new file mode 100644 index 00000000..138411b1 --- /dev/null +++ b/app/graphql/mutations/create_work_week.rb @@ -0,0 +1,14 @@ +module Mutations + class CreateWorkWeek < BaseMutation + # arguments passed to the `resolve` method + # argument :description, String, required: true + # argument :url, String, required: true + + # return type from the mutation + type Types::StaffPlan::WorkWeekType + + def resolve + # TODO: implement + end + end +end \ No newline at end of file diff --git a/app/graphql/mutations/set_current_company.rb b/app/graphql/mutations/set_current_company.rb new file mode 100644 index 00000000..c5af4796 --- /dev/null +++ b/app/graphql/mutations/set_current_company.rb @@ -0,0 +1,13 @@ +module Mutations + class SetCurrentCompany < BaseMutation + # arguments passed to the `resolve` method + argument :company_id, ID, required: true + + # return type from the mutation + type Types::StaffPlan::CompanyType + + def resolve + # TODO: implement + end + end +end \ No newline at end of file diff --git a/app/graphql/mutations/update_work_week.rb b/app/graphql/mutations/update_work_week.rb new file mode 100644 index 00000000..cf2b2676 --- /dev/null +++ b/app/graphql/mutations/update_work_week.rb @@ -0,0 +1,14 @@ +module Mutations + class UpdateWorkWeek < BaseMutation + # arguments passed to the `resolve` method + # argument :description, String, required: true + # argument :url, String, required: true + + # return type from the mutation + type Types::StaffPlan::WorkWeekType + + def resolve + # TODO: implement + end + end +end \ No newline at end of file diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb new file mode 100644 index 00000000..89b7f9da --- /dev/null +++ b/app/graphql/resolvers/base_resolver.rb @@ -0,0 +1,4 @@ +module Resolvers + class BaseResolver < GraphQL::Schema::Resolver + end +end diff --git a/app/graphql/staffplan_redux_schema.rb b/app/graphql/staffplan_redux_schema.rb new file mode 100644 index 00000000..2bd91357 --- /dev/null +++ b/app/graphql/staffplan_redux_schema.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class StaffplanReduxSchema < GraphQL::Schema + mutation(Types::MutationType) + query(Types::QueryType) + + # For batch-loading (see https://graphql-ruby.org/dataloader/overview.html) + use GraphQL::Dataloader + + # GraphQL-Ruby calls this when something goes wrong while running a query: + def self.type_error(err, context) + # if err.is_a?(GraphQL::InvalidNullError) + # # report to your bug tracker here + # return nil + # end + super + end + + # Union and Interface Resolution + def self.resolve_type(abstract_type, obj, ctx) + # TODO: Implement this method + # to return the correct GraphQL object type for `obj` + raise(GraphQL::RequiredImplementationMissingError) + end + + # Stop validating when it encounters this many errors: + validate_max_errors(100) + + # Relay-style Object Identification: + + # Return a string UUID for `object` + def self.id_from_object(object, type_definition, query_ctx) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + object.to_gid_param + end + + # Given a string UUID, find the object + def self.object_from_id(global_id, query_ctx) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + GlobalID.find(global_id) + end +end diff --git a/app/graphql/types/.keep b/app/graphql/types/.keep new file mode 100644 index 00000000..e69de29b diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb new file mode 100644 index 00000000..2e2278c5 --- /dev/null +++ b/app/graphql/types/base_argument.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseArgument < GraphQL::Schema::Argument + end +end diff --git a/app/graphql/types/base_connection.rb b/app/graphql/types/base_connection.rb new file mode 100644 index 00000000..366c69e8 --- /dev/null +++ b/app/graphql/types/base_connection.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseConnection < Types::BaseObject + # add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides + include GraphQL::Types::Relay::ConnectionBehaviors + end +end diff --git a/app/graphql/types/base_edge.rb b/app/graphql/types/base_edge.rb new file mode 100644 index 00000000..e0d2f79c --- /dev/null +++ b/app/graphql/types/base_edge.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseEdge < Types::BaseObject + # add `node` and `cursor` fields, as well as `node_type(...)` override + include GraphQL::Types::Relay::EdgeBehaviors + end +end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb new file mode 100644 index 00000000..cf43fea4 --- /dev/null +++ b/app/graphql/types/base_enum.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseEnum < GraphQL::Schema::Enum + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb new file mode 100644 index 00000000..611eb056 --- /dev/null +++ b/app/graphql/types/base_field.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class BaseField < GraphQL::Schema::Field + argument_class Types::BaseArgument + end +end diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb new file mode 100644 index 00000000..27951132 --- /dev/null +++ b/app/graphql/types/base_input_object.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class BaseInputObject < GraphQL::Schema::InputObject + argument_class Types::BaseArgument + end +end diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb new file mode 100644 index 00000000..18899387 --- /dev/null +++ b/app/graphql/types/base_interface.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module BaseInterface + include GraphQL::Schema::Interface + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + + field_class Types::BaseField + end +end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb new file mode 100644 index 00000000..487af2f5 --- /dev/null +++ b/app/graphql/types/base_object.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + class BaseObject < GraphQL::Schema::Object + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + field_class Types::BaseField + end +end diff --git a/app/graphql/types/base_scalar.rb b/app/graphql/types/base_scalar.rb new file mode 100644 index 00000000..719bc808 --- /dev/null +++ b/app/graphql/types/base_scalar.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseScalar < GraphQL::Schema::Scalar + end +end diff --git a/app/graphql/types/base_union.rb b/app/graphql/types/base_union.rb new file mode 100644 index 00000000..95941696 --- /dev/null +++ b/app/graphql/types/base_union.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseUnion < GraphQL::Schema::Union + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb new file mode 100644 index 00000000..321b5dee --- /dev/null +++ b/app/graphql/types/mutation_type.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + class MutationType < Types::BaseObject + field :set_current_company, mutation: Mutations::SetCurrentCompany + field :create_work_week, mutation: Mutations::CreateWorkWeek + field :update_work_week, mutation: Mutations::UpdateWorkWeek + end +end diff --git a/app/graphql/types/node_type.rb b/app/graphql/types/node_type.rb new file mode 100644 index 00000000..c71ec3ee --- /dev/null +++ b/app/graphql/types/node_type.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + module NodeType + include Types::BaseInterface + # Add the `id` field + include GraphQL::Types::Relay::NodeBehaviors + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb new file mode 100644 index 00000000..8a50de20 --- /dev/null +++ b/app/graphql/types/query_type.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Types + class QueryType < Types::BaseObject + field :node, Types::NodeType, null: true, description: "Fetches an object given its ID." do + argument :id, ID, required: true, description: "ID of the object." + end + + def node(id:) + context.schema.object_from_id(id, context) + end + + field :nodes, [Types::NodeType, null: true], null: true, description: "Fetches a list of objects given a list of IDs." do + argument :ids, [ID], required: true, description: "IDs of the objects." + end + + def nodes(ids:) + ids.map { |id| context.schema.object_from_id(id, context) } + end + + field :clients, [Types::StaffPlan::ClientType], null: false + def clients + context[:current_user].current_company.clients.all + end + + field :project_assignments, [Types::StaffPlan::AssignmentType], null: false do + argument :project_id, ID, required: true, description: "ID of the project to fetch assignments for." + end + + def project_assignments(project_id: nil) + context[:current_company] + .projects + .find(project_id) + .assignments + .all + end + + field :user_assignments, [Types::StaffPlan::AssignmentType], null: false do + argument :user_id, ID, required: false, + description: "ID of the user to fetch assignments for. The current user's assignments will be returned if this argument is not provided." + end + def user_assignments(user_id: nil) + target = if user_id.present? + context[:current_company].users.find(user_id) + else + context[:current_user] + end + + target.assignments.all + end + + field :users, [Types::StaffPlan::UserType], null: false + def users + context[:current_user].current_company.users.all + end + end +end diff --git a/app/graphql/types/staff_plan/assignment_type.rb b/app/graphql/types/staff_plan/assignment_type.rb new file mode 100644 index 00000000..cdc0ec44 --- /dev/null +++ b/app/graphql/types/staff_plan/assignment_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module StaffPlan + class AssignmentType < Types::BaseObject + field :id, ID, null: false + field :user, Types::StaffPlan::UserType, null: false + field :project, Types::StaffPlan::ProjectType, null: false + field :status, String, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :work_weeks, [Types::StaffPlan::WorkWeekType], null: false + + def work_weeks + object.work_weeks + end + end + end +end diff --git a/app/graphql/types/staff_plan/client_type.rb b/app/graphql/types/staff_plan/client_type.rb new file mode 100644 index 00000000..a46765c1 --- /dev/null +++ b/app/graphql/types/staff_plan/client_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module StaffPlan + class ClientType < Types::BaseObject + field :id, ID, null: false + field :name, String, null: false + field :description, String, null: true + field :status, String, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :projects, [Types::StaffPlan::ProjectType], null: false + + def projects + object.projects + end + end + end +end diff --git a/app/graphql/types/staff_plan/company_type.rb b/app/graphql/types/staff_plan/company_type.rb new file mode 100644 index 00000000..05a4a753 --- /dev/null +++ b/app/graphql/types/staff_plan/company_type.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + module StaffPlan + class CompanyType < Types::BaseObject + field :id, ID, null: false + field :name, String, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :projects, [Types::StaffPlan::ProjectType], null: false + + def projects + object.projects + end + + field :clients, [Types::StaffPlan::ClientType], null: false + + def clients + object.clients + end + + field :users, [Types::StaffPlan::UserType], null: false + + def users + object.users + end + end + end +end diff --git a/app/graphql/types/staff_plan/project_type.rb b/app/graphql/types/staff_plan/project_type.rb new file mode 100644 index 00000000..1a7f7a71 --- /dev/null +++ b/app/graphql/types/staff_plan/project_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Types + module StaffPlan + class ProjectType < Types::BaseObject + field :id, ID, null: false + field :client, Types::StaffPlan::ClientType, null: false + field :name, String, null: false + field :status, String, null: false + field :payment_frequency, String, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :assignments, [Types::StaffPlan::AssignmentType], null: false + + def assigmnents + object.assignments + end + + field :users, [Types::StaffPlan::UserType], null: false + + def users + object.users + end + + field :work_weeks, [Types::StaffPlan::WorkWeekType], null: false + + def work_weeks + object.work_weeks + end + end + end +end diff --git a/app/graphql/types/staff_plan/user_type.rb b/app/graphql/types/staff_plan/user_type.rb new file mode 100644 index 00000000..c4358968 --- /dev/null +++ b/app/graphql/types/staff_plan/user_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module StaffPlan + class UserType < Types::BaseObject + field :id, ID, null: false + field :name, String, null: false + field :email, String, null: false + field :current_company_id, ID, null: true + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :companies, [Types::StaffPlan::CompanyType], null: false + + def companies + object.companies + end + + field :assignments, [Types::StaffPlan::AssignmentType], null: false + + def assignments + object.assignments + end + + field :projects, [Types::StaffPlan::ProjectType], null: false + + def projects + object.projects + end + end + end +end diff --git a/app/graphql/types/staff_plan/work_week_type.rb b/app/graphql/types/staff_plan/work_week_type.rb new file mode 100644 index 00000000..76fbd2a9 --- /dev/null +++ b/app/graphql/types/staff_plan/work_week_type.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module StaffPlan + class WorkWeekType < Types::BaseObject + field :id, ID, null: false + field :user, Types::StaffPlan::UserType, null: false + field :assignment_id, Integer, null: false + field :cweek, Integer, null: false + field :year, Integer, null: false + field :beginning_of_week, Integer, null: false + field :estimated_hours, Integer, null: false + field :actual_hours, Integer, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + def user + object.user + end + + field :project, Types::StaffPlan::ProjectType, null: false + + def project + object.project + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 3c58fc32..4c7b16ca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,13 @@ Rails.application.routes.draw do + namespace :api do + get 'current_user/create' + end + if Rails.env.development? + mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" + end + + post "/graphql", to: "graphql#execute" + resources :work_weeks resources :assignments resources :projects