diff --git a/eslint.config.mjs b/eslint.config.mjs index 94f9c59776d..04ef3907f14 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -969,6 +969,7 @@ export default [ 'root/static/scripts/edit/components/UrlRelationshipCreditFieldset.js', 'root/static/scripts/edit/externalLinks.js', 'root/static/scripts/event/components/EventEditForm.js', + 'root/static/scripts/recording/components/RecordingEditForm.js', 'root/static/scripts/relationship-editor/components/DialogPreview.js', ], rules: { diff --git a/lib/MusicBrainz/Server/Controller/Recording.pm b/lib/MusicBrainz/Server/Controller/Recording.pm index 9e07c4a14e8..a59b5d2bd0f 100644 --- a/lib/MusicBrainz/Server/Controller/Recording.pm +++ b/lib/MusicBrainz/Server/Controller/Recording.pm @@ -198,7 +198,6 @@ with 'MusicBrainz::Server::Controller::Role::Create' => { $ret{form_args} = { used_by_tracks => 0 }; return %ret; }, - dialog_template => 'recording/edit_form.tt', }; sub _merge_load_entities { diff --git a/lib/MusicBrainz/Server/Controller/Role/Create.pm b/lib/MusicBrainz/Server/Controller/Role/Create.pm index f1789d88df3..c96e3fa62bc 100644 --- a/lib/MusicBrainz/Server/Controller/Role/Create.pm +++ b/lib/MusicBrainz/Server/Controller/Role/Create.pm @@ -60,10 +60,12 @@ role { my $model = $self->config->{model}; my $entity; my %props; + my %edit_arguments = $params->edit_arguments->($self, $c); - if ($model eq 'Event' || $model eq 'Genre') { + if ($model eq 'Event' || $model eq 'Genre' || $model eq 'Recording') { my $type = model_to_type($model); - my $form = $c->form( form => $params->form ); + my %form_args = %{ $edit_arguments{form_args} || {}}; + my $form = $c->form( form => $params->form, ctx => $c, %form_args ); %props = ( form => $form->TO_JSON ); $c->stash( @@ -98,8 +100,9 @@ role { delete $c->flash->{message}; }, pre_validation => sub { + my $form = shift; + if ($model eq 'Event') { - my $form = shift; my %event_descriptions = map { $_->id => $_->l_description } $c->model('EventType')->get_all(); @@ -107,6 +110,11 @@ role { $props{eventTypes} = $form->options_type_id; $props{eventDescriptions} = \%event_descriptions; } + + if ($model eq 'Recording') { + $props{usedByTracks} = $form->used_by_tracks; + } + }, redirect => sub { $c->response->redirect($c->uri_for_action( @@ -114,7 +122,7 @@ role { }, no_redirect => $args{within_dialog}, edit_rels => 1, - $params->edit_arguments->($self, $c), + %edit_arguments, ); }; }; diff --git a/lib/MusicBrainz/Server/Controller/Role/Edit.pm b/lib/MusicBrainz/Server/Controller/Role/Edit.pm index 62591ffa727..fb08bf73958 100644 --- a/lib/MusicBrainz/Server/Controller/Role/Edit.pm +++ b/lib/MusicBrainz/Server/Controller/Role/Edit.pm @@ -34,18 +34,22 @@ role { method 'edit' => sub { my ($self, $c) = @_; - my @react_models = qw( Event Genre); + my @react_models = qw( Event Genre Recording ); my $entity_name = $self->{entity_name}; my $edit_entity = $c->stash->{ $entity_name }; my $model = $self->{model}; my %props; + my %edit_arguments = $params->edit_arguments->($self, $c, $edit_entity); if (any { $_ eq $model } @react_models) { my $type = model_to_type($model); + my %form_args = %{ $edit_arguments{form_args} || {}}; my $form = $c->form( form => $params->form, + ctx => $c, init_object => $edit_entity, + %form_args, ); %props = ( @@ -69,8 +73,9 @@ role { edit_args => { to_edit => $edit_entity }, edit_rels => 1, pre_validation => sub { + my $form = shift; + if ($model eq 'Event') { - my $form = shift; my %event_descriptions = map { $_->id => $_->l_description } $c->model('EventType')->get_all(); @@ -78,12 +83,16 @@ role { $props{eventTypes} = $form->options_type_id; $props{eventDescriptions} = \%event_descriptions; } + + if ($model eq 'Recording') { + $props{usedByTracks} = $form->used_by_tracks; + } }, redirect => sub { $c->response->redirect( $c->uri_for_action($self->action_for('show'), [ $edit_entity->gid ])); }, - $params->edit_arguments->($self, $c, $edit_entity), + %edit_arguments, ); }; }; diff --git a/root/recording/CreateRecording.js b/root/recording/CreateRecording.js new file mode 100644 index 00000000000..68e71f3a219 --- /dev/null +++ b/root/recording/CreateRecording.js @@ -0,0 +1,30 @@ +/* + * @flow strict-local + * Copyright (C) 2024 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import Layout from '../layout/index.js'; +import manifest from '../static/manifest.mjs'; +import RecordingEditForm + from '../static/scripts/recording/components/RecordingEditForm.js'; + +import type {RecordingFormT} from './types.js'; + +component CreateRecording(form: RecordingFormT, usedByTracks: boolean) { + return ( + +
+

{lp('Add recording', 'header')}

+ +
+ {manifest('recording/components/RecordingEditForm', {async: 'async'})} + {manifest('relationship-editor', {async: 'async'})} +
+ ); +} + +export default CreateRecording; diff --git a/root/recording/EditRecording.js b/root/recording/EditRecording.js new file mode 100644 index 00000000000..6bb6c0daeb8 --- /dev/null +++ b/root/recording/EditRecording.js @@ -0,0 +1,36 @@ +/* + * @flow strict-local + * Copyright (C) 2024 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import manifest from '../static/manifest.mjs'; +import RecordingEditForm + from '../static/scripts/recording/components/RecordingEditForm.js'; + +import RecordingLayout from './RecordingLayout.js'; +import type {RecordingFormT} from './types.js'; + +component EditRecording( + entity: RecordingWithArtistCreditT, + form: RecordingFormT, + usedByTracks: boolean, +) { + return ( + + + {manifest('recording/components/RecordingEditForm', {async: 'async'})} + {manifest('relationship-editor', {async: 'async'})} + + ); +} + +export default EditRecording; diff --git a/root/recording/types.js b/root/recording/types.js new file mode 100644 index 00000000000..1917f80d46e --- /dev/null +++ b/root/recording/types.js @@ -0,0 +1,19 @@ +/* + * @flow strict + * Copyright (C) 2024 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +export type RecordingFormT = FormT<{ + +artist_credit: ArtistCreditFieldT, + +comment: FieldT, + +edit_note: FieldT, + +isrcs: RepeatableFieldT>, + +length: FieldT, + +make_votable: FieldT, + +name: FieldT, + +video: FieldT, + }>; diff --git a/root/server/components.mjs b/root/server/components.mjs index dc18e851636..a3a09f8e1d7 100644 --- a/root/server/components.mjs +++ b/root/server/components.mjs @@ -185,7 +185,9 @@ export default { 'place/PlaceMap': (): Promise => import('../place/PlaceMap.js'), 'place/PlaceMerge': (): Promise => import('../place/PlaceMerge.js'), 'place/PlacePerformances': (): Promise => import('../place/PlacePerformances.js'), + 'recording/CreateRecording': (): Promise => import('../recording/CreateRecording.js'), 'recording/DeleteRecording': (): Promise => import('../recording/DeleteRecording.js'), + 'recording/EditRecording': (): Promise => import('../recording/EditRecording.js'), 'recording/RecordingFingerprints': (): Promise => import('../recording/RecordingFingerprints.js'), 'recording/RecordingIndex': (): Promise => import('../recording/RecordingIndex.js'), 'recording/RecordingMerge': (): Promise => import('../recording/RecordingMerge.js'), diff --git a/root/static/scripts/common/entity2.js b/root/static/scripts/common/entity2.js index 5ad052fc502..5e1573c39f8 100644 --- a/root/static/scripts/common/entity2.js +++ b/root/static/scripts/common/entity2.js @@ -242,6 +242,10 @@ export function createRecordingObject( }>, ): RecordingT { return { + artist: '', + artistCredit: { + names: ([]: $ReadOnlyArray), + }, comment: '', editsPending: false, entityType: 'recording', diff --git a/root/static/scripts/common/utility/catalyst.js b/root/static/scripts/common/utility/catalyst.js index 2e5e726d9c4..f172f0b5b17 100644 --- a/root/static/scripts/common/utility/catalyst.js +++ b/root/static/scripts/common/utility/catalyst.js @@ -30,7 +30,9 @@ export function maybeGetCatalystContext(): ?SanitizedCatalystContextT { return globalThis[GLOBAL_JS_NAMESPACE]?.$c; } -export function getSourceEntityData(): +export function getSourceEntityData( + passedContext?: SanitizedCatalystContextT, +): | RelatableEntityT | { +entityType: RelatableEntityTypeT, @@ -39,12 +41,14 @@ export function getSourceEntityData(): +orderingTypeID?: number, } | null { - const $c = getCatalystContext(); + const $c = passedContext ?? getCatalystContext(); return $c.stash.source_entity ?? null; } -export function getSourceEntityDataForRelationshipEditor(): RelatableEntityT { - let source = getSourceEntityData(); +export function getSourceEntityDataForRelationshipEditor( + $c?: SanitizedCatalystContextT, +): RelatableEntityT { + let source = getSourceEntityData($c); invariant( source, 'Source entity data not found in global Catalyst stash', diff --git a/root/static/scripts/edit/components/FormRowTextList.js b/root/static/scripts/edit/components/FormRowTextList.js index 2b4c2606ca8..e8bb1e0f77d 100644 --- a/root/static/scripts/edit/components/FormRowTextList.js +++ b/root/static/scripts/edit/components/FormRowTextList.js @@ -29,6 +29,7 @@ component TextListRow( dispatch: (ActionT) => void, fieldId: number, name: string, + onFocus?: (event: SyntheticEvent) => void, removeButtonLabel: string, value: string, ) { @@ -49,6 +50,7 @@ component TextListRow( className="value with-button" name={name} onChange={updateRow} + onFocus={onFocus} type="text" value={value} /> @@ -112,6 +114,7 @@ component FormRowTextList( addButtonLabel: string, addButtonId: string, label: string, + onFocus?: (event: SyntheticEvent) => void, removeButtonLabel: string, repeatable: RepeatableFieldT>, required: boolean = false, @@ -138,6 +141,7 @@ component FormRowTextList( fieldId={field.id} key={field.id} name={field.html_name} + onFocus={onFocus} removeButtonLabel={removeButtonLabel} value={field.value} /> @@ -158,10 +162,11 @@ component FormRowTextList( } export component NonHydratedFormRowTextList( + rowRef?: {-current: HTMLDivElement | null}, ...props: React.PropsOf ) { return ( - + ); diff --git a/root/static/scripts/recording/components/RecordingEditForm.js b/root/static/scripts/recording/components/RecordingEditForm.js new file mode 100644 index 00000000000..8d81127910d --- /dev/null +++ b/root/static/scripts/recording/components/RecordingEditForm.js @@ -0,0 +1,333 @@ +/* + * @flow strict-local + * Copyright (C) 2024 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import mutate from 'mutate-cow'; +import * as React from 'react'; + +import {SanitizedCatalystContext} from '../../../../context.mjs'; +import type {RecordingFormT} from '../../../../recording/types.js'; +import Bubble from '../../common/components/Bubble.js'; +import { + getSourceEntityDataForRelationshipEditor, +} from '../../common/utility/catalyst.js'; +import formatTrackLength + from '../../common/utility/formatTrackLength.js'; +import isBlank from '../../common/utility/isBlank.js'; +import ArtistCreditEditor, { + createInitialState as createArtistCreditState, + reducer as runArtistCreditReducer, +} from '../../edit/components/ArtistCreditEditor.js'; +import { + type ActionT as ArtistCreditActionT, + type StateT as ArtistCreditStateT, +} from '../../edit/components/ArtistCreditEditor/types.js'; +import EnterEdit from '../../edit/components/EnterEdit.js'; +import EnterEditNote from '../../edit/components/EnterEditNote.js'; +import FieldErrors from '../../edit/components/FieldErrors.js'; +import FormRow from '../../edit/components/FormRow.js'; +import FormRowCheckbox from '../../edit/components/FormRowCheckbox.js'; +import FormRowNameWithGuessCase, { + type ActionT as NameActionT, + runReducer as runNameReducer, +} from '../../edit/components/FormRowNameWithGuessCase.js'; +import {NonHydratedFormRowTextList as FormRowTextList} + from '../../edit/components/FormRowTextList.js'; +import FormRowTextLong from '../../edit/components/FormRowTextLong.js'; +import { + type StateT as GuessCaseOptionsStateT, + createInitialState as createGuessCaseOptionsState, +} from '../../edit/components/GuessCaseOptions.js'; +import { + _ExternalLinksEditor, + ExternalLinksEditor, + prepareExternalLinksHtmlFormSubmission, +} from '../../edit/externalLinks.js'; +import { + applyAllPendingErrors, + hasSubfieldErrors, +} from '../../edit/utility/subfieldErrors.js'; +import { + NonHydratedRelationshipEditorWrapper as RelationshipEditorWrapper, +} from '../../relationship-editor/components/RelationshipEditorWrapper.js'; + +/* eslint-disable ft-flow/sort-keys */ +type ActionT = + | {+type: 'show-all-pending-errors'} + | {+type: 'toggle-isrc-bubble'} + | {+type: 'update-name', +action: NameActionT} + | {+type: 'update-artist-credit', +action: ArtistCreditActionT}; +/* eslint-enable ft-flow/sort-keys */ + +type StateT = { + +artistCredit: ArtistCreditStateT, + +form: RecordingFormT, + +guessCaseOptions: GuessCaseOptionsStateT, + +isGuessCaseOptionsOpen: boolean, + +recording: RecordingT, + +showIsrcBubble: boolean, +}; + +function createInitialState(form: RecordingFormT, $c: SanitizedCatalystContextT) { + const recording = getSourceEntityDataForRelationshipEditor($c); + invariant(recording && recording.entityType === 'recording'); + + return { + artistCredit: createArtistCreditState({ + entity: recording, + formName: form.name, + id: 'artist-credit-editor', + }), + form, + guessCaseOptions: createGuessCaseOptionsState(), + isGuessCaseOptionsOpen: false, + recording, + showIsrcBubble: false, + }; +} + +function reducer(state: StateT, action: ActionT): StateT { + const newStateCtx = mutate(state); + + switch (action.type) { + case 'update-name': { + const nameStateCtx = mutate({ + field: state.form.field.name, + guessCaseOptions: state.guessCaseOptions, + isGuessCaseOptionsOpen: state.isGuessCaseOptionsOpen, + }); + runNameReducer(nameStateCtx, action.action); + + const nameState = nameStateCtx.read(); + newStateCtx + .update('form', 'field', 'name', (nameFieldCtx) => { + nameFieldCtx.set(nameState.field); + if (isBlank(nameState.field.value)) { + nameFieldCtx.set('has_errors', true); + nameFieldCtx.set('pendingErrors', [ + l('Required field.'), + ]); + } else { + nameFieldCtx.set('has_errors', false); + nameFieldCtx.set('pendingErrors', []); + nameFieldCtx.set('errors', []); + } + }) + .set('guessCaseOptions', nameState.guessCaseOptions) + .set('isGuessCaseOptionsOpen', nameState.isGuessCaseOptionsOpen); + break; + } + case 'toggle-isrc-bubble': { + newStateCtx.set('showIsrcBubble', true); + break; + } + case 'show-all-pending-errors': { + applyAllPendingErrors(newStateCtx.get('form')); + break; + } + case 'update-artist-credit': { + newStateCtx.set( + 'artistCredit', + runArtistCreditReducer(state.artistCredit, action.action), + ); + break; + } + default: { + /*:: exhaustive(action); */ + } + } + return newStateCtx.final(); +} + +component RecordingEditForm( + form as initialForm: RecordingFormT, + usedByTracks: boolean, +) { + const $c = React.useContext(SanitizedCatalystContext); + + const [state, dispatch] = React.useReducer( + reducer, + createInitialState(initialForm, $c), + ); + + const nameDispatch = React.useCallback((action: NameActionT) => { + dispatch({action, type: 'update-name'}); + }, [dispatch]); + + const artistCreditEditorDispatch = React.useCallback(( + action: ArtistCreditActionT, + ) => { + dispatch({action, type: 'update-artist-credit'}); + }, [dispatch]); + + function handleIsrcFocus() { + dispatch({type: 'toggle-isrc-bubble'}); + } + + const hasErrors = hasSubfieldErrors(state.form); + + const externalLinksEditorRef = React.createRef<_ExternalLinksEditor>(); + + // Ensure errors are shown if the user tries to submit with Enter + const handleKeyDown = (event: SyntheticKeyboardEvent) => { + if (event.key === 'Enter' && hasErrors) { + dispatch({type: 'show-all-pending-errors'}); + } + }; + + const handleSubmit = (event: SyntheticEvent) => { + if (hasErrors) { + dispatch({type: 'show-all-pending-errors'}); + event.preventDefault(); + } + invariant(externalLinksEditorRef.current); + prepareExternalLinksHtmlFormSubmission( + 'edit-event', + externalLinksEditorRef.current, + ); + }; + + const isrcFieldRef = React.useRef(null); + + return ( +
+

+ {exp.l( + 'For more information, check the {doc_doc|documentation}.', + {doc_doc: {href: '/doc/Event', target: '_blank'}}, + )} +

+ +
+
+ {l('Recording details')} + + + + + + + + {(!usedByTracks || state.form.field.length.has_errors) ? ( + + ) : ( + + + {exp.l( + `{recording_length} + ({length_info|derived} from the associated track lengths)`, + { + length_info: {href: '/doc/Recording', target: '_blank'}, + recording_length: formatTrackLength( + parseInt(state.form.field.length.value, 10), + ), + }, + )} + + + )} + + +
+ + + +
+ {l('External links')} + +
+ + + +
+ +
+ {state.showIsrcBubble ? ( + +

+ {exp.l(`You are about to add an ISRC to this recording. + The ISRC must be entered in standard + CCXXXYYNNNNN format:`)} +

+
    +
  • + {l(`"CC" is the appropriate for the registrant + two-character country code.`)} +
  • +
  • + {l(`"XXX" is a three character alphanumeric registrant code, + uniquely identifying the organisation + which registered the code.`)} +
  • +
  • + {l(`"YY" is the last two digits + of the year of registration.`)} +
  • +
  • + {l(`"NNNNN" is a unique 5-digit number identifying + the particular sound recording.`)} +
  • +
+
+ ) : null} +
+
+ ); +} + +export default (hydrate>( + 'div.recording-edit-form', + RecordingEditForm, +): React.AbstractComponent>); diff --git a/root/types/formcomponents.js b/root/types/formcomponents.js index 4ccfc3593b2..44108eee631 100644 --- a/root/types/formcomponents.js +++ b/root/types/formcomponents.js @@ -19,6 +19,16 @@ declare type AreaFieldT = CompoundFieldT<{ +name: FieldT, }>; +declare type ArtistCreditFieldT = CompoundFieldT<{ + +names: RepeatableFieldT, +}>; + +declare type ArtistCreditNameFieldT = CompoundFieldT<{ + +artist: FieldT, + +join_phrase: FieldT, + +name: FieldT, +}>; + declare type CompoundFieldT<+F> = { +errors: $ReadOnlyArray, +field: F, diff --git a/webpack/client.config.mjs b/webpack/client.config.mjs index 4760aacc968..d43cb0122eb 100644 --- a/webpack/client.config.mjs +++ b/webpack/client.config.mjs @@ -75,6 +75,7 @@ const entries = [ 'place/edit', 'place/index', 'place/map', + 'recording/components/RecordingEditForm', 'recording/edit', 'relationship-editor', 'release/coverart',