From 52e84f2ffe8541ea49a086f48b946b7dafef3d90 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Tue, 20 Nov 2018 22:27:40 +0100 Subject: [PATCH] Add graphql object types --- app/graphql/extensions/attachment.rb | 36 ++++++++++++++ app/graphql/loaders/association.rb | 66 ++++++++++++++++++++++++++ app/graphql/loaders/record.rb | 30 ++++++++++++ app/graphql/types/demarche_type.rb | 50 +++++++++++++++++++ app/graphql/types/dossier_type.rb | 42 ++++++++++++++++ app/graphql/types/profile_type.rb | 6 +++ config/locales/models/dossier/fr.yml | 6 +++ config/locales/models/procedure/fr.yml | 4 ++ 8 files changed, 240 insertions(+) create mode 100644 app/graphql/extensions/attachment.rb create mode 100644 app/graphql/loaders/association.rb create mode 100644 app/graphql/loaders/record.rb create mode 100644 app/graphql/types/demarche_type.rb create mode 100644 app/graphql/types/dossier_type.rb create mode 100644 app/graphql/types/profile_type.rb diff --git a/app/graphql/extensions/attachment.rb b/app/graphql/extensions/attachment.rb new file mode 100644 index 000000000..f0ac99aed --- /dev/null +++ b/app/graphql/extensions/attachment.rb @@ -0,0 +1,36 @@ +# references: +# https://evilmartians.com/chronicles/active-storage-meets-graphql-pt-2-exposing-attachment-urls + +module Extensions + class Attachment < GraphQL::Schema::FieldExtension + attr_reader :attachment_assoc + + def apply + # Here we try to define the attachment name: + # - it could be set explicitly via extension options + # - or we imply that is the same as the field name w/o "_url" + # suffix (e.g., "avatar_url" => "avatar") + attachment = options&.[](:attachment) || field.original_name.to_s.sub(/_url$/, "") + + # that's the name of the Active Record association + @attachment_assoc = "#{attachment}_attachment" + end + + # This method resolves (as it states) the field itself + # (it's the same as defining a method within a type) + def resolve(object:, **_rest) + Loaders::Association.for( + object.object.class, + attachment_assoc => :blob + ).load(object.object) + end + + # This method is called if the result of the `resolve` + # is a lazy value (e.g., a Promise – like in our case) + def after_resolve(value:, **_rest) + return if value.nil? + + Rails.application.routes.url_helpers.url_for(value) + end + end +end diff --git a/app/graphql/loaders/association.rb b/app/graphql/loaders/association.rb new file mode 100644 index 000000000..693bc9b58 --- /dev/null +++ b/app/graphql/loaders/association.rb @@ -0,0 +1,66 @@ +# references: +# https://github.com/Shopify/graphql-batch/blob/master/examples/association_loader.rb +# https://gist.github.com/palkan/03eb5306a1a3e8addbe8df97a298a466 +# https://evilmartians.com/chronicles/active-storage-meets-graphql-pt-2-exposing-attachment-urls + +module Loaders + class Association < GraphQL::Batch::Loader + def self.validate(model, association_name) + new(model, association_name) + nil + end + + def initialize(model, association_schema) + @model = model + @association_schema = association_schema + @association_name = extract_association_id(association_schema) + validate + end + + def load(record) + raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model) + return Promise.resolve(read_association(record)) if association_loaded?(record) + super + end + + # We want to load the associations on all records, even if they have the same id + def cache_key(record) + record.object_id + end + + def perform(records) + preload_association(records.uniq) + records.each { |record| fulfill(record, read_association(record)) } + end + + private + + def validate + unless @model.reflect_on_association(@association_name) + raise ArgumentError, "No association #{@association_name} on #{@model}" + end + end + + def preload_association(records) + ::ActiveRecord::Associations::Preloader.new.preload(records, @association_schema) + end + + def read_association(record) + record.public_send(@association_name) + end + + def association_loaded?(record) + record.association(@association_name).loaded? + end + + def extract_association_id(id_or_hash) + return id_or_hash unless id_or_hash.is_a?(Hash) + + if id_or_hash.keys.size != 1 + raise ArgumentError, "You can only preload exactly one association! You passed: #{id_or_hash}" + end + + id_or_hash.keys.first + end + end +end diff --git a/app/graphql/loaders/record.rb b/app/graphql/loaders/record.rb new file mode 100644 index 000000000..9602a3b60 --- /dev/null +++ b/app/graphql/loaders/record.rb @@ -0,0 +1,30 @@ +# references: +# https://github.com/Shopify/graphql-batch/blob/master/examples/record_loader.rb + +module Loaders + class Record < GraphQL::Batch::Loader + def initialize(model, column: model.primary_key, where: nil) + @model = model + @column = column.to_s + @column_type = model.type_for_attribute(@column) + @where = where + end + + def load(key) + super(@column_type.cast(key)) + end + + def perform(keys) + query(keys).each { |record| fulfill(record.public_send(@column), record) } + keys.each { |key| fulfill(key, nil) unless fulfilled?(key) } + end + + private + + def query(keys) + scope = @model + scope = scope.where(@where) if @where + scope.where(@column => keys) + end + end +end diff --git a/app/graphql/types/demarche_type.rb b/app/graphql/types/demarche_type.rb new file mode 100644 index 000000000..2f561b052 --- /dev/null +++ b/app/graphql/types/demarche_type.rb @@ -0,0 +1,50 @@ +module Types + class DemarcheType < Types::BaseObject + class DemarcheState < Types::BaseEnum + Procedure.aasm.states.reject { |state| state.name == :hidden }.each do |state| + value(state.name.to_s, state.display_name, value: state.name) + end + end + + description "Une demarche" + + global_id_field :id + field :number, ID, "Le numero de la démarche.", null: false, method: :id + field :title, String, null: false, method: :libelle + field :description, String, "Déscription de la démarche.", null: false + field :state, DemarcheState, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + field :archived_at, GraphQL::Types::ISO8601DateTime, null: true + + field :instructeurs, [Types::ProfileType], null: false + + field :dossiers, Types::DossierType.connection_type, "Liste de tous les dossiers d'une démarche.", null: false do + argument :ids, [ID], required: false, description: "Filtrer les dossiers par ID." + argument :since, GraphQL::Types::ISO8601DateTime, required: false, description: "Dossiers crées depuis la date." + end + + def state + object.aasm.current_state + end + + def instructeurs + Loaders::Association.for(Procedure, :instructeurs).load(object) + end + + def dossiers(ids: nil, since: nil) + dossiers = object.dossiers.for_api_v2 + + if ids.present? + dossiers = dossiers.where(id: ids) + end + + if since.present? + dossiers = dossiers.since(since) + end + + dossiers + end + end +end diff --git a/app/graphql/types/dossier_type.rb b/app/graphql/types/dossier_type.rb new file mode 100644 index 000000000..9d44f1f69 --- /dev/null +++ b/app/graphql/types/dossier_type.rb @@ -0,0 +1,42 @@ +module Types + class DossierType < Types::BaseObject + class DossierState < Types::BaseEnum + Dossier.aasm.states.reject { |state| state.name == :brouillon }.each do |state| + value(state.name.to_s, state.display_name, value: state.name.to_s) + end + end + + description "Un dossier" + + global_id_field :id + field :number, ID, "Le numero du dossier.", null: false, method: :id + field :state, DossierState, "L'état du dossier.", null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, "Date de dernière mise à jour.", null: false + + field :date_passage_en_construction, GraphQL::Types::ISO8601DateTime, "Date de dépôt.", null: false, method: :en_construction_at + field :date_passage_en_instruction, GraphQL::Types::ISO8601DateTime, "Date de passage en instruction.", null: true, method: :en_instruction_at + field :date_traitement, GraphQL::Types::ISO8601DateTime, "Date de traitement.", null: true, method: :processed_at + + field :archived, Boolean, null: false + + field :motivation, String, null: true + field :motivation_attachment_url, Types::URL, null: true, extensions: [ + { Extensions::Attachment => { attachment: :justificatif_motivation } } + ] + + field :usager, Types::ProfileType, null: false + field :instructeurs, [Types::ProfileType], null: false + + def state + object.state + end + + def usager + Loaders::Record.for(User).load(object.user_id) + end + + def instructeurs + Loaders::Association.for(object.class, :followers_instructeurs).load(object) + end + end +end diff --git a/app/graphql/types/profile_type.rb b/app/graphql/types/profile_type.rb new file mode 100644 index 000000000..f3f056fd3 --- /dev/null +++ b/app/graphql/types/profile_type.rb @@ -0,0 +1,6 @@ +module Types + class ProfileType < Types::BaseObject + global_id_field :id + field :email, String, null: false + end +end diff --git a/config/locales/models/dossier/fr.yml b/config/locales/models/dossier/fr.yml index 3fc73da1e..729d6ec12 100644 --- a/config/locales/models/dossier/fr.yml +++ b/config/locales/models/dossier/fr.yml @@ -17,3 +17,9 @@ fr: refuse: "Refusé" sans_suite: "Sans suite" autorisation_donnees: Acceptation des CGU + state/brouillon: Brouillon + state/en_construction: En construction + state/en_instruction: En instruction + state/accepte: Accepté + state/refuse: Refusé + state/sans_suite: Sans suite diff --git a/config/locales/models/procedure/fr.yml b/config/locales/models/procedure/fr.yml index f06c0a673..ce2e28fd7 100644 --- a/config/locales/models/procedure/fr.yml +++ b/config/locales/models/procedure/fr.yml @@ -10,3 +10,7 @@ fr: organisation: Organisme duree_conservation_dossiers_dans_ds: Durée de conservation des dossiers sur demarches-simplifiees.fr duree_conservation_dossiers_hors_ds: Durée de conservation des dossiers hors demarches-simplifiees.fr + aasm_state/brouillon: Brouillon + aasm_state/publiee: Publiée + aasm_state/archivee: Archivée + aasm_state/hidden: Suprimée