Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import axios from "axios"
const SPOTIFY_CLIENT_ID = "9950ac751e34487dbbe027c4fd7f8e99"
const SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize"
const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
const SPOTIFY_SCOPES = "playlist-read-private playlist-read-collaborative user-library-read"
const SPOTIFY_SCOPES = "playlist-read-private playlist-read-collaborative user-library-read playlist-modify-public playlist-modify-private"

// Access token management
export function loadAccessToken(): string | null {
Expand Down
90 changes: 90 additions & 0 deletions src/components/ImportPlaylistModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import ImportPlaylistModal, { PlaylistImportItem } from "./ImportPlaylistModal"
import "../i18n/config"
import "../icons"

describe("ImportPlaylistModal", () => {
const singleItem: PlaylistImportItem[] = [
{ id: "1", fileName: "Road_Trip.csv", playlistName: "Road Trip", trackUris: ["spotify:track:1", "spotify:track:2"] }
]

const multiItems: PlaylistImportItem[] = [
{ id: "1", fileName: "Road_Trip.csv", playlistName: "Road Trip", trackUris: ["spotify:track:1", "spotify:track:2"] },
{ id: "2", fileName: "Summer_Vibes.csv", playlistName: "Summer Vibes", trackUris: ["spotify:track:3"] },
{ id: "3", fileName: "Empty.csv", playlistName: "Empty", trackUris: [] }
]

it("renders single-file modal layout when 1 item is passed", () => {
render(<ImportPlaylistModal show={true} items={singleItem} onClose={jest.fn()} onConfirm={jest.fn()} />)

const nameInput = screen.getByLabelText(/Playlist Name/i) as HTMLInputElement
expect(nameInput.value).toBe("Road Trip")
expect(screen.getByText(/2 tracks found ready to import/i)).toBeInTheDocument()
})

it("renders multi-file itemized list when multiple items are passed", () => {
render(<ImportPlaylistModal show={true} items={multiItems} onClose={jest.fn()} onConfirm={jest.fn()} />)

expect(screen.getByDisplayValue("Road Trip")).toBeInTheDocument()
expect(screen.getByDisplayValue("Summer Vibes")).toBeInTheDocument()
expect(screen.getByDisplayValue("Empty")).toBeInTheDocument()
expect(screen.getByText(/2 playlists ready to import \(3 tracks total\)/i)).toBeInTheDocument()
})

it("allows editing playlist names and removing items in multi-file mode", () => {
const onConfirmMock = jest.fn()
render(<ImportPlaylistModal show={true} items={multiItems} onClose={jest.fn()} onConfirm={onConfirmMock} />)

const roadTripInput = screen.getByDisplayValue("Road Trip")
fireEvent.change(roadTripInput, { target: { value: "Updated Road Trip" } })

const removeButtons = screen.getAllByRole("button", { name: /Remove/i })
fireEvent.click(removeButtons[0]) // remove first item

const submitBtn = screen.getByRole("button", { name: /Import to Spotify/i })
fireEvent.click(submitBtn)

expect(onConfirmMock).toHaveBeenCalledWith(
[
expect.objectContaining({ playlistName: "Summer Vibes", trackUris: ["spotify:track:3"] })
],
false
)
})

it("toggles public status when checkbox is clicked", () => {
const onConfirmMock = jest.fn()
render(<ImportPlaylistModal show={true} items={singleItem} onClose={jest.fn()} onConfirm={onConfirmMock} />)

const publicCheckbox = screen.getByLabelText(/Make playlist public/i)
fireEvent.click(publicCheckbox)

const submitBtn = screen.getByRole("button", { name: /Import to Spotify/i })
fireEvent.click(submitBtn)

expect(onConfirmMock).toHaveBeenCalledWith(
[
expect.objectContaining({ playlistName: "Road Trip", trackUris: ["spotify:track:1", "spotify:track:2"] })
],
true
)
})

it("handles cancel button click", () => {
const onCloseMock = jest.fn()
render(<ImportPlaylistModal show={true} items={singleItem} onClose={onCloseMock} onConfirm={jest.fn()} />)
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }))
expect(onCloseMock).toHaveBeenCalled()
})

it("disables submit button when no valid playlists with tracks and names exist", () => {
const emptyItems: PlaylistImportItem[] = [
{ id: "1", fileName: "Empty.csv", playlistName: "Empty", trackUris: [] }
]
render(<ImportPlaylistModal show={true} items={emptyItems} onClose={jest.fn()} onConfirm={jest.fn()} />)

const submitBtn = screen.getByRole("button", { name: /Import to Spotify/i })
expect(submitBtn).toBeDisabled()
})
})
188 changes: 188 additions & 0 deletions src/components/ImportPlaylistModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import React, { useState, useEffect } from "react"
import { useTranslation } from "react-i18next"
import { Modal, Button, Form, Alert, Badge, Table } from "react-bootstrap"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"

export interface PlaylistImportItem {
id: string
fileName: string
playlistName: string
trackUris: string[]
}

export interface ImportPlaylistModalProps {
show: boolean
items: PlaylistImportItem[]
onClose: () => void
onConfirm: (validItems: PlaylistImportItem[], isPublic: boolean) => void
}

const EMPTY_ITEMS: PlaylistImportItem[] = []

export const ImportPlaylistModal: React.FC<ImportPlaylistModalProps> = ({
show,
items = EMPTY_ITEMS,
onClose,
onConfirm
}) => {
const { t } = useTranslation()
const [localItems, setLocalItems] = useState<PlaylistImportItem[]>(items)
const [isPublic, setIsPublic] = useState(false)

useEffect(() => {
if (show) {
setLocalItems(items || EMPTY_ITEMS)
setIsPublic(false)
}
}, [items, show])

const handleNameChange = (id: string, newName: string) => {
setLocalItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, playlistName: newName } : item))
)
}

const handleRemoveItem = (id: string) => {
setLocalItems((prev) => prev.filter((item) => item.id !== id))
}

const validItems = (localItems || []).filter(
(item) => item.playlistName.trim().length > 0 && item.trackUris.length > 0
)
const totalTracks = validItems.reduce((acc, item) => acc + item.trackUris.length, 0)
const canSubmit = validItems.length > 0

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
onConfirm(
validItems.map((item) => ({ ...item, playlistName: item.playlistName.trim() })),
isPublic
)
}

const isSingle = (items?.length === 1) && localItems.length === 1

return (
<Modal show={show} onHide={onClose} centered backdrop="static" size={isSingle ? undefined : "lg"}>
<Modal.Header closeButton>
<Modal.Title>{t("import_modal_title")}</Modal.Title>
</Modal.Header>
<Form onSubmit={handleSubmit}>
<Modal.Body>
{isSingle ? (
<>
<Form.Group className="mb-3" controlId="importPlaylistNameInput">
<Form.Label>{t("import_modal_playlist_name")}</Form.Label>
<Form.Control
type="text"
value={localItems[0]?.playlistName || ""}
onChange={(e) => handleNameChange(localItems[0].id, e.target.value)}
placeholder={t("import_modal_playlist_name")}
autoFocus
/>
</Form.Group>

{localItems[0]?.trackUris.length > 0 ? (
<Alert variant="info" className="py-2 mb-3">
{t("import_modal_tracks_found", { count: localItems[0].trackUris.length })}
</Alert>
) : (
<Alert variant="warning" className="py-2 mb-3">
{t("import_modal_no_tracks")}
</Alert>
)}
</>
) : (
<>
<div className="table-responsive mb-3" style={{ maxHeight: "350px", overflowY: "auto" }}>
<Table hover size="sm" className="align-middle">
<thead>
<tr>
<th style={{ width: "55%" }}>{t("import_modal_playlist_name")}</th>
<th style={{ width: "35%" }}>{t("playlist.tracks")}</th>
<th style={{ width: "10%" }} className="text-end"></th>
</tr>
</thead>
<tbody>
{localItems.map((item) => {
const hasTracks = item.trackUris.length > 0
return (
<tr key={item.id}>
<td>
<Form.Control
type="text"
size="sm"
value={item.playlistName}
onChange={(e) => handleNameChange(item.id, e.target.value)}
placeholder={t("import_modal_playlist_name")}
aria-label={t("import_modal_playlist_name")}
/>
</td>
<td>
{hasTracks ? (
<Badge bg="info" className="text-dark">
{item.trackUris.length} {t("playlist.tracks").toLowerCase()}
</Badge>
) : (
<Badge bg="warning" className="text-dark">
{t("import_modal_no_tracks")}
</Badge>
)}
</td>
<td className="text-end">
<Button
variant="outline-danger"
size="sm"
onClick={() => handleRemoveItem(item.id)}
aria-label={t("import_modal_remove")}
className="py-0 px-2"
>
<FontAwesomeIcon icon={["fas", "times"]} />
</Button>
</td>
</tr>
)
})}
</tbody>
</Table>
</div>

{canSubmit ? (
<Alert variant="info" className="py-2 mb-3">
{t("import_modal_batch_summary", {
playlistCount: validItems.length,
trackCount: totalTracks
})}
</Alert>
) : (
<Alert variant="warning" className="py-2 mb-3">
{t("import_modal_no_tracks")}
</Alert>
)}
</>
)}

<Form.Group className="mb-2" controlId="importPlaylistPublicCheckbox">
<Form.Check
type="checkbox"
label={t("import_modal_public")}
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
{t("import_modal_cancel")}
</Button>
<Button variant="primary" type="submit" disabled={!canSubmit}>
{t("import_modal_confirm")}
</Button>
</Modal.Footer>
</Form>
</Modal>
)
}

export default ImportPlaylistModal
Loading