diff --git a/src/client/components/icons/ChakraIcons.tsx b/src/client/components/icons/ChakraIcons.tsx index 89c31f59..a92f6c89 100644 --- a/src/client/components/icons/ChakraIcons.tsx +++ b/src/client/components/icons/ChakraIcons.tsx @@ -35,6 +35,7 @@ import { LuBraces, LuText, LuCode, + LuPlus } from "react-icons/lu" import { VscDebugRestart } from 'react-icons/vsc'; import { HiMiniStop } from "react-icons/hi2"; @@ -327,4 +328,8 @@ export const TextButton = makeIconButton(LuText); export const CodeIconRaw = LuCode; export const CodeIcon = makeChakraIcon(LuCode); -export const CodeButton = makeIconButton(LuCode); \ No newline at end of file +export const CodeButton = makeIconButton(LuCode); + +export const PlusIconRaw = LuPlus; +export const PlusIcon = makeChakraIcon(LuPlus); +export const PlusButton = makeIconButton(LuPlus); \ No newline at end of file diff --git a/src/client/components/musicServices/MusicServiceEditable.tsx b/src/client/components/musicServices/MusicServiceEditable.tsx new file mode 100644 index 00000000..3023c1a4 --- /dev/null +++ b/src/client/components/musicServices/MusicServiceEditable.tsx @@ -0,0 +1,204 @@ +import { Input, InputGroup, Group, createListCollection, Select, Portal, Field } from "@chakra-ui/react" +import * as z from "zod"; +import { useForm, formOptions } from '@tanstack/react-form'; +import { musicServiceName, type MusicServiceIdBase, type MusicServices } from "../../../core/Atomic" +import { PlusButton } from "../icons/ChakraIcons"; +import { intersect } from "../../../core/DataUtils"; + +// musicServicesSchema accepts an empty id and reports a missing name as a generic union error +// no optional keys because formOptions.looseSchema (2.0.0-alpha.2) types them as required, a blank idType is '' in the form instead +const musicServiceFormSchema = z.object({ + name: z.enum(musicServiceName.options, 'Type is required'), + idType: z.string().transform((x) => x === '' ? undefined : x), + id: z.string().trim().min(1, 'ID is required'), +}); + +type MusicServiceType = Pick; + +type MusicServiceSelectItem = { label: string, value: MusicServiceType, category?: string, useWith?: string[] }; +const musicServiceColl = createListCollection({ + items: [ + { label: "ISRC", value: { name: 'isrc' }, useWith: ['track'] }, + { label: "Spotify", value: { name: 'spotify' } }, + { label: "Rocksky", value: { name: 'rocksky' } }, + { label: "Release", value: { name: 'musicbrainz', idType: 'release' }, category: 'Brainz', useWith: ['album'] }, + { label: "Release Group", value: { name: 'musicbrainz', idType: 'release-group' }, category: 'Brainz', useWith: ['album'] }, + { label: "Recording", value: { name: 'musicbrainz', idType: 'recording' }, category: 'Brainz', useWith: ['track'] }, + { label: "Track", value: { name: 'musicbrainz', idType: 'track' }, category: 'Brainz', useWith: ['track'] }, + { label: "Artist", value: { name: 'musicbrainz', idType: 'artist' }, category: 'Brainz', useWith: ['artist'] }, + ], + itemToString: (item) => item.label, + itemToValue: (item) => `${item.value.name}${item.value.idType ?? ''}` +}); + +const categoryItems = Object.groupBy(musicServiceColl.items, (x) => x.category ?? 'none'); + + +export interface MusicServiceIdTypeSelectProps { + useWith?: string[] + onChange?: (obj: MusicServiceType) => void + value?: MusicServiceType + invalid?: boolean +} + +const MusicServiceIdSelect = (props: MusicServiceIdTypeSelectProps) => { + const { + useWith, + onChange = (e) => console.log(e, 'music service id type changed'), + value, + invalid + } = props; + // always controlled, an empty list is "nothing selected" + const serviceVal = value !== undefined ? [`${value.name}${value.idType ?? ''}`] : []; + + return ( + onChange(e.items[0].value)}> + + + + + + + + + + + + + {(categoryItems.none ?? []).map((x) => { + if (x.useWith !== undefined && useWith !== undefined && intersect(useWith, x.useWith).length === 0) { + return undefined; + } + return ( + + {x.label} + + + ); + })} + {(Object.entries(categoryItems).map(([category, items]) => { + if (category === 'none' || items === undefined || items.length === 0) { + return undefined; + } + let usableItems = items; + if (useWith !== undefined) { + usableItems = items.filter(x => x.useWith === undefined || intersect(x.useWith, useWith).length > 0); + } + if (usableItems.length === 0) { + return undefined; + } + return ( + + {category} + {usableItems.map((item) => ( + + {item.label} + + + ))} + + ); + }))} + + + + + ) +}; + + +export interface MusicServiceIdSelectProps { + useWith?: string[] + onSubmit?: (obj: MusicServices) => void + /** Omit for a new, blank music service */ + value?: MusicServices +} + +const logSubmit: MusicServiceIdSelectProps['onSubmit'] = (val) => console.log(val, 'Music Service Submit'); + +export const MusicServiceInput = (props: MusicServiceIdSelectProps) => { + const { + useWith, + onSubmit = logSubmit, + value + } = props; + + // loose so a blank service (no name/idType yet) is a valid editing state, the schema still has to pass to submit + const opts = formOptions.looseSchema(musicServiceFormSchema, { + defaultValues: { + name: value?.name, + idType: value?.idType ?? '', + id: value?.id ?? '' + }, + validators: [{ + run: musicServiceFormSchema, + triggers: ['change'], + }], + }); + + const form = useForm({ + ...opts, + onSubmit: ({ schemaOutputs }) => { + // idType can only come from the select so it is always valid for the name + onSubmit(schemaOutputs[0] as MusicServices); + form.reset(); + } + }); + + // not a
element because this input is rendered inside other forms (PlayEdit) and forms cannot be nested + const submit = () => form.handleSubmit(); + + return ( + ( + ( + 0} w="full" maxW="sm"> + + state.values.idType} + children={(idType) => ( + 0} + useWith={useWith} + value={nameField.value != null ? { name: nameField.value, idType: idType || undefined } : undefined} + onChange={(val) => { + form.setFieldValue('idType', val.idType ?? ''); + nameField.handleChange(val.name); + }} /> + )} + /> + }> + idField.handleChange(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }} /> + + + + {[...nameField.errors, ...idField.errors].map((error) => ( + + {error.message} + + ))} + + )} + /> + )} + /> + ) +} diff --git a/src/client/index.css b/src/client/index.css index 46608cd6..2170a2c8 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -104,4 +104,17 @@ gap: var(--gap); grid-template-columns: repeat(auto-fill, minmax(min(100%, max(10rem, (100% - (var(--max-columns) - 1) * var(--gap)) / var(--max-columns))), 1fr)); +} + +.chakra-select__itemGroupLabel { + padding-inline: var(--chakra-spacing-2); + padding-block: var(--chakra-spacing-1); + font-size: var(--chakra-font-sizes-xs); + font-weight: var(--chakra-font-weights-semibold); + text-transform: uppercase; + color: var(--chakra-colors-fg-muted); +} + +.chakra-select__itemGroup .chakra-select__item { + padding-inline: var(--chakra-spacing-3) } \ No newline at end of file diff --git a/src/stories/plays/MusicService.stories.tsx b/src/stories/plays/MusicService.stories.tsx new file mode 100644 index 00000000..9441e27b --- /dev/null +++ b/src/stories/plays/MusicService.stories.tsx @@ -0,0 +1,43 @@ +import preview from "../../../.storybook/preview.js"; +import React from 'react'; + +import { PlayEdit } from "../../client/components/play/PlayEdit"; +import { PlayEditable, PlayEditableMutable } from "../../client/components/play/PlayEditable"; +import {Provider} from "../../client/components/Provider.js"; +import { Container } from '@chakra-ui/react'; +import { generateArtistCredits, generateJsonPlay, withBrainz } from "../../core/tests/utils/PlayTestUtils.js" +import { asJsonPlayObject } from '../../core/PlayMarshalUtils.js'; +import type {PlayObjectMinimal} from "../../core/Atomic.js"; +//import { http, HttpResponse, delay } from 'msw'; +import { MusicServiceInput } from "../../client/components/musicServices/MusicServiceEditable"; + +type PropsAndCustomArgs = React.ComponentProps & { +}; +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = preview.type<{args: PropsAndCustomArgs}>().meta({ + title: 'Music Service/Input', + component: MusicServiceInput, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: 'padded', + msw: { + handlers: [ + ], + }, + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], +decorators: [ + (Story) => (), + ], +args: { + }, + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args +}); + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const InputStory = meta.story({ + render: (args) => { + return () + } +}); \ No newline at end of file diff --git a/src/stories/plays/PlayEdit.stories.tsx b/src/stories/plays/PlayEdit.stories copy.tsx similarity index 100% rename from src/stories/plays/PlayEdit.stories.tsx rename to src/stories/plays/PlayEdit.stories copy.tsx