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 (
+
+
+
+ );
+}
diff --git a/frontend/components/Review/UpdateReviewModal.tsx b/frontend/components/Review/UpdateReviewModal.tsx
new file mode 100644
index 0000000..ef5ea96
--- /dev/null
+++ b/frontend/components/Review/UpdateReviewModal.tsx
@@ -0,0 +1,69 @@
+import { ReviewListItemFragment } from "@/components/Review/ReviewListItem";
+import ReviewModalPresentation from "@/components/Review/ReviewModalPresentation";
+import { FragmentType, useFragment } from "@/graphql/generated";
+import { useMutation } from "@apollo/client";
+import gql from "graphql-tag";
+
+export const updateReviewModalMutation = gql`
+ mutation updateReview($input: UpdateReviewInput!) {
+ updateReview(input: $input) {
+ review {
+ id
+ body
+ rating
+ createdAt
+ profile {
+ id
+ displayName
+ photoUrl
+ }
+ }
+ }
+ }
+`;
+
+type Props = {
+ myReview: FragmentType;
+ isOpen: boolean;
+ setIsOpen: (open: boolean) => void;
+ onSubmitComplete?: () => void;
+};
+
+export default function UpdateReviewModal({
+ myReview: myReviewFragment,
+ isOpen,
+ setIsOpen,
+ onSubmitComplete,
+}: Props) {
+ const myReview = useFragment(ReviewListItemFragment, myReviewFragment);
+ console.log({ myReview });
+ const [updateReview, { error, data }] = useMutation(
+ updateReviewModalMutation
+ );
+
+ const onSubmit = async (newReview: { body: string; rating: number }) => {
+ try {
+ await updateReview({
+ variables: {
+ input: {
+ reviewId: myReview.id,
+ ...newReview,
+ },
+ },
+ });
+ onSubmitComplete && onSubmitComplete();
+ } catch (error) {
+ console.log({ error });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/components/StatButton.tsx b/frontend/components/StatButton.tsx
index fa1d49b..e032d09 100644
--- a/frontend/components/StatButton.tsx
+++ b/frontend/components/StatButton.tsx
@@ -2,20 +2,35 @@ type Props = {
icon?: React.ReactNode;
label: string;
stat: string;
+ isActive: boolean;
onClick?: () => void;
};
-export default function StatButton({ icon, label, stat, onClick }: Props) {
+export default function StatButton({
+ icon,
+ label,
+ stat,
+ isActive,
+ onClick,
+}: Props) {
return (