diff --git a/root/components/forms.tt b/root/components/forms.tt
index 974f4bf9c7b..87611b156c2 100644
--- a/root/components/forms.tt
+++ b/root/components/forms.tt
@@ -153,7 +153,8 @@
[% END %]
[%- END -%]
-[%- MACRO form_row_text_list(r, field_name, label, item_name) BLOCK -%]
+[%- MACRO form_row_text_list(r, field_name, label, item_name) BLOCK # Converted to React at root/static/scripts/edit/components/FormTowTextList.js
+-%]
[% WRAPPER form_row %]
diff --git a/root/static/scripts/edit/components/AddButton.js b/root/static/scripts/edit/components/AddButton.js
new file mode 100644
index 00000000000..cdef659bdee
--- /dev/null
+++ b/root/static/scripts/edit/components/AddButton.js
@@ -0,0 +1,25 @@
+/*
+ * @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(
+ onClick: (event: SyntheticEvent
) => void,
+ label?: string,
+) {
+ if (label == null) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+export default AddButton;
diff --git a/root/static/scripts/edit/components/FormRowTextList.js b/root/static/scripts/edit/components/FormRowTextList.js
new file mode 100644
index 00000000000..b9be1b56ed0
--- /dev/null
+++ b/root/static/scripts/edit/components/FormRowTextList.js
@@ -0,0 +1,139 @@
+/*
+ * @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 React, {useState} from 'react';
+
+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 TextListRowProps = {
+ +index?: number,
+ +name: string,
+ +onChange?: (event: SyntheticEvent) => void,
+ +onRemove?: (event: SyntheticEvent) => void,
+ +template?: boolean,
+ +value?: string,
+};
+
+component TextListRow(...{
+ index = 0,
+ name,
+ onChange = () => {},
+ onRemove = () => {},
+ template = false,
+ value = '',
+}: TextListRowProps) {
+ if (template) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+const initialRows = (repeatable: RepeatableFieldT>) => {
+ if (repeatable.field.length === 0) {
+ return [{name: repeatable.html_name + '.0', value: ''}];
+ }
+
+ return repeatable.field.map((field, index) => ({
+ name: repeatable.html_name + '.' + index,
+ value: field.value ?? '',
+ }));
+};
+
+component FormRowTextList(
+ repeatable: RepeatableFieldT>,
+ label: string,
+ itemName: string,
+ required: boolean = false,
+) {
+ const newRow = (name: string, value: string, index: number) => {
+ return {name: name + '.' + index, value};
+ };
+
+ const [rows, setRows] = useState(initialRows(repeatable));
+
+ const add = () => {
+ const index = rows.length;
+
+ setRows([...rows, newRow(repeatable.html_name, '', index)]);
+ };
+
+ const change = (index: number, value: string) => {
+ const newRows = [...rows];
+ newRows[index] = newRow(repeatable.html_name, value, index);
+ setRows(newRows);
+ };
+
+ const removeRow = (index: number) => {
+ if (rows.length === 1) {
+ setRows([newRow(repeatable.html_name, '', 0)]);
+ return;
+ }
+
+ setRows(rows.filter((_, i) => i !== index));
+ };
+
+ return (
+
+
+
+
+
+
+ {rows.map((field, index) => (
+
change(index, event.currentTarget.value)}
+ onRemove={() => removeRow(index)}
+ value={field.value}
+ />
+ ))}
+
+
+
+
+
+
+ );
+}
+
+export default FormRowTextList;