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 @@
-
-
-[%- 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 (
+
+ );
+}
+
+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',