-
Notifications
You must be signed in to change notification settings - Fork 0
feat(link-wizard): search-assisted candidate selection #338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
1182acc
feat(link-wizard): search-assisted candidate selection UI
claude 0a222f4
fix(link-wizard): address code review defects for #335
claude d76fbe1
feat(link-wizard): show provider search errors in wizard steps
claude bea9951
fix(link-wizard): address Copilot review nits
claude d0b6446
feat(link-wizard): stage description alongside image_url on candidate…
claude 0d74475
refactor(link-wizard): address review feedback
claude 93ecf7c
fix(link-wizard): show raw follower count below 1000
claude fe6abe4
fix(link-wizard): remove autoFocus from custom search input
claude 5bf9413
refactor(link-wizard): extract next-batch prefetch into a custom hook
claude 7af7d37
fix(link-wizard): fix accidental submit, unblock single-field saves, …
claude 8d5a549
refactor(link-wizard): extract CandidateCard component, move LinkWiza…
claude 241cf73
feat(link-wizard): let admins pick which candidate fields to apply, m…
claude 7ebd6b6
refactor(link-wizard): decompose LinkWizardStep into smaller pieces
claude b1b4b35
Build update payload as a variable in LinkWizardStep onSubmit
claude 3399d2d
Stage candidate URLs in a nested providerUrl object, flatten at submit
claude 7462966
Move URL inputs into the Staged panel, always write on submit
claude 7209853
Remove duplicate provider heading in SoundCloud/Spotify candidate cards
claude 1ea31df
Move candidate query into ProviderCandidatesPanel, rename from Provid…
claude 1570779
Move staged fields fully into form state, nest URL fields under provi…
claude b6d4536
Move URL_FIELDS into StagedFieldsPreview
claude b14952d
Remove redundant staged-image caption
claude f7c5e9d
Prefill staged description and image_url from the artist's existing v…
claude 6e31ea0
Type StagedFieldsPreview's form prop as LinkStepData instead of any
claude 2c60313
Fix candidate selection overwriting already-staged image/description
claude 538d6a3
Extract batch artist search into useArtistBatchQuery hook
claude 78a715a
Skip the mutation on Save & Next when nothing changed; retry same cus…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { mergeCandidateSelection } from "./mergeCandidateSelection"; | ||
| import type { Candidate } from "./types"; | ||
|
|
||
| describe("mergeCandidateSelection", () => { | ||
| it("sets providerUrl.spotify when url is selected for spotify", () => { | ||
| const candidate = makeCandidate(); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "spotify", ["url"]); | ||
|
|
||
| expect(update.providerUrl).toEqual({ spotify: candidate.url }); | ||
| }); | ||
|
|
||
| it("sets providerUrl.soundcloud when url is selected for soundcloud", () => { | ||
| const candidate = makeCandidate({ | ||
| url: "https://soundcloud.com/artist", | ||
| }); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "soundcloud", ["url"]); | ||
|
|
||
| expect(update.providerUrl).toEqual({ soundcloud: candidate.url }); | ||
| }); | ||
|
|
||
| it("does not set providerUrl when url is not selected", () => { | ||
| const candidate = makeCandidate(); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "spotify", ["image"]); | ||
|
|
||
| expect(update.providerUrl).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("sets image_url when image is selected", () => { | ||
| const candidate = makeCandidate({ | ||
| imageUrl: "https://example.com/img.jpg", | ||
| }); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "spotify", ["image"]); | ||
|
|
||
| expect(update.image_url).toBe(candidate.imageUrl); | ||
| }); | ||
|
|
||
| it("does not set image_url when the candidate has none", () => { | ||
| const candidate = makeCandidate({ imageUrl: null }); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "spotify", ["image"]); | ||
|
|
||
| expect(update.image_url).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("sets description when description is selected", () => { | ||
| const candidate = makeCandidate({ description: "A great artist." }); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "spotify", [ | ||
| "description", | ||
| ]); | ||
|
|
||
| expect(update.description).toBe(candidate.description); | ||
| }); | ||
|
|
||
| it("does not set description when the candidate has none", () => { | ||
| const candidate = makeCandidate({ description: null }); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "spotify", [ | ||
| "description", | ||
| ]); | ||
|
|
||
| expect(update.description).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("sets all requested fields when multiple are selected", () => { | ||
| const candidate = makeCandidate({ | ||
| imageUrl: "https://example.com/img.jpg", | ||
| description: "A great artist.", | ||
| }); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "spotify", [ | ||
| "url", | ||
| "image", | ||
| "description", | ||
| ]); | ||
|
|
||
| expect(update.providerUrl).toEqual({ spotify: candidate.url }); | ||
| expect(update.image_url).toBe(candidate.imageUrl); | ||
| expect(update.description).toBe(candidate.description); | ||
| }); | ||
|
|
||
| it("never writes genres to updates", () => { | ||
| const candidate = makeCandidate({ genres: ["rock", "pop"] }); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "spotify", [ | ||
| "url", | ||
| "image", | ||
| "description", | ||
| ]); | ||
|
|
||
| expect(update).not.toHaveProperty("genres"); | ||
| }); | ||
|
|
||
| it("does not overwrite an already-staged image_url", () => { | ||
| const candidate = makeCandidate({ | ||
| imageUrl: "https://example.com/other-image.jpg", | ||
| }); | ||
|
|
||
| const update = mergeCandidateSelection(candidate, "spotify", ["image"], { | ||
| image_url: "https://example.com/first-image.jpg", | ||
| }); | ||
|
|
||
| expect(update.image_url).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("does not overwrite an already-staged description", () => { | ||
| const candidate = makeCandidate({ description: "Another bio." }); | ||
|
|
||
| const update = mergeCandidateSelection( | ||
| candidate, | ||
| "spotify", | ||
| ["description"], | ||
| { description: "First staged bio." }, | ||
| ); | ||
|
|
||
| expect(update.description).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("still sets providerUrl even when other fields are already staged", () => { | ||
| const candidate = makeCandidate(); | ||
|
|
||
| const update = mergeCandidateSelection( | ||
| candidate, | ||
| "spotify", | ||
| ["url", "image", "description"], | ||
| { | ||
| image_url: "https://example.com/first-image.jpg", | ||
| description: "First staged bio.", | ||
| }, | ||
| ); | ||
|
|
||
| expect(update.providerUrl).toEqual({ spotify: candidate.url }); | ||
| }); | ||
| }); | ||
|
|
||
| function makeCandidate(overrides: Partial<Candidate> = {}): Candidate { | ||
| return { | ||
| name: "Test Artist", | ||
| url: "https://spotify.com/artist/123", | ||
| imageUrl: "https://example.com/image.jpg", | ||
| description: "A test artist bio.", | ||
| followers: 1000, | ||
| genres: ["rock", "pop"], | ||
| ...overrides, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import type { Candidate, Provider } from "./types"; | ||
|
|
||
| export interface CandidateUpdate { | ||
| providerUrl?: Partial<Record<Provider, string>>; | ||
| image_url?: string | null; | ||
| description?: string | null; | ||
| } | ||
|
|
||
| export type SelectableField = "url" | "image" | "description"; | ||
|
|
||
| export interface StagedValues { | ||
| image_url?: string | null | undefined; | ||
| description?: string | null | undefined; | ||
| } | ||
|
|
||
| export function mergeCandidateSelection( | ||
| candidate: Candidate, | ||
| provider: Provider, | ||
| fields: SelectableField[], | ||
| staged: StagedValues = {}, | ||
| ): CandidateUpdate { | ||
|
chiptus marked this conversation as resolved.
|
||
| const updates: CandidateUpdate = {}; | ||
|
|
||
| if (fields.includes("url")) { | ||
| updates.providerUrl = { [provider]: candidate.url }; | ||
| } | ||
|
|
||
| if (fields.includes("image") && candidate.imageUrl && !staged.image_url) { | ||
| updates.image_url = candidate.imageUrl; | ||
| } | ||
|
|
||
| if ( | ||
| fields.includes("description") && | ||
| candidate.description && | ||
| !staged.description | ||
| ) { | ||
| updates.description = candidate.description; | ||
| } | ||
|
|
||
| return updates; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { z } from "zod"; | ||
|
|
||
| export const providerSchema = z.enum(["soundcloud", "spotify"]); | ||
| export type Provider = z.infer<typeof providerSchema>; | ||
|
|
||
| export const candidateSchema = z.object({ | ||
| name: z.string(), | ||
| url: z.string(), | ||
| imageUrl: z.string().nullable(), | ||
| description: z.string().nullable(), | ||
| followers: z.number().nullable(), | ||
| genres: z.array(z.string()), | ||
| }); | ||
| export type Candidate = z.infer<typeof candidateSchema>; | ||
|
|
||
| export const searchResultSchema = z.object({ | ||
| artistName: z.string(), | ||
| provider: providerSchema, | ||
| candidates: z.array(candidateSchema), | ||
| error: z.string().optional(), | ||
| }); | ||
| export type SearchResult = z.infer<typeof searchResultSchema>; | ||
|
|
||
| export const searchResponseSchema = z.object({ | ||
| results: z.array(searchResultSchema), | ||
| }); | ||
| export type SearchResponse = z.infer<typeof searchResponseSchema>; | ||
|
|
||
| export const artistSearchKeys = { | ||
| all: ["artistSearch"] as const, | ||
| searches: () => [...artistSearchKeys.all, "search"] as const, | ||
| search: (artistNames: string[], provider?: Provider) => | ||
| [...artistSearchKeys.searches(), { artistNames, provider }] as const, | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { useEffect } from "react"; | ||
| import { useQueryClient } from "@tanstack/react-query"; | ||
| import type { Artist } from "@/api/artists/types"; | ||
| import { searchArtistLinksQuery } from "./useSearchArtistLinksQuery"; | ||
|
|
||
| export function usePrefetchNextBatchLinks( | ||
| artists: Artist[], | ||
| currentArtistId: string | undefined, | ||
| ) { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| useEffect(() => { | ||
| if (!currentArtistId || artists.length === 0) return; | ||
|
|
||
| const currentIdx = Math.max( | ||
| 0, | ||
| artists.findIndex((a) => a.id === currentArtistId), | ||
| ); | ||
| const positionInBatch = currentIdx % 10; | ||
|
|
||
| if (positionInBatch < 8) return; | ||
|
|
||
| const nextBatchStart = Math.floor(currentIdx / 10) * 10 + 10; | ||
| const nextBatchArtists = artists | ||
| .slice(nextBatchStart, nextBatchStart + 10) | ||
| .map((a) => a.name); | ||
|
|
||
| if (nextBatchArtists.length > 0) { | ||
| queryClient.prefetchQuery(searchArtistLinksQuery(nextBatchArtists)); | ||
| } | ||
| }, [currentArtistId, artists, queryClient]); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { queryOptions, useQuery } from "@tanstack/react-query"; | ||
| import { supabase } from "@/integrations/supabase/client"; | ||
| import { | ||
| artistSearchKeys, | ||
| searchResponseSchema, | ||
| type SearchResponse, | ||
| type Provider, | ||
| } from "./types"; | ||
|
|
||
| async function fetchSearchArtistLinks( | ||
| artistNames: string[], | ||
| provider?: Provider, | ||
| ): Promise<SearchResponse> { | ||
| if (artistNames.length === 0) { | ||
| return { results: [] }; | ||
| } | ||
|
|
||
| const { data, error } = await supabase.functions.invoke( | ||
| "search-artist-links", | ||
| { | ||
| body: { | ||
| artistNames, | ||
| provider, | ||
| }, | ||
| }, | ||
| ); | ||
|
|
||
| if (error) { | ||
| console.error("Error searching artist links:", error); | ||
| throw new Error("Failed to search artist links"); | ||
| } | ||
|
|
||
| return searchResponseSchema.parse(data); | ||
| } | ||
|
|
||
| export function searchArtistLinksQuery( | ||
| artistNames: string[], | ||
| provider?: Provider, | ||
| ) { | ||
| return queryOptions({ | ||
| queryKey: artistSearchKeys.search(artistNames, provider), | ||
| queryFn: () => fetchSearchArtistLinks(artistNames, provider), | ||
| enabled: artistNames.length > 0, | ||
| staleTime: 1000 * 60 * 30, | ||
| }); | ||
| } | ||
|
|
||
| export function useSearchArtistLinksQuery( | ||
| artistNames: string[], | ||
| provider?: Provider, | ||
| ) { | ||
| return useQuery({ | ||
| ...searchArtistLinksQuery(artistNames, provider), | ||
| }); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.