Skip to content

Commit

Permalink
playlists: show in the frontend
Browse files Browse the repository at this point in the history
  • Loading branch information
rr- committed Aug 6, 2023
1 parent b98af14 commit 21442be
Show file tree
Hide file tree
Showing 34 changed files with 913 additions and 78 deletions.
9 changes: 9 additions & 0 deletions backend/trcustoms/common/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from typing import Any

from rest_framework.exceptions import APIException


class CustomAPIException(APIException):
def __init__(self, detail: Any, code: str) -> None:
self.code = code
super().__init__({**detail, "code": code})
8 changes: 6 additions & 2 deletions backend/trcustoms/playlists/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from rest_framework import serializers

from trcustoms.common.errors import CustomAPIException
from trcustoms.levels.models import Level
from trcustoms.levels.serializers import LevelNestedSerializer
from trcustoms.permissions import UserPermission, has_permission
Expand Down Expand Up @@ -46,8 +47,11 @@ def validate(self, data):
.exclude(id=self.instance.id if self.instance else None)
.exists()
):
raise serializers.ValidationError(
{"level_id": "This level already appears in this playlist."}
raise CustomAPIException(
detail={
"level_id": "This level already appears in this playlist.",
},
code="duplicate_level",
)

return validated_data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def test_playlist_item_update_allows_edits_from_staff(
user=UserFactory(username="unique user")
)
resp = staff_api_client.patch(
f"/api/users/{staff_api_client.user.pk}/playlist/{playlist_item.pk}/",
f"/api/users/{playlist_item.user.pk}/playlist/{playlist_item.pk}/",
format="json",
data={},
)
Expand Down
20 changes: 18 additions & 2 deletions backend/trcustoms/playlists/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from rest_framework import mixins, viewsets
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import AllowAny
from rest_framework.response import Response

from trcustoms.mixins import PermissionsMixin
from trcustoms.permissions import (
Expand All @@ -22,12 +25,13 @@ class PlaylistItemViewSet(
):
queryset = PlaylistItem.objects.all().prefetch_related("level", "user")
search_fields = ["level__name"]
ordering_fields = ["level__name", "created", "last_updated"]
ordering_fields = ["level__name", "status", "created", "last_updated"]

permission_classes = [AllowNone]
permission_classes_by_action = {
"retrieve": [AllowAny],
"list": [AllowAny],
"by_level_id": [AllowAny],
"create": [
HasPermission(UserPermission.EDIT_PLAYLISTS)
| IsAccessingOwnResource
Expand All @@ -48,8 +52,20 @@ class PlaylistItemViewSet(

serializer_class = PlaylistItemSerializer

def get_queryset(self):
user_id = self.kwargs["user_id"]
return self.queryset.filter(user_id=user_id)

def get_serializer_context(self) -> dict:
return {
**super().get_serializer_context(),
"user_id": self.kwargs["user_id"],
}

@action(detail=False)
def by_level_id(self, request, user_id: int, level_id: str):
item = get_object_or_404(
self.queryset, user_id=user_id, level_id=level_id
)
serializer = self.get_serializer(item)
return Response(serializer.data, status=status.HTTP_200_OK)
6 changes: 5 additions & 1 deletion backend/trcustoms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
)
from trcustoms.uploads.views import UploadViewSet
from trcustoms.users.views import UserViewSet
from trcustoms.utils.views import as_detail_view, as_list_view
from trcustoms.utils.views import as_detail_view, as_list_view, as_view
from trcustoms.walkthroughs.views import WalkthroughViewSet

router = DefaultRouter()
Expand Down Expand Up @@ -72,6 +72,10 @@
"api/users/<int:user_id>/playlist/<int:pk>/",
as_detail_view(PlaylistItemViewSet),
),
path(
"api/users/<int:user_id>/playlist/by_level_id/<int:level_id>/",
as_view(PlaylistItemViewSet, actions={"get": "by_level_id"}),
),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/swagger/", SpectacularSwaggerView.as_view(url_name="schema")),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema")),
Expand Down
6 changes: 3 additions & 3 deletions backend/trcustoms/utils/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
def _as_view(viewset, actions):
def as_view(viewset, actions):
actual_actions = {
action: method
for action, method in actions.items()
Expand All @@ -8,7 +8,7 @@ def _as_view(viewset, actions):


def as_list_view(viewset):
return _as_view(
return as_view(
viewset,
actions={
"get": "list",
Expand All @@ -18,7 +18,7 @@ def as_list_view(viewset):


def as_detail_view(viewset):
return _as_view(
return as_view(
viewset,
actions={
"get": "retrieve",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { TrophiesPage } from "src/components/pages/TrophiesPage";
import { UserEditPage } from "src/components/pages/UserEditPage";
import { UserListPage } from "src/components/pages/UserListPage";
import { UserPage } from "src/components/pages/UserPage";
import { UserPlaylistPage } from "src/components/pages/UserPlaylistPage";
import { UserWalkthroughsPage } from "src/components/pages/UserWalkthroughsPage";
import { WalkthroughEditPage } from "src/components/pages/WalkthroughEditPage";
import { WalkthroughPage } from "src/components/pages/WalkthroughPage";
Expand Down Expand Up @@ -120,6 +121,10 @@ const App = () => {
path="/users/:userId/walkthroughs"
element={<UserWalkthroughsPage />}
/>
<Route
path="/users/:userId/playlist"
element={<UserPlaylistPage />}
/>
<Route
path="/email-confirmation/:token"
element={<EmailConfirmationPage />}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useContext } from "react";
import { useState } from "react";
import { useQueryClient } from "react-query";
import { useQuery } from "react-query";
import { Button } from "src/components/common/Button";
import { IconBookmark } from "src/components/icons";
import { PlaylistItemModal } from "src/components/modals/PlaylistItemModal";
import { UserContext } from "src/contexts/UserContext";
import type { LevelNested } from "src/services/LevelService";
import type { PlaylistItemDetails } from "src/services/PlaylistService";
import { PlaylistService } from "src/services/PlaylistService";
import { resetQueries } from "src/utils/misc";

interface LevelAddToMyPlaylistButtonProps {
level: LevelNested;
}

const LevelAddToMyPlaylistButton = ({
level,
}: LevelAddToMyPlaylistButtonProps) => {
const { user } = useContext(UserContext);
const queryClient = useQueryClient();
const [isChanged, setIsChanged] = useState(false);
const [isModalActive, setIsModalActive] = useState(false);

const playlistItemResult = useQuery<PlaylistItemDetails, Error>(
["playlists", PlaylistService.get, user?.id, level.id],
async () => PlaylistService.get(user?.id, level.id)
);

const handleButtonClick = () => {
setIsModalActive(true);
};

const handleSubmit = () => {
setIsChanged(true);
};

const handleIsModalActiveChange = (value: boolean) => {
setIsModalActive(value);
if (isChanged) {
resetQueries(queryClient, ["playlists"]);
}
};

if (!user) {
return <></>;
}

if (playlistItemResult.isLoading) {
return <></>;
}

return (
<>
<PlaylistItemModal
isActive={isModalActive}
onIsActiveChange={handleIsModalActiveChange}
user={user}
level={level}
playlistItem={
(!playlistItemResult.error && playlistItemResult.data) || undefined
}
onSubmit={handleSubmit}
/>

<Button icon={<IconBookmark />} onClick={handleButtonClick}>
Add to my playlist
</Button>
</>
);
};

export { LevelAddToMyPlaylistButton };
4 changes: 3 additions & 1 deletion frontend/src/components/common/AutoComplete/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ interface AutoCompleteProps<TItem> {
onSearchTrigger: (textInput: string) => void;
onResultApply: (result: TItem) => void;
onNewResultApply?: ((textInput: string) => void) | undefined;
placeholder?: string;
}

const AutoComplete = <TItem extends Object>({
maxLength,
placeholder,
suggestions,
getResultText,
getResultKey,
Expand Down Expand Up @@ -156,7 +158,7 @@ const AutoComplete = <TItem extends Object>({
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
value={textInput}
placeholder="Start typing to search…"
placeholder={placeholder || "Start typing to search…"}
/>
{showResults && textInput && <AutoCompleteSuggestions />}
{onNewResultApply && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Radioboxes } from "src/components/common/Radioboxes";

interface WalkthroughRadioboxesProps {
videoWalkthroughs: boolean | null;
textWalkthroughs: boolean | null;
videoWalkthroughs: boolean | null | undefined;
textWalkthroughs: boolean | null | undefined;
onChange: (
videoWalkthroughs: boolean | null,
textWalkthroughs: boolean | null
) => any;
}

interface OptionId {
videoWalkthroughs: boolean | null;
textWalkthroughs: boolean | null;
videoWalkthroughs: boolean | null | undefined;
textWalkthroughs: boolean | null | undefined;
}

interface Option {
Expand Down Expand Up @@ -53,9 +53,9 @@ const WalkthroughRadioboxes = ({
},
];

const onChangeInternal = (value: OptionId | null): void => {
const onChangeInternal = (value: OptionId | null | undefined): void => {
if (value) {
onChange(value.videoWalkthroughs, value.textWalkthroughs);
onChange(value.videoWalkthroughs ?? null, value.textWalkthroughs ?? null);
} else {
onChange(null, null);
}
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/components/common/LevelSearchSidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ const LevelSearchSidebar = ({
<div className={styles.section}>
<Collapsible storageKey="levelSearchGenres" title="Genre">
<GenresCheckboxes
value={searchQuery.genres}
value={searchQuery.genres || []}
onChange={handleGenresChange}
/>
</Collapsible>
Expand All @@ -251,7 +251,7 @@ const LevelSearchSidebar = ({
<div className={styles.section}>
<Collapsible storageKey="levelSearchTags" title="Tags">
<TagsCheckboxes
value={searchQuery.tags}
value={searchQuery.tags || []}
onChange={handleTagsChange}
/>
</Collapsible>
Expand All @@ -260,7 +260,7 @@ const LevelSearchSidebar = ({
<div className={styles.section}>
<Collapsible storageKey="levelSearchEngines" title="Engine">
<EnginesCheckboxes
value={searchQuery.engines}
value={searchQuery.engines || []}
onChange={handleEnginesChange}
/>
</Collapsible>
Expand All @@ -278,7 +278,7 @@ const LevelSearchSidebar = ({
<div className={styles.section}>
<Collapsible storageKey="levelSearchRatings" title="Rating">
<RatingsCheckboxes
value={searchQuery.ratings}
value={searchQuery.ratings || []}
onChange={handleRatingsChange}
/>
</Collapsible>
Expand All @@ -287,7 +287,7 @@ const LevelSearchSidebar = ({
<div className={styles.section}>
<Collapsible storageKey="levelSearchDurations" title="Duration">
<DurationsCheckboxes
value={searchQuery.durations}
value={searchQuery.durations || []}
onChange={handleDurationsChange}
/>
</Collapsible>
Expand All @@ -299,7 +299,7 @@ const LevelSearchSidebar = ({
title="Difficulty"
>
<DifficultiesCheckboxes
value={searchQuery.difficulties}
value={searchQuery.difficulties || []}
onChange={handleDifficultiesChange}
/>
</Collapsible>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/common/LevelSidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import styles from "./index.module.css";
import { useContext } from "react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { LevelAddToMyPlaylistButton } from "src/components/buttons/LevelAddToMyPlaylistButton";
import { LevelApproveButton } from "src/components/buttons/LevelApproveButton";
import { LevelDeleteButton } from "src/components/buttons/LevelDeleteButton";
import { LevelRejectButton } from "src/components/buttons/LevelRejectButton";
Expand Down Expand Up @@ -123,6 +124,8 @@ const LevelSidebar = ({ level, reviewCount }: LevelSidebarProps) => {
<LevelDeleteButton level={level} onComplete={handleDelete} />
</PermissionGuard>

{user && <LevelAddToMyPlaylistButton level={level} />}

<Button icon={<IconBook />} onClick={handleWalkthroughsButtonClick}>
Walkthrough
</Button>
Expand Down
Loading

0 comments on commit 21442be

Please sign in to comment.