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/Event.pm b/lib/MusicBrainz/Server/Controller/Event.pm index 58dbc6424d6..33570649b89 100644 --- a/lib/MusicBrainz/Server/Controller/Event.pm +++ b/lib/MusicBrainz/Server/Controller/Event.pm @@ -128,7 +128,6 @@ with 'MusicBrainz::Server::Controller::Role::Merge' => { with 'MusicBrainz::Server::Controller::Role::Create' => { form => 'Event', edit_type => $EDIT_EVENT_CREATE, - dialog_template => 'event/edit_form.tt', }; with 'MusicBrainz::Server::Controller::Role::Edit' => { 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..531586c92ac 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/area/edit_form.tt b/root/area/edit_form.tt index af8a57fe99d..c5d4b6e66c0 100644 --- a/root/area/edit_form.tt +++ b/root/area/edit_form.tt @@ -9,9 +9,27 @@ [%- form_row_name_with_guesscase(r) -%] [%- form_row_text_long(r, 'comment', 'Disambiguation:') -%] [%- form_row_select(r, 'type_id', 'Type:') -%] - [%- form_row_text_list(r, 'iso_3166_1', 'ISO 3166-1:', 'ISO 3166-1') -%] - [%- form_row_text_list(r, 'iso_3166_2', 'ISO 3166-2:', 'ISO 3166-2') -%] - [%- form_row_text_list(r, 'iso_3166_3', 'ISO 3166-3:', 'ISO 3166-3') -%] + [% React.embed(c, 'static/scripts/edit/components/FormRowTextList', { + repeatable => form_to_json(form.field('iso_3166_1')), + label => 'ISO 3166-1:', + addButtonId => 'add-iso-3166-1', + addButtonLabel => 'Add ISO 3166-1', + removeButtonLabel => 'Remove ISO 3166-1', + }) %] + [% React.embed(c, 'static/scripts/edit/components/FormRowTextList', { + repeatable => form_to_json(form.field('iso_3166_2')), + label => 'ISO 3166-2:', + addButtonId => 'add-iso-3166-2', + addButtonLabel => 'Add ISO 3166-2', + removeButtonLabel => 'Remove ISO 3166-2', + }) %] + [% React.embed(c, 'static/scripts/edit/components/FormRowTextList', { + repeatable => form_to_json(form.field('iso_3166_3')), + label => 'ISO 3166-3:', + addButtonId => 'add-iso-3166-3', + addButtonLabel => 'Add ISO 3166-3', + removeButtonLabel => 'Remove ISO 3166-3', + }) %] [% date_range_fieldset(r, 'area', 'This area has ended.') %] diff --git a/root/artist/edit_form.tt b/root/artist/edit_form.tt index 3506b6874a8..dabe29f0c35 100644 --- a/root/artist/edit_form.tt +++ b/root/artist/edit_form.tt @@ -26,8 +26,20 @@ [% field_errors(r.form, 'area.name') %] [% END %] - [%- form_row_text_list(r, 'ipi_codes', add_colon(l('IPI codes')), l('IPI code')) -%] - [%- form_row_text_list(r, 'isni_codes', add_colon(l('ISNI codes')), l('ISNI code')) -%] + [% React.embed(c, 'static/scripts/edit/components/FormRowTextList', { + repeatable => form_to_json(form.field('ipi_codes')), + label => add_colon(l('IPI codes')), + addButtonId => 'add-ipi-code', + addButtonLabel => lp('Add IPI code', 'interactive'), + removeButtonLabel => lp('Remove IPI code', 'interactive'), + }) %] + [% React.embed(c, 'static/scripts/edit/components/FormRowTextList', { + repeatable => form_to_json(form.field('isni_codes')), + label => add_colon(l('ISNI codes')), + addButtonId => 'add-isni-code', + addButtonLabel => lp('Add ISNI code', 'interactive'), + removeButtonLabel => lp('Remove ISNI code', 'interactive'), + }) %]
diff --git a/root/components/forms.tt b/root/components/forms.tt index 9fb99031880..a922e599441 100644 --- a/root/components/forms.tt +++ b/root/components/forms.tt @@ -133,39 +133,6 @@ [% END %] [%- END -%] -[%- MACRO form_row_text_list(r, field_name, label, item_name) BLOCK -%] - [% WRAPPER form_row %] - -
- - [% FOR value=r.form.field(field_name).value -%] -
- - -
- [% END -%] -
- -
-
- - - [% field_errors(r.form, field_name) %] - [% END %] -[%- END -%] - [%- MACRO form_row_date(r, field_name, label) BLOCK # Converted to React at root/components/FormRowPartialDate.js -%] [% WRAPPER form_row %] diff --git a/root/label/edit_form.tt b/root/label/edit_form.tt index 5b4b33b0c79..d7fd8d1f165 100644 --- a/root/label/edit_form.tt +++ b/root/label/edit_form.tt @@ -29,8 +29,20 @@ LC- [% r.text('label_code', size => 5, class => "label-code", pattern => "[0-9]*") -%] [%- field_errors(form, 'label_code') -%] [% END %] - [%- form_row_text_list(r, 'ipi_codes', add_colon(l('IPI codes')), l('IPI code')) -%] - [%- form_row_text_list(r, 'isni_codes', add_colon(l('ISNI codes')), l('ISNI code')) -%] + [% React.embed(c, 'static/scripts/edit/components/FormRowTextList', { + repeatable => form_to_json(form.field('ipi_codes')), + label => add_colon(l('IPI codes')), + addButtonId => 'add-ipi-code', + addButtonLabel => lp('Add IPI code', 'interactive'), + removeButtonLabel => lp('Remove IPI code', 'interactive'), + }) %] + [% React.embed(c, 'static/scripts/edit/components/FormRowTextList', { + repeatable => form_to_json(form.field('isni_codes')), + label => add_colon(l('ISNI codes')), + addButtonId => 'add-isni-code', + addButtonLabel => lp('Add ISNI code', 'interactive'), + removeButtonLabel => lp('Remove ISNI code', 'interactive'), + }) %]
[% date_range_fieldset(r, 'label', l('This label has ended.')) %] 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/edit_form.tt b/root/recording/edit_form.tt index a703f5da200..d637a73a042 100644 --- a/root/recording/edit_form.tt +++ b/root/recording/edit_form.tt @@ -42,7 +42,13 @@ [%- END -%] [%- END -%] [%- form_row_checkbox(r, 'video', l('Video')) -%] - [%- form_row_text_list(r, 'isrcs', add_colon(l('ISRCs')), l('ISRC')) -%] + [% React.embed(c, 'static/scripts/edit/components/FormRowTextList', { + repeatable => form_to_json(form.field('isrcs')), + label => add_colon(l('ISRCs')), + addButtonId => 'add-isrc', + addButtonLabel => lp('Add ISRC', 'interactive'), + removeButtonLabel => lp('Remove ISRC', 'interactive'), + }) %] [% PROCESS 'forms/relationship-editor.tt' %] 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 42e79601b16..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'), @@ -530,6 +532,7 @@ export default { 'static/scripts/common/components/WarningIcon': (): Promise => import('../static/scripts/common/components/WarningIcon.js'), 'static/scripts/edit/components/AddIcon': (): Promise => import('../static/scripts/edit/components/AddIcon.js'), 'static/scripts/edit/components/FormRowNameWithGuessCase': (): Promise => import('../static/scripts/edit/components/FormRowNameWithGuessCase.js'), + 'static/scripts/edit/components/FormRowTextList': (): Promise => import('../static/scripts/edit/components/FormRowTextList.js'), 'static/scripts/edit/components/GuessCaseIcon': (): Promise => import('../static/scripts/edit/components/GuessCaseIcon.js'), 'static/scripts/edit/components/InformationIcon': (): Promise => import('../static/scripts/edit/components/InformationIcon.js'), 'static/scripts/recording/RecordingName': (): Promise => import('../static/scripts/recording/RecordingName.js'), diff --git a/root/static/scripts/area/edit.js b/root/static/scripts/area/edit.js index 1f779dd6898..5a7c785ee74 100644 --- a/root/static/scripts/area/edit.js +++ b/root/static/scripts/area/edit.js @@ -7,6 +7,8 @@ * later version: http://www.gnu.org/licenses/gpl-2.0.txt */ +import '../edit/components/FormRowTextList.js'; + import typeBubble from '../edit/typeBubble.js'; const typeIdField = 'select[name=edit-area\\.type_id]'; diff --git a/root/static/scripts/artist/edit.js b/root/static/scripts/artist/edit.js index 6ccf36351f8..fc67cfbad08 100644 --- a/root/static/scripts/artist/edit.js +++ b/root/static/scripts/artist/edit.js @@ -8,6 +8,7 @@ */ import './components/ArtistCreditRenamer.js'; +import '../edit/components/FormRowTextList.js'; import '../relationship-editor/components/RelationshipEditorWrapper.js'; import typeBubble from '../edit/typeBubble.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.js b/root/static/scripts/edit.js index ee090169761..59d17c8b5f8 100644 --- a/root/static/scripts/edit.js +++ b/root/static/scripts/edit.js @@ -21,7 +21,6 @@ require('./edit/MB/Control/Bubble.js'); require('./edit/URLCleanup.js'); require('./edit/MB/edit.js'); require('./edit/MB/reltypeslist.js'); -require('./edit/MB/TextList.js'); require('./edit/check-duplicates.js'); require('./guess-case/MB/Control/GuessCase.js'); diff --git a/root/static/scripts/edit/MB/TextList.js b/root/static/scripts/edit/MB/TextList.js deleted file mode 100644 index 79b6e0e0bac..00000000000 --- a/root/static/scripts/edit/MB/TextList.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (C) 2012 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 $ from 'jquery'; - -import MB from '../../common/MB.js'; - -MB.Form = (MB.Form) ? MB.Form : {}; - -MB.Form.TextList = function (input) { - var template = input + '-template'; - var self = {}; - var $template = $('.' + template.replace(/\./g, '\\.')); - var counter = 0; - - self.removeEvent = function () { - $(this).closest('div.text-list-row').remove(); - }; - - self.init = function (maxIndex) { - counter = maxIndex; - $template - .parent() - .find('div.text-list-row input.value') - .siblings('button.remove-item') - .bind('click.mb', self.removeEvent); - - return self; - }; - - self.add = function (initValue) { - $template.clone() - .removeClass(template) - .insertAfter($template - .parent() - .find('div.text-list-row') - .last()) - .show() - .find('input.value') - .attr('name', input + '.' + counter) - .val(initValue) - .end() - .find('button.remove-item') - .bind('click.mb', self.removeEvent); - - counter++; - - return self; - }; - - $template.parent().find('button.add-item').bind('click.mb', function () { - self.add(''); - }); - - return self; -}; diff --git a/root/static/scripts/edit/components/AddButton.js b/root/static/scripts/edit/components/AddButton.js new file mode 100644 index 00000000000..7b0004455cf --- /dev/null +++ b/root/static/scripts/edit/components/AddButton.js @@ -0,0 +1,38 @@ +/* + * @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 + */ + +component AddButton( + id: string, + onClick: (event: SyntheticEvent) => void, + label?: string, +) { + if (label == null) { + return ( + + ); +} + +export default AddButton; diff --git a/root/static/scripts/edit/components/ArtistCreditEditor.js b/root/static/scripts/edit/components/ArtistCreditEditor.js index bfb11d74a34..ec80d35d14e 100644 --- a/root/static/scripts/edit/components/ArtistCreditEditor.js +++ b/root/static/scripts/edit/components/ArtistCreditEditor.js @@ -444,7 +444,6 @@ function createInitialNamesState( export function createInitialState( initialState: { - +activeUser: ActiveEditorT, +entity: ArtistCreditableT, +formName?: string, /* diff --git a/root/static/scripts/edit/components/ArtistCreditEditor/types.js b/root/static/scripts/edit/components/ArtistCreditEditor/types.js index 5f499b53d8b..144a8243f2c 100644 --- a/root/static/scripts/edit/components/ArtistCreditEditor/types.js +++ b/root/static/scripts/edit/components/ArtistCreditEditor/types.js @@ -29,7 +29,6 @@ export type ArtistCreditNameStateT = { }; export type StateT = { - +activeUser: ActiveEditorT | null, +artistCreditString: string, +changeMatchingTrackArtists?: boolean, +editsPending?: boolean, diff --git a/root/static/scripts/edit/components/FormRow.js b/root/static/scripts/edit/components/FormRow.js index f9c1b3a6240..853e98b86f2 100644 --- a/root/static/scripts/edit/components/FormRow.js +++ b/root/static/scripts/edit/components/FormRow.js @@ -9,6 +9,7 @@ component FormRow( children: React.Node, + className as passedClassname?: string, hasNoLabel: boolean = false, hasNoMargin: boolean = false, rowRef?: {-current: HTMLDivElement | null}, @@ -18,7 +19,8 @@ component FormRow( className={ 'row' + (hasNoLabel ? ' no-label' : '') + - (hasNoMargin ? ' no-margin' : '') + (hasNoMargin ? ' no-margin' : '') + + (nonEmpty(passedClassname) ? ' ' + passedClassname : '') } ref={rowRef} > diff --git a/root/static/scripts/edit/components/FormRowSelectList.js b/root/static/scripts/edit/components/FormRowSelectList.js index 0a2a5f98bf8..19b4b27307f 100644 --- a/root/static/scripts/edit/components/FormRowSelectList.js +++ b/root/static/scripts/edit/components/FormRowSelectList.js @@ -9,6 +9,7 @@ import SelectField from '../../common/components/SelectField.js'; +import AddButton from './AddButton.js'; import FieldErrors from './FieldErrors.js'; import FormRow from './FormRow.js'; @@ -49,14 +50,11 @@ component FormRowSelectList( ))} {hideAddButton ? null : (
- + />
)} diff --git a/root/static/scripts/edit/components/FormRowTextList.js b/root/static/scripts/edit/components/FormRowTextList.js new file mode 100644 index 00000000000..e8bb1e0f77d --- /dev/null +++ b/root/static/scripts/edit/components/FormRowTextList.js @@ -0,0 +1,182 @@ +/* + * @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 + */ + +import mutate from 'mutate-cow'; +import React from 'react'; + +import {pushField} from '../utility/pushField.js'; + +import AddButton from './AddButton.js'; +import FieldErrors from './FieldErrors.js'; +import FormLabel from './FormLabel.js'; +import FormRow from './FormRow.js'; +import RemoveButton from './RemoveButton.js'; + +type StateT = RepeatableFieldT>; + +type ActionT = + | {+type: 'add-row'} + | {+fieldId: number, +type: 'remove-row'} + | {+fieldId: number, +type: 'update-row', +value: string}; + +component TextListRow( + dispatch: (ActionT) => void, + fieldId: number, + name: string, + onFocus?: (event: SyntheticEvent) => void, + removeButtonLabel: string, + value: string, +) { + const removeRow = React.useCallback((): void => { + dispatch({fieldId, type: 'remove-row'}); + }, [dispatch, fieldId]); + + const updateRow = React.useCallback(( + event: SyntheticKeyboardEvent, + ) => { + const value = event.currentTarget.value; + dispatch({fieldId, type: 'update-row', value}); + }, [dispatch, fieldId]); + + return ( +
+ + +
+ ); +} + +const createInitialState = (repeatable: RepeatableFieldT>) => { + let newField = {...repeatable}; + if (newField.last_index === -1) { + newField = mutate(newField).update((fieldCtx) => { + pushField(fieldCtx, ''); + }).final(); + } + return newField; +}; + +function reducer(state: StateT, action: ActionT): StateT { + const newStateCtx = mutate(state); + const fieldCtx = newStateCtx.get('field'); + + switch (action.type) { + case 'add-row': { + newStateCtx.update((fieldCtx) => { + pushField(fieldCtx, ''); + }); + break; + } + case 'remove-row': { + const index = fieldCtx.read().findIndex( + (subfield) => subfield.id === action.fieldId, + ); + + if (fieldCtx.read().length === 1) { + newStateCtx.set('field', index, 'value', ''); + break; + } + + newStateCtx.update('field', (fieldCtx) => { + fieldCtx.write().splice(index, 1); + }); + break; + } + case 'update-row': { + const index = fieldCtx.read().findIndex( + (subfield) => subfield.id === action.fieldId, + ); + + newStateCtx.set('field', index, 'value', action.value); + break; + } + } + return newStateCtx.final(); +} + +component FormRowTextList( + addButtonLabel: string, + addButtonId: string, + label: string, + onFocus?: (event: SyntheticEvent) => void, + removeButtonLabel: string, + repeatable: RepeatableFieldT>, + required: boolean = false, +) { + const [state, dispatch] = + React.useReducer>>( + reducer, + repeatable, + createInitialState, + ); + + const addRow = React.useCallback(() => { + dispatch({type: 'add-row'}); + }, [dispatch]); + + return ( + <> + + +
+ {state.field.map((field) => ( + + ))} + +
+ +
+
+ + + + ); +} + +export component NonHydratedFormRowTextList( + rowRef?: {-current: HTMLDivElement | null}, + ...props: React.PropsOf +) { + return ( + + + + ); +} + +/* + * Hydration must be moved higher up in the component hierarchy once + * more of the page is converted to React. + */ +export default (hydrate>( + 'div.row.form-row-text-list-container', + FormRowTextList, +): React.AbstractComponent, void>); diff --git a/root/static/scripts/edit/components/forms.js b/root/static/scripts/edit/components/forms.js index 12bb1bed499..4d68f6c75f9 100644 --- a/root/static/scripts/edit/components/forms.js +++ b/root/static/scripts/edit/components/forms.js @@ -157,7 +157,6 @@ MB.initializeArtistCredit = function (form, initialArtistCredit) { }); const initialState = createArtistCreditEditorState({ - activeUser: window[GLOBAL_JS_NAMESPACE].$c.user, artistCredit: initialArtistCredit, entity: source, formName: form.name, diff --git a/root/static/scripts/label/edit.js b/root/static/scripts/label/edit.js index bc875ef2aae..e793813c057 100644 --- a/root/static/scripts/label/edit.js +++ b/root/static/scripts/label/edit.js @@ -7,6 +7,8 @@ * later version: http://www.gnu.org/licenses/gpl-2.0.txt */ +import '../edit/components/FormRowTextList.js'; + import typeBubble from '../edit/typeBubble.js'; const typeIdField = 'select[name=edit-label\\.type_id]'; diff --git a/root/static/scripts/recording/components/RecordingEditForm.js b/root/static/scripts/recording/components/RecordingEditForm.js new file mode 100644 index 00000000000..5b427566ff5 --- /dev/null +++ b/root/static/scripts/recording/components/RecordingEditForm.js @@ -0,0 +1,338 @@ +/* + * @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-recording', + externalLinksEditorRef.current, + ); + }; + + const isrcFieldRef = React.useRef(null); + + return ( +
+

+ {exp.l( + 'For more information, check the {doc_doc|documentation}.', + {doc_doc: {href: '/doc/Recording', 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( + nonEmpty(state.form.field.length.value) + ? parseInt(state.form.field.length.value, 10) + : null, + ), + }, + )} + + + )} + + +
+ + + +
+ {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/static/scripts/recording/edit.js b/root/static/scripts/recording/edit.js index a886a01f142..f0ef8d56882 100644 --- a/root/static/scripts/recording/edit.js +++ b/root/static/scripts/recording/edit.js @@ -8,3 +8,4 @@ */ import './RecordingName.js'; +import '../edit/components/FormRowTextList.js'; diff --git a/root/static/scripts/relationship-editor.js b/root/static/scripts/relationship-editor.js index fbd9fc2c2f3..64da631753a 100644 --- a/root/static/scripts/relationship-editor.js +++ b/root/static/scripts/relationship-editor.js @@ -16,7 +16,7 @@ import {createExternalLinksEditorForHtmlForm} from './edit/externalLinks.js'; const sourceData = getSourceEntityDataForRelationshipEditor(); const entityType = sourceData.entityType; -const reactEditors = ['event', 'genre']; +const reactEditors = ['event', 'genre', 'recording']; if ( /* diff --git a/root/static/scripts/release-editor/bindingHandlers.js b/root/static/scripts/release-editor/bindingHandlers.js index ca249bd1ac9..9d995d25bb1 100644 --- a/root/static/scripts/release-editor/bindingHandlers.js +++ b/root/static/scripts/release-editor/bindingHandlers.js @@ -105,7 +105,6 @@ ko.bindingHandlers.artistCreditEditor = { const artistCredit = entity.artistCredit(); const initialState = createArtistCreditEditorState({ - activeUser: window[GLOBAL_JS_NAMESPACE].$c.user, artistCredit, entity, id: entity.uniqueID, diff --git a/root/static/scripts/tests/artist-credit-editor.js b/root/static/scripts/tests/artist-credit-editor.js index 2117cc19bb7..56e57b43b12 100644 --- a/root/static/scripts/tests/artist-credit-editor.js +++ b/root/static/scripts/tests/artist-credit-editor.js @@ -9,22 +9,18 @@ import test from 'tape'; -import activeSanitizedEditor - from '../../../utility/activeSanitizedEditor.mjs'; import { createInitialState, reducer, } from '../edit/components/ArtistCreditEditor.js'; import { - genericEditor, genericRecording, } from './utility/constants.js'; test('MBS-13538: Removing all rows in the AC editor makes it disappear', function (t) { t.plan(2); const state = createInitialState({ - activeUser: activeSanitizedEditor(genericEditor), entity: genericRecording, id: '', }); diff --git a/root/static/scripts/work/edit.js b/root/static/scripts/work/edit.js index 51a90c8d894..adfaa7dba72 100644 --- a/root/static/scripts/work/edit.js +++ b/root/static/scripts/work/edit.js @@ -17,6 +17,8 @@ import {flushSync} from 'react-dom'; import * as ReactDOMClient from 'react-dom/client'; import {legacy_createStore as createStore} from 'redux'; +import '../edit/components/FormRowTextList.js'; + import {LANGUAGE_MUL_ID, LANGUAGE_ZXX_ID} from '../common/constants.js'; import {groupBy} from '../common/utility/arrays.js'; import getScriptArgs from '../common/utility/getScriptArgs.js'; 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/root/work/edit_form.tt b/root/work/edit_form.tt index c98ebfd8a6a..958c24d4421 100644 --- a/root/work/edit_form.tt +++ b/root/work/edit_form.tt @@ -13,7 +13,13 @@ [%- form_row_text_long(r, 'comment', add_colon(l('Disambiguation'))) -%] [%- form_row_select(r, 'type_id', add_colon(l('Type'))) -%]
- [%- form_row_text_list(r, 'iswcs', add_colon(l('ISWCs')), l('ISWC')) -%] + [% React.embed(c, 'static/scripts/edit/components/FormRowTextList', { + repeatable => form_to_json(form.field('iswcs')), + label => add_colon(l('ISWCs')), + addButtonId => 'add-iswc', + addButtonLabel => lp('Add ISWC', 'interactive'), + removeButtonLabel => lp('Remove ISWC', 'interactive'), + }) %]
diff --git a/webpack/client.config.mjs b/webpack/client.config.mjs index 853c851a5d8..d43cb0122eb 100644 --- a/webpack/client.config.mjs +++ b/webpack/client.config.mjs @@ -60,6 +60,7 @@ const entries = [ 'common/components/WorkArtists', 'confirm-seed', 'edit', + 'edit/components/FormRowTextList', 'edit/components/NewNotesAlertCheckbox', 'event/components/EventEditForm', 'event/edit', @@ -74,6 +75,7 @@ const entries = [ 'place/edit', 'place/index', 'place/map', + 'recording/components/RecordingEditForm', 'recording/edit', 'relationship-editor', 'release/coverart',