diff --git a/README.md b/README.md index 8efd562..703c6ec 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ https://docs.gitguardian.com/ggshield-docs/integrations/git-hooks/pre-commit ```bash $ cd backend -$ rails graphql:schema:dump +$ rails graphql:schema:idl ``` ※ /backendに生成されるが、ルートに移動させないとfrontend側で認識されないので注意 ### graphqlのコード自動生成 diff --git a/backend/Gemfile b/backend/Gemfile index e15b832..68f14e3 100644 --- a/backend/Gemfile +++ b/backend/Gemfile @@ -12,6 +12,7 @@ gem 'pg' gem 'puma', '~> 5.0' gem 'rack-cors' gem 'rails', '~> 7.0.4', '>= 7.0.4.3' +# gem 'rspec-rails' #あとで書く!!! gem 'sass-rails' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] diff --git a/backend/app/controllers/graphql_controller.rb b/backend/app/controllers/graphql_controller.rb index f22acd1..feefeab 100644 --- a/backend/app/controllers/graphql_controller.rb +++ b/backend/app/controllers/graphql_controller.rb @@ -15,11 +15,11 @@ def execute context = { current_user: } result = BackendSchema.execute(query, variables:, context:, operation_name:) render json: result - # rescue StandardError => e - # Rails.logger.error e.message - # raise e unless Rails.env.development? + rescue StandardError => e + Rails.logger.error e.message + raise e unless Rails.env.development? - # handle_error_in_development(e) + handle_error_in_development(e) end private @@ -35,7 +35,6 @@ def authenticate_user # リクエストヘッダーからjwtを取得 token = request.headers['Authorization'].split(' ').last - # TODO: あとでmiddlewareでキャッシュする実装にする jwks = JwtKeyFetcher.fetch_key # jwtを検証 diff --git a/backend/app/graphql/mutations/delete_review.rb b/backend/app/graphql/mutations/delete_review.rb new file mode 100644 index 0000000..90c089b --- /dev/null +++ b/backend/app/graphql/mutations/delete_review.rb @@ -0,0 +1,24 @@ +class Mutations::DeleteReview < Mutations::BaseMutation + null true + argument :review_id, ID, required: true, loads: Types::ReviewType + + field :review, Types::ReviewType, null: true + field :errors, [String], null: false + + def resolve(review:) + review.destroy! + return { review: review, errors: [] } + rescue ActiveRecord::RecordInvalid => e + raise GraphQL::ExecutionError.new(e.full_messages, extensions: { "code" => ""}) + end + + def ready?(**args) + if context[:current_user].present? + super + elsif context[:current_user].profile == args[:review].profile + raise GraphQL::ExecutionError.new('レビューを編集する権限がありません', extensions: { "code" => "UNAUTHORIZED" }) + else + raise GraphQL::ExecutionError.new('ログインしてください', extensions: { "code" => "UNAUTHORIZED" }) + end + end +end diff --git a/backend/app/graphql/mutations/post_review.rb b/backend/app/graphql/mutations/post_review.rb new file mode 100644 index 0000000..7365278 --- /dev/null +++ b/backend/app/graphql/mutations/post_review.rb @@ -0,0 +1,26 @@ +class Mutations::PostReview < Mutations::BaseMutation + null true + argument :game_id, ID, required: true, loads: Types::GameType + argument :rating, Float, required: true + argument :body, String, required: false + + field :review, Types::ReviewType, null: true + field :errors, [String], null: false + + def resolve(game:, rating:, body:) + review = Review.new(game: game, rating:, body:, profile: context[:current_user].profile) + if review.save + { review: review, errors: [] } + else + { review: nil, errors: review.errors.full_messages } + end + end + + def ready?(**args) + if context[:current_user].present? + super + else + raise GraphQL::ExecutionError.new('ログインしてください', extensions: { "code" => "UNAUTHORIZED" }) + end + end +end diff --git a/backend/app/graphql/mutations/update_review.rb b/backend/app/graphql/mutations/update_review.rb new file mode 100644 index 0000000..5e9c272 --- /dev/null +++ b/backend/app/graphql/mutations/update_review.rb @@ -0,0 +1,26 @@ +class Mutations::UpdateReview < Mutations::BaseMutation + null true + argument :review_id, ID, required: true, loads: Types::ReviewType + argument :rating, Float, required: true + argument :body, String, required: false + + field :review, Types::ReviewType, null: true + field :errors, [String], null: false + + def resolve(review:, rating:, body:) + review.update!(rating: rating, body: body) + return { review: review, errors: [] } + rescue ActiveRecord::RecordInvalid => e + raise GraphQL::ExecutionError.new(e.full_messages, extensions: { "code" => "" }) + end + + def ready?(**args) + if context[:current_user].present? + super + elsif context[:current_user].profile == args[:review].profile + raise GraphQL::ExecutionError.new('レビューを編集する権限がありません', extensions: { "code" => "UNAUTHORIZED" }) + else + raise GraphQL::ExecutionError.new('ログインしてください', extensions: { "code" => "UNAUTHORIZED" }) + end + end +end diff --git a/backend/app/graphql/types/game_type.rb b/backend/app/graphql/types/game_type.rb index 000b80e..0d0d827 100644 --- a/backend/app/graphql/types/game_type.rb +++ b/backend/app/graphql/types/game_type.rb @@ -20,7 +20,9 @@ class GameType < Types::BaseObject field :rating_average, Float, null: false, description: 'レビューの平均評価' field :reviews, [Types::ReviewType], null: false + field :my_review, Types::ReviewType, null: true, description: '自分のレビュー' field :clips, [Types::ClipType], null: false + field :is_clipped, Boolean, null: false, description: 'クリップしたかどうか' field :platforms, [Types::PlatformType], null: false field :genres, [Types::GenreType], null: false field :publisher, Types::PublisherType, null: false @@ -29,10 +31,22 @@ def reviews dataloader.with(Sources::BatchedAssociationsByForeignKey, Review, :game_id).load(object.id) end + def my_review + dataloader.with(Sources::BatchedAssociationsByForeignKey, Review, :game_id).load(object.id).then do |reviews| + reviews.find { |review| review.profile_id == context[:current_user]&.id } + end + end + def clips dataloader.with(Sources::BatchedAssociationsByForeignKey, Clip, :game_id).load(object.id) end + def is_clipped + dataloader.with(Sources::BatchedAssociationsByForeignKey, Clip, :game_id).load(object.id).then do |clips| + clips.any? { |clip| clip.profile_id == context[:current_user]&.id } + end + end + def platforms dataloader.with(Sources::BatchedAssociationsByManyToMany, Platform, :games_platforms, :game_id, :platform_id).load(object.id) diff --git a/backend/app/graphql/types/mutation_type.rb b/backend/app/graphql/types/mutation_type.rb index fa83106..230ee3f 100644 --- a/backend/app/graphql/types/mutation_type.rb +++ b/backend/app/graphql/types/mutation_type.rb @@ -1,10 +1,7 @@ module Types class MutationType < Types::BaseObject - # TODO: remove me - field :test_field, String, null: false, - description: 'An example field added by the generator' - def test_field - 'Hello World' - end + field :post_review, mutation: Mutations::PostReview + field :update_review, mutation: Mutations::UpdateReview + field :delete_review, mutation: Mutations::DeleteReview end end diff --git a/backend/app/graphql/types/query_type.rb b/backend/app/graphql/types/query_type.rb index 8ed6aca..b80303d 100644 --- a/backend/app/graphql/types/query_type.rb +++ b/backend/app/graphql/types/query_type.rb @@ -63,6 +63,15 @@ def review(id:) Review.find(id) end + field :my_review, Types::ReviewType, null: true do + description 'Find a review by game and profile(from current_user)' + argument :game_id, ID, required: true, loads: Types::GameType + end + + def my_review(game:) + Review.find_by(game:, profile_id: context[:current_user]&.id) + end + field :reviews, Types::ReviewType.connection_type, null: false do description 'Fetch all reviews. filtered by game or profile' argument :game_id, ID, required: false diff --git a/backend/app/graphql/types/review_type.rb b/backend/app/graphql/types/review_type.rb index 23ab51e..6c81c80 100644 --- a/backend/app/graphql/types/review_type.rb +++ b/backend/app/graphql/types/review_type.rb @@ -3,7 +3,6 @@ module Types class ReviewType < Types::BaseObject implements GraphQL::Types::Relay::Node - field :id, ID, null: false field :rating, Float, null: false field :body, String, null: false field :game, Types::GameType, null: false diff --git a/frontend/codegen.ts b/frontend/codegen.ts index 7cfa0e4..48a65b8 100644 --- a/frontend/codegen.ts +++ b/frontend/codegen.ts @@ -2,7 +2,7 @@ import type { CodegenConfig } from "@graphql-codegen/cli"; const config: CodegenConfig = { schema: "../schema.graphql", - documents: "./**/*.ts(x)", + documents: "./**/*.tsx", generates: { "./graphql/generated/": { preset: "client", diff --git a/frontend/components/Review/PostReviewModal.tsx b/frontend/components/Review/PostReviewModal.tsx new file mode 100644 index 0000000..31e7680 --- /dev/null +++ b/frontend/components/Review/PostReviewModal.tsx @@ -0,0 +1,63 @@ +import ReviewModalPresentation from "@/components/Review/ReviewModalPresentation"; +import { PostReviewDocument } from "@/graphql/generated/graphql"; +import { useMutation } from "@apollo/client"; +import gql from "graphql-tag"; + +export const postReviewModalMutation = gql` + mutation postReview($input: PostReviewInput!) { + postReview(input: $input) { + review { + id + body + rating + createdAt + profile { + id + displayName + photoUrl + } + } + } + } +`; + +export default function PostReviewModal({ + gameId, + isOpen, + setIsOpen, + onSubmitComplete, +}: { + gameId: string; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + onSubmitComplete?: () => void; +}) { + const [postReview, { error, data }] = useMutation(PostReviewDocument); + + const onSubmit = async (review: { body: string; rating: number }) => { + try { + await postReview({ + variables: { + input: { + gameId: gameId, + body: review.body, + rating: review.rating, + }, + }, + }); + onSubmitComplete && onSubmitComplete(); + } catch (error) { + console.log({ error }); + } + }; + + return ( + + ); +} diff --git a/frontend/components/Review/ReviewListItem.tsx b/frontend/components/Review/ReviewListItem.tsx index 2e2c95f..bda3ee4 100644 --- a/frontend/components/Review/ReviewListItem.tsx +++ b/frontend/components/Review/ReviewListItem.tsx @@ -41,9 +41,9 @@ export function ReviewListItem(props: Props) {
-

{review.body}

+

{review.body}

- 投稿日:{format(new Date(review.createdAt), "yyyy-MM-dd")} + 投稿日:{format(new Date(review.createdAt), "yyyy/MM/dd")}
diff --git a/frontend/components/Review/ReviewModalPresentation.tsx b/frontend/components/Review/ReviewModalPresentation.tsx new file mode 100644 index 0000000..3286d51 --- /dev/null +++ b/frontend/components/Review/ReviewModalPresentation.tsx @@ -0,0 +1,118 @@ +import { Dialog, Transition } from "@headlessui/react"; +import { Fragment, useState } from "react"; + +type Props = { + body: string | undefined; + rating: number | undefined; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + onSubmit: ({ body, rating }: { body: string; rating: number }) => void; +}; +export default function ReviewModalPresentation({ + body = "", + rating = 3, + isOpen, + setIsOpen, + onSubmit, +}: Props) { + const [reviewBody, setReviewBody] = useState(body); + const [reviewRating, setReviewRating] = useState(rating); + const handleBodyChange = (e: React.ChangeEvent) => { + setReviewBody(e.target.value); + }; + const handleRatingChange = (e: React.ChangeEvent) => { + setReviewRating(Number(e.target.value)); + }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + onSubmit({ body: reviewBody, rating: reviewRating }); + setIsOpen(false); + }; + return ( + + setIsOpen(false)} + > + +
+ + +
+
+ + +
+ +

+ レビューを投稿する +

+
+
+ + +
+ +