diff --git a/lib/MusicBrainz/Server/Controller/Area.pm b/lib/MusicBrainz/Server/Controller/Area.pm index 5debcdaec72..b6fab00374a 100644 --- a/lib/MusicBrainz/Server/Controller/Area.pm +++ b/lib/MusicBrainz/Server/Controller/Area.pm @@ -382,7 +382,6 @@ after [qw( show collections details tags aliases artists events labels releases with 'MusicBrainz::Server::Controller::Role::Create' => { form => 'Area', edit_type => $EDIT_AREA_CREATE, - dialog_template => 'area/edit_form.tt', }; with 'MusicBrainz::Server::Controller::Role::Edit' => { diff --git a/lib/MusicBrainz/Server/Controller/Role/Create.pm b/lib/MusicBrainz/Server/Controller/Role/Create.pm index ac6150f75c7..787318e32ca 100644 --- a/lib/MusicBrainz/Server/Controller/Role/Create.pm +++ b/lib/MusicBrainz/Server/Controller/Role/Create.pm @@ -63,7 +63,7 @@ role { my $entity; my %props; - if ($model eq 'Event' || $model eq 'Genre') { + if ($model eq 'Area' || $model eq 'Event' || $model eq 'Genre') { my $form = $c->form( form => $params->form ); %props = ( form => $form->TO_JSON ); @@ -100,6 +100,14 @@ role { }, pre_validation => sub { my $form = shift; + if ($model eq 'Area') { + my %area_descriptions = map { + $_->id => $_->l_description + } $c->model('AreaType')->get_all(); + + $props{areaTypes} = $form->options_type_id; + $props{areaDescriptions} = \%area_descriptions; + } if ($model eq 'Event') { my %event_descriptions = map { $_->id => $_->l_description diff --git a/lib/MusicBrainz/Server/Controller/Role/Edit.pm b/lib/MusicBrainz/Server/Controller/Role/Edit.pm index 8a9643b3ddc..35633bbc3c8 100644 --- a/lib/MusicBrainz/Server/Controller/Role/Edit.pm +++ b/lib/MusicBrainz/Server/Controller/Role/Edit.pm @@ -35,7 +35,7 @@ role { method 'edit' => sub { my ($self, $c) = @_; - my @react_models = qw( Event Genre); + my @react_models = qw( Area Event Genre ); my $entity_name = $self->{entity_name}; my $edit_entity = $c->stash->{ $entity_name }; my $model = $self->{model}; @@ -70,6 +70,14 @@ role { edit_rels => 1, pre_validation => sub { my $form = shift; + if ($model eq 'Area') { + my %area_descriptions = map { + $_->id => $_->l_description + } $c->model('AreaType')->get_all(); + + $props{areaTypes} = $form->options_type_id; + $props{areaDescriptions} = \%area_descriptions; + } if ($model eq 'Event') { my %event_descriptions = map { $_->id => $_->l_description diff --git a/lib/MusicBrainz/Server/Validation.pm b/lib/MusicBrainz/Server/Validation.pm index b627bb2e6e7..eae2a10e536 100644 --- a/lib/MusicBrainz/Server/Validation.pm +++ b/lib/MusicBrainz/Server/Validation.pm @@ -282,18 +282,21 @@ sub is_valid_setlist my @invalid_lines = grep { $_ !~ /^([@#*] |\s*$)/ } split(/\r\n/, $setlist); return @invalid_lines ? 0 : 1; } +# Converted to JavaScript at root/static/scripts/edit/utility/iso3166.js sub is_valid_iso_3166_1 { my $iso_3166_1 = shift; return $iso_3166_1 =~ /^[A-Z]{2}$/; } +# Converted to JavaScript at root/static/scripts/edit/utility/iso3166.js sub is_valid_iso_3166_2 { my $iso_3166_2 = shift; return $iso_3166_2 =~ /^[A-Z]{2}-[A-Z0-9]+$/; } +# Converted to JavaScript at root/static/scripts/edit/utility/iso3166.js sub is_valid_iso_3166_3 { my $iso_3166_3 = shift; diff --git a/root/area/CreateArea.js b/root/area/CreateArea.js new file mode 100644 index 00000000000..6395d007495 --- /dev/null +++ b/root/area/CreateArea.js @@ -0,0 +1,38 @@ +/* + * @flow strict-local + * Copyright (C) 2025 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 AreaEditForm + from '../static/scripts/area/components/AreaEditForm.js'; + +import type {AreaFormT} from './types.js'; + +component CreateArea( + areaDescriptions: {+[id: string]: string}, + areaTypes: SelectOptionsT, + form: AreaFormT, +) { + return ( + +
+

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

+ +
+ {manifest('area/components/AreaEditForm', {async: true})} + {manifest('relationship-editor', {async: true})} +
+ ); +} + +export default CreateArea; diff --git a/root/area/EditArea.js b/root/area/EditArea.js new file mode 100644 index 00000000000..eb2ceaa84a2 --- /dev/null +++ b/root/area/EditArea.js @@ -0,0 +1,41 @@ +/* + * @flow strict-local + * Copyright (C) 2025 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 AreaEditForm + from '../static/scripts/area/components/AreaEditForm.js'; + +import AreaLayout from './AreaLayout.js'; +import {type AreaFormT} from './types.js'; + +component EditArea( + entity: AreaT, + areaDescriptions: {+[id: string]: string}, + areaTypes: SelectOptionsT, + form: AreaFormT, +) { + return ( + + + {manifest('area/components/AreaEditForm', {async: true})} + {manifest('relationship-editor', {async: true})} + + ); +} + +export default EditArea; diff --git a/root/area/create.tt b/root/area/create.tt deleted file mode 100644 index 2a132c4562e..00000000000 --- a/root/area/create.tt +++ /dev/null @@ -1,6 +0,0 @@ -[%- WRAPPER 'layout.tt' title=lp('Add area', 'header') full_width=1 -%] -
-

[%- lp('Add area', 'header') -%]

- [%- INCLUDE "area/edit_form.tt" -%] -
-[%- END -%] diff --git a/root/area/edit_form.tt b/root/area/edit_form.tt deleted file mode 100644 index b6b379ca80d..00000000000 --- a/root/area/edit_form.tt +++ /dev/null @@ -1,57 +0,0 @@ -
- [%- USE r = FormRenderer(form) -%] - -
-
- [%- 'Area details' -%] - [%- form_row_name_with_guesscase(r) -%] - [%- form_row_text_long(r, 'comment', 'Disambiguation:') -%] - [%- form_row_select(r, 'type_id', 'Type:') -%] - [% React.embed(c, 'static/scripts/edit/components/FormRowTextListSimple', { - 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/FormRowTextListSimple', { - 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/FormRowTextListSimple', { - 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', - }) %] -
- - [% React.embed(c, 'static/scripts/edit/components/HydratedDateRangeFieldset', { - initialField => form_to_json(form.field('period')), - endedLabel => 'This area has ended.', - }) %] - [% script_manifest('edit/components/HydratedDateRangeFieldset', {async => 'async'}) %] - - [% PROCESS 'forms/relationship-editor.tt' %] - -
- [% 'External links' %] - [% external_links_editor() %] -
- - [%- INCLUDE 'forms/edit-note.tt' -%] - [%- enter_edit() -%] -
- -
- [%- type_bubble(form.field('type_id'), area_types) -%] -
- -
- -[%- guesscase_options() -%] -[% script_manifest('area/edit.js', {async => 'async'}) %] diff --git a/root/area/layout.tt b/root/area/layout.tt deleted file mode 100644 index 4ccbe6f41c4..00000000000 --- a/root/area/layout.tt +++ /dev/null @@ -1,11 +0,0 @@ -[%~ WRAPPER "layout.tt" title=title ? area.l_name _ " - ${title}" : area.l_name ~%] - [%- area_json_obj = React.to_json_object(area) -%] -
- [%~ React.embed(c, 'area/AreaHeader', { area => area_json_obj, page => page }) ~%] - [%~ content ~%] -
- - [%~ IF !full_width ~%] - [%~ React.embed(c, 'layout/components/sidebar/AreaSidebar', {area => area_json_obj}) ~%] - [%~ END ~%] -[%~ END ~%] diff --git a/root/area/types.js b/root/area/types.js new file mode 100644 index 00000000000..46be6fea5bb --- /dev/null +++ b/root/area/types.js @@ -0,0 +1,22 @@ +/* + * @flow strict + * Copyright (C) 2025 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 type { + Iso3166VariantSnake, +} from '../static/scripts/edit/utility/iso3166.js'; + +export type AreaFormT = FormT<{ + +comment: FieldT, + +edit_note: FieldT, + +[key: Iso3166VariantSnake]: RepeatableFieldT>, + +make_votable: FieldT, + +name: FieldT, + +period: DatePeriodFieldT, + +type_id: FieldT, +}>; diff --git a/root/server/components.mjs b/root/server/components.mjs index e05a64241c2..e2e0b93614e 100644 --- a/root/server/components.mjs +++ b/root/server/components.mjs @@ -80,7 +80,9 @@ export default { 'area/AreaReleases': (): Promise => import('../area/AreaReleases.js'), 'area/AreaUsers': (): Promise => import('../area/AreaUsers.js'), 'area/AreaWorks': (): Promise => import('../area/AreaWorks.js'), + 'area/CreateArea': (): Promise => import('../area/CreateArea.js'), 'area/DeleteArea': (): Promise => import('../area/DeleteArea.js'), + 'area/EditArea': (): Promise => import('../area/EditArea.js'), 'artist/ArtistEvents': (): Promise => import('../artist/ArtistEvents.js'), 'artist/ArtistIndex': (): Promise => import('../artist/ArtistIndex.js'), 'artist/ArtistMerge': (): Promise => import('../artist/ArtistMerge.js'), diff --git a/root/static/scripts/area/components/AreaEditForm.js b/root/static/scripts/area/components/AreaEditForm.js new file mode 100644 index 00000000000..ca861440d02 --- /dev/null +++ b/root/static/scripts/area/components/AreaEditForm.js @@ -0,0 +1,347 @@ +/* + * @flow strict-local + * Copyright (C) 2025 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 type {CowContext} from 'mutate-cow'; +import mutate from 'mutate-cow'; +import * as React from 'react'; + +import type { + AreaFormT, +} from '../../../../area/types.js'; +import {SanitizedCatalystContext} from '../../../../context.mjs'; +import TypeBubble from '../../common/components/TypeBubble.js'; +import isBlank from '../../common/utility/isBlank.js'; +import DateRangeFieldset, { + type ActionT as DateRangeFieldsetActionT, + runReducer as runDateRangeFieldsetReducer, +} from '../../edit/components/DateRangeFieldset.js'; +import EnterEdit from '../../edit/components/EnterEdit.js'; +import EnterEditNote from '../../edit/components/EnterEditNote.js'; +import FormRowNameWithGuessCase, { + type ActionT as NameActionT, + runReducer as runNameReducer, +} from '../../edit/components/FormRowNameWithGuessCase.js'; +import FormRowSelect from '../../edit/components/FormRowSelect.js'; +import FormRowTextListSimple, { + type ActionT as Iso3166ActionT, + createInitialState as createIso3166State, + runReducer as runIso3166Reducer, +} + from '../../edit/components/FormRowTextListSimple.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 { + type Iso3166Variant, + ISO_3166_VARIANTS, + iso3166VariantSnake, + isValidIso3166, +} from '../../edit/utility/iso3166.js'; +import {applyAllPendingErrors} from '../../edit/utility/subfieldErrors.js'; +import { + NonHydratedRelationshipEditorWrapper as RelationshipEditorWrapper, +} from '../../relationship-editor/components/RelationshipEditorWrapper.js'; + +/* eslint-disable ft-flow/sort-keys */ +type ActionT = + | {+type: 'set-type', +type_id: string} + | {+type: 'show-all-pending-errors'} + | {+type: 'toggle-type-bubble'} + | { + +type: 'update-iso-3166', + +variant: Iso3166Variant, + +action: Iso3166ActionT, + } + | {+type: 'update-date-range', +action: DateRangeFieldsetActionT} + | {+type: 'update-name', +action: NameActionT}; +/* eslint-enable ft-flow/sort-keys */ + +type StateT = { + +form: AreaFormT, + +guessCaseOptions: GuessCaseOptionsStateT, + +isGuessCaseOptionsOpen: boolean, + +showTypeBubble: boolean, +}; + +function createInitialState(form: AreaFormT): StateT { + const formCtx = mutate(form); + + for (const variant of ISO_3166_VARIANTS) { + formCtx + .update('field', iso3166VariantSnake(variant), (iso3166Ctx) => { + iso3166Ctx.set(createIso3166State(iso3166Ctx.read())); + updateIso3166FieldErrors(variant, iso3166Ctx); + }); + } + + return { + form: formCtx.final(), + guessCaseOptions: createGuessCaseOptionsState(), + isGuessCaseOptionsOpen: false, + showTypeBubble: false, + }; +} + +function updateIso3166FieldErrors( + variant: Iso3166Variant, + fieldCtx: CowContext>>, +) { + const innerFieldCtx = fieldCtx.get('field'); + const innerFieldLength = innerFieldCtx.read().length; + for (let i = 0; i < innerFieldLength; i++) { + const subFieldCtx = innerFieldCtx.get(i); + const value = subFieldCtx.get('value').read(); + + if (empty(value) || isValidIso3166(variant, value)) { + subFieldCtx.set('has_errors', false); + subFieldCtx.set('pendingErrors', []); + subFieldCtx.set('errors', []); + } else { + subFieldCtx.set('has_errors', true); + subFieldCtx.set('errors', [ + l(`This is not a valid ${variant} code`), + ]); + } + } +} + +function reducer(state: StateT, action: ActionT): StateT { + const newStateCtx = mutate(state); + const fieldCtx = newStateCtx.get('form', 'field'); + + match (action) { + {type: 'set-type', const type_id} => { + fieldCtx.set('type_id', 'value', type_id); + } + {type: 'show-all-pending-errors'} => { + applyAllPendingErrors(newStateCtx.get('form')); + } + {type: 'toggle-type-bubble'} => { + newStateCtx.set('showTypeBubble', true); + } + {type: 'update-iso-3166', const variant, const action} => { + const fieldName = iso3166VariantSnake(variant); + const iso3166StateCtx = mutate(state.form.field[fieldName]); + + runIso3166Reducer(iso3166StateCtx, action); + + const iso3166State = iso3166StateCtx.read(); + newStateCtx + .update('form', 'field', fieldName, (iso3166FieldCtx) => { + iso3166FieldCtx.set(iso3166State); + updateIso3166FieldErrors(variant, iso3166FieldCtx); + }); + } + {type: 'update-date-range', const action} => { + runDateRangeFieldsetReducer( + newStateCtx.get('form', 'field', 'period'), + action, + ); + } + {type: 'update-name', const action} => { + const nameStateCtx = mutate({ + field: state.form.field.name, + guessCaseOptions: state.guessCaseOptions, + isGuessCaseOptionsOpen: state.isGuessCaseOptionsOpen, + }); + runNameReducer(nameStateCtx, 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); + } + } + return newStateCtx.final(); +} + +component AreaEditForm( + areaDescriptions: {+[id: string]: string}, + areaTypes: SelectOptionsT, + form as initialForm: AreaFormT +) { + const $c = React.useContext(SanitizedCatalystContext); + + const typeOptions = { + grouped: false as const, + options: areaTypes, + }; + + const [state, dispatch] = React.useReducer( + reducer, + createInitialState(initialForm), + ); + + const nameDispatch = React.useCallback((action: NameActionT) => { + dispatch({action, type: 'update-name'}); + }, [dispatch]); + + const setType = React.useCallback(( + event: SyntheticEvent, + ) => { + dispatch({type: 'set-type', type_id: event.currentTarget.value}); + }, [dispatch]); + + const handleTypeFocus = React.useCallback(() => { + dispatch({type: 'toggle-type-bubble'}); + }, [dispatch]); + + const handleIso3166Update = React.useCallback( + (variant: Iso3166Variant) => (action: Iso3166ActionT) => { + dispatch({ + action, + type: 'update-iso-3166', + variant, + }); + }, [dispatch], + ); + + const dispatchDateRange = React.useCallback(( + action: DateRangeFieldsetActionT, + ) => { + dispatch({action, type: 'update-date-range'}); + }, [dispatch]); + + const missingRequired = isBlank(state.form.field.name.value); + + const hasErrors = missingRequired; + + // Ensure errors are shown if the user tries to submit with Enter + const handleKeyDown = (event: SyntheticKeyboardEvent) => { + if (event.key === 'Enter' && hasErrors) { + event.preventDefault(); + } + }; + + const area = $c.stash.source_entity; + invariant(area && area.entityType === 'area'); + + const externalLinksEditorRef = React.createRef<_ExternalLinksEditor>(); + + const handleSubmit = (event: SyntheticEvent) => { + if (hasErrors) { + event.preventDefault(); + } + invariant(externalLinksEditorRef.current); + prepareExternalLinksHtmlFormSubmission( + 'edit-area', + externalLinksEditorRef.current, + ); + }; + + const typeSelectRef = React.useRef(null); + + return ( +
+
+
+ {l('Area details')} + + + + {ISO_3166_VARIANTS.map((variant) => { + const fieldName = iso3166VariantSnake(variant); + + return ( + + ); + })} +
+ + +
+ {l('External links')} + +
+ + + +
+ +
+ {state.showTypeBubble ? ( + + ) : null} +
+
+ ); +} + +export default (hydrate>( + 'div.area-edit-form', + AreaEditForm, +): component(...React.PropsOf)); diff --git a/root/static/scripts/common/components/TypeBubble.js b/root/static/scripts/common/components/TypeBubble.js new file mode 100644 index 00000000000..4f16d002ea1 --- /dev/null +++ b/root/static/scripts/common/components/TypeBubble.js @@ -0,0 +1,59 @@ +/* + * @flow + * Copyright (C) 2025 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 expand2react from '../i18n/expand2react.js'; + +import Bubble from './Bubble.js'; + +component TypeBubble( + controlRef: {+current: HTMLElement | null}, + descriptions: {+[id: string]: string}, + types: SelectOptionsT, + field: FieldT, +) { + return ( + + + {l(`Select any type from the list to see its description. + If no type seems to fit, just leave this blank.`)} + + {types.map((type) => { + const typeId = type.value.toString(); + const selectedType = field.value.toString(); + const description = descriptions[typeId]; + return ( + + {nonEmpty(description) + ? expand2react(description) + : l('No description available.')} + + ); + })} + + ); +} + +export default TypeBubble; diff --git a/root/static/scripts/edit/components/FormRowTextListSimple.js b/root/static/scripts/edit/components/FormRowTextListSimple.js index f474d4230de..319b2cefae5 100644 --- a/root/static/scripts/edit/components/FormRowTextListSimple.js +++ b/root/static/scripts/edit/components/FormRowTextListSimple.js @@ -14,6 +14,7 @@ * which values the user removed (to handle cases like MBS-13969). */ +import type {CowContext} from 'mutate-cow'; import mutate from 'mutate-cow'; import React from 'react'; @@ -27,7 +28,7 @@ import RemoveButton from './RemoveButton.js'; type StateT = RepeatableFieldT>; -type ActionT = +export type ActionT = | {+type: 'add-row'} | {+fieldId: number, +type: 'remove-row'} | {+fieldId: number, +type: 'update-row', +value: string}; @@ -67,7 +68,9 @@ component TextListRow( ); } -const createInitialState = (repeatable: RepeatableFieldT>) => { +export const createInitialState = ( + repeatable: RepeatableFieldT>, +): RepeatableFieldT> => { let newField = {...repeatable}; if (newField.last_index === -1) { newField = mutate(newField).update((fieldCtx) => { @@ -77,8 +80,10 @@ const createInitialState = (repeatable: RepeatableFieldT>) => { return newField; }; -function reducer(state: StateT, action: ActionT): StateT { - const newStateCtx = mutate(state); +export function runReducer( + newStateCtx: CowContext, + action: ActionT, +): void { const fieldCtx = newStateCtx.get('field'); match (action) { @@ -108,27 +113,38 @@ function reducer(state: StateT, action: ActionT): StateT { newStateCtx.set('field', index, 'value', value); } } +} + +function reducer(state: StateT, action: ActionT): StateT { + const newStateCtx = mutate(state); + + runReducer(newStateCtx, action); + return newStateCtx.final(); } component FormRowTextListSimple( addButtonLabel: string, addButtonId: string, + dispatch: ?(ActionT) => void, label: string, removeButtonLabel: string, repeatable: RepeatableFieldT>, required: boolean = false, ) { - const [state, dispatch] = + const [internalState, internalDispatch] = React.useReducer>>( reducer, repeatable, createInitialState, ); + const dispatchFn: (ActionT) => void = dispatch ?? internalDispatch; + const state = dispatch ? repeatable : internalState; + const addRow = React.useCallback(() => { - dispatch({type: 'add-row'}); - }, [dispatch]); + dispatchFn({type: 'add-row'}); + }, [dispatchFn]); return ( <> @@ -137,7 +153,7 @@ component FormRowTextListSimple(
{state.field.map((field) => ( = + ['ISO 3166-1', 'ISO 3166-2', 'ISO 3166-3']; + +export type Iso3166Variant = + | 'ISO 3166-1' + | 'ISO 3166-2' + | 'ISO 3166-3'; + +export type Iso3166VariantSnake = + | 'iso_3166_1' + | 'iso_3166_2' + | 'iso_3166_3'; + +/* + * Get the ISO 3166 variant as a snake_case string + */ +export function iso3166VariantSnake( + variant: Iso3166Variant, +): Iso3166VariantSnake { + return match (variant) { + 'ISO 3166-1' => 'iso_3166_1', + 'ISO 3166-2' => 'iso_3166_2', + 'ISO 3166-3' => 'iso_3166_3', + }; +} + +const iso31661Pattern = /^[A-Z]{2}$/; +const iso31662Pattern = /^[A-Z]{2}-[A-Z0-9]+$/; +const iso31663Pattern = /^[A-Z]{4}$/; + +/* + * Validates whether `value` is a valid ISO 3166 code of the given `variant` + */ +export function isValidIso3166( + variant: Iso3166Variant, + value: string, +): boolean { + return match (variant) { + 'ISO 3166-1' => iso31661Pattern.test(value), + 'ISO 3166-2' => iso31662Pattern.test(value), + 'ISO 3166-3' => iso31663Pattern.test(value), + }; +} diff --git a/root/static/scripts/event/components/EventEditForm.js b/root/static/scripts/event/components/EventEditForm.js index b2b3aec0941..1238dd22b74 100644 --- a/root/static/scripts/event/components/EventEditForm.js +++ b/root/static/scripts/event/components/EventEditForm.js @@ -12,8 +12,7 @@ import * as React from 'react'; import {SanitizedCatalystContext} from '../../../../context.mjs'; import type {EventFormT} from '../../../../event/types.js'; -import Bubble from '../../common/components/Bubble.js'; -import expand2react from '../../common/i18n/expand2react.js'; +import TypeBubble from '../../common/components/TypeBubble.js'; import isBlank from '../../common/utility/isBlank.js'; import DateRangeFieldset, { type ActionT as DateRangeFieldsetActionT, @@ -313,43 +312,14 @@ component EventEditForm(
- {state.showTypeBubble ? ( - - - {l(`Select any type from the list to see its description. - If no type seems to fit, just leave this blank.`)} - - {eventTypes.map((type) => { - const typeId = type.value.toString(); - const selectedType = state.form.field.type_id.value.toString(); - const description = eventDescriptions[typeId]; - return ( - - {nonEmpty(description) - ? expand2react(description) - : l('No description available.')} - - ); - })} - - ) : null} + descriptions={eventDescriptions} + field={state.form.field.type_id} + types={eventTypes} + /> + ) : null}
); diff --git a/t/lib/t/MusicBrainz/Server/Controller/Area/Edit.pm b/t/lib/t/MusicBrainz/Server/Controller/Area/Edit.pm index 1eef00fe496..f2ef972b73f 100644 --- a/t/lib/t/MusicBrainz/Server/Controller/Area/Edit.pm +++ b/t/lib/t/MusicBrainz/Server/Controller/Area/Edit.pm @@ -61,7 +61,10 @@ test 'Editing a (non-ended) area' => sub { id => 5099, name => 'Chicago', }, - new => { name => 'wild onion' }, + new => { + name => 'wild onion', + type_id => undef, + }, old => { name => 'Chicago' }, }, 'The edit contains the right data', diff --git a/webpack/client.config.mjs b/webpack/client.config.mjs index 78de1d7b7a9..f38c7229ce5 100644 --- a/webpack/client.config.mjs +++ b/webpack/client.config.mjs @@ -48,6 +48,7 @@ const entries = [ 'admin/components/SpammerButton', 'annotation/AnnotationHistoryTable', 'alias', + 'area/components/AreaEditForm', 'area/edit', 'area/index', 'area/places-map',