Repository: opencast/opencast-admin-interface
Pull request #1254: Changing themes of series fixed
Author: login: wsmirnow
Labels: name: type:bug
description: Something isn't working
Created: 2025-05-13T16:08:00Z | Merged: 2025-05-16T07:38:19Z

In the series dialog the themes selection is fixed.

fixes #1205

Linked issue #1205: Themes can't be changed for series
See https://github.com/opencast/opencast/pull/6640#pullrequestreview-2787013625: 

Setting the theme when creating a new series works, but then changing the theme for that series afterwards doesn't work - the themes are correctly listed by their name in the drop down, but once selected it only shows the id, and the changes cannot be saved. 

Looks like a key and value mix-up of the listprovider data in the admin ui that leads to an incorrect update request to the back-end that then gets rejected.

Diff:
diff --git a/src/components/events/partials/ModalTabsAndPages/SeriesDetailsThemeTab.tsx b/src/components/events/partials/ModalTabsAndPages/SeriesDetailsThemeTab.tsx
index 14858caae1..95edf3bf7e 100644
--- a/src/components/events/partials/ModalTabsAndPages/SeriesDetailsThemeTab.tsx
+++ b/src/components/events/partials/ModalTabsAndPages/SeriesDetailsThemeTab.tsx
@@ -11,6 +11,8 @@ import { updateSeriesTheme } from "../../../../slices/seriesDetailsSlice";
 import WizardNavigationButtons from "../../../shared/wizard/WizardNavigationButtons";
 import ModalContentTable from "../../../shared/modals/ModalContentTable";
 
+type SeriesTheme = { id: string; value: string; };
+
 /**
  * This component renders the tab for editing the theme of a certain series
  */
@@ -19,11 +21,8 @@ const SeriesDetailsThemeTab = ({
 	themeNames,
 	seriesId,
 }: {
-	theme: string,
-	themeNames: {
-		id: string;
-		value: string;
-	}[],
+	theme: SeriesTheme | null,
+	themeNames: SeriesTheme[],
 	seriesId: string
 }) => {
 	const { t } = useTranslation();
@@ -31,11 +30,11 @@ const SeriesDetailsThemeTab = ({
 
 	const user = useAppSelector(state => getUserInformation(state));
 
-	const handleSubmit = (values: { theme: string }) => {
+	const handleSubmit = (values: { theme: SeriesTheme | null }) => {
 		dispatch(updateSeriesTheme({id: seriesId, values: values}));
 	};
 
-	const checkValidity = (formik: FormikProps<{theme: string }>) => {
+	const checkValidity = (formik: FormikProps<{theme: SeriesTheme | null }>) => {
 		if (formik.dirty && formik.isValid) {
 			// check if user provided values differ from initial ones
 			return !_.isEqual(formik.values, formik.initialValues);
@@ -64,13 +63,13 @@ const SeriesDetailsThemeTab = ({
 										{themeNames.length > 0 && (
 											<div className="editable">
 												<DropDown
-													value={formik.values.theme}
-													text={formik.values.theme}
+													value={formik.values.theme?.id}
+													text={formik.values.theme?.value || ''}
 													options={themeNames.map(names => ({ label: names.value, value: names.id }))}
 													required={false}
 													handleChange={(element) => {
 														if (element) {
-															formik.setFieldValue("theme", element.value)
+															formik.setFieldValue("theme", {id: element.value, value: element.label})
 														}
 													}}
 													placeholder={t("EVENTS.SERIES.NEW.THEME.LABEL")}
diff --git a/src/slices/seriesDetailsSlice.ts b/src/slices/seriesDetailsSlice.ts
index 649f298660..203ae63cfb 100644
--- a/src/slices/seriesDetailsSlice.ts
+++ b/src/slices/seriesDetailsSlice.ts
@@ -51,7 +51,7 @@ type SeriesDetailsState = {
   	metadata: MetadataCatalog,
 	extendedMetadata: MetadataCatalog[],
 	acl: TransformedAcl[],
-	theme: string,
+	theme: { id: string, value: string } | null,
 	themeNames: { id: string, value: string }[],
 	fetchingStatisticsInProgress: boolean,
 	statistics: Statistics[],
@@ -83,7 +83,7 @@ const initialState: SeriesDetailsState = {
 	},
 	extendedMetadata: [],
 	acl: [],
-	theme: "",
+	theme: null,
 	themeNames: [],
 	fetchingStatisticsInProgress: false,
 	statistics: [],
@@ -165,12 +165,12 @@ export const fetchSeriesDetailsTheme = createAppAsyncThunk('seriesDetails/fetchS
 	const res = await axios.get(`/admin-ng/series/${id}/theme.json`);
 	const themeResponse = res.data;
 
-	let seriesTheme = "";
+	let seriesTheme: SeriesDetailsState["theme"] = null;
 
 	// check if series has a theme
 	if (!_.isEmpty(themeResponse)) {
 		// transform response for further use
-		seriesTheme = transformToIdValueArray(themeResponse)[0].value;
+		seriesTheme = transformToIdValueArray(themeResponse)[0];
 	}
 
 	return seriesTheme;
@@ -298,67 +298,55 @@ export const updateSeriesAccess = createAppAsyncThunk('seriesDetails/updateSerie
 export const updateSeriesTheme = createAppAsyncThunk('seriesDetails/updateSeriesTheme', async (params: {
 	id: string,
 	values: { theme: SeriesDetailsState["theme"] },
-}, {dispatch, getState}) => {
+}, {dispatch}) => {
 	const { id, values } = params;
 
-	let themeNames = getSeriesDetailsThemeNames(getState());
-
-	let themeId = themeNames.find((theme) => theme.value === values.theme)?.id;
-
-	if (!values.theme) {
-        axios
-            .delete(`/admin-ng/series/${id}/theme`)
-            .then((response) => {
-                dispatch(
-                    addNotification({
-                        type: "warning",
-                        key: "SERIES_THEME_REPROCESS_EXISTING_EVENTS",
-                        duration: 10,
-                        parameter: undefined,
-                        context: NOTIFICATION_CONTEXT
-                    })
-                );
-            })
-            .catch((response) => {
-                console.error(response);
-            });
-	} else if (!themeId) {
-        console.error("Can't update series theme. " + values.theme + " not found");
-        dispatch(
-            addNotification({
-                type: "error",
-                key: "SERIES_NOT_SAVED",
-                duration: 10,
-                parameter: undefined,
-                context: NOTIFICATION_CONTEXT
-            })
-        );
-    } else {
-        let data = new URLSearchParams();
-        data.append("themeId", themeId);
-
-        axios
-            .put(`/admin-ng/series/${id}/theme`, data)
-            .then((response) => {
-                let themeResponse = response.data;
-
-                let seriesTheme = transformToIdValueArray(themeResponse)[0].value;
-
-                dispatch(setSeriesDetailsTheme(seriesTheme));
-                dispatch(
-                    addNotification({
-                        type: "warning",
-                        key: "SERIES_THEME_REPROCESS_EXISTING_EVENTS",
-                        duration: 10,
-                        parameter: undefined,
-                        context: NOTIFICATION_CONTEXT
-                    })
-                );
-            })
-            .catch((response) => {
-                console.error(response);
-            });
-    }
+	let themeId = values.theme?.id;
+
+	if (!themeId || themeId === '') {
+		axios
+			.delete(`/admin-ng/series/${id}/theme`)
+			.then((response) => {
+				dispatch(setSeriesDetailsTheme(values.theme));
+				dispatch(
+					addNotification({
+						type: "warning",
+						key: "SERIES_THEME_REPROCESS_EXISTING_EVENTS",
+						duration: 10,
+						parameter: undefined,
+						context: NOTIFICATION_CONTEXT
+					})
+				);
+			})
+			.catch((response) => {
+				console.error(response);
+			});
+	} else {
+		let data = new URLSearchParams();
+		data.append("themeId", themeId);
+
+		axios
+			.put(`/admin-ng/series/${id}/theme`, data)
+			.then((response) => {
+				let themeResponse = response.data;
+
+				let seriesTheme = transformToIdValueArray(themeResponse)[0];
+
+				dispatch(setSeriesDetailsTheme(seriesTheme));
+				dispatch(
+					addNotification({
+						type: "warning",
+						key: "SERIES_THEME_REPROCESS_EXISTING_EVENTS",
+						duration: 10,
+						parameter: undefined,
+						context: NOTIFICATION_CONTEXT
+					})
+				);
+			})
+			.catch((response) => {
+				console.error(response);
+			});
+	}
 });
 
 // fetch Tobira data of certain series from server


Reviews:
user: login: snoesberger
body: I successfully tested the changes. I was able to change the theme of an existing series.

The code changes do also look appropriate to me.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T09:03:27Z
user: login: Arnei
body: Works and code looks generally good to me. I have some typescript considerations, tell me what you think about them.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-14T09:37:42Z
user: login: wsmirnow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T13:31:59Z
user: login: Arnei
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T13:41:50Z
user: login: wsmirnow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:25:39Z
user: login: wsmirnow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:30:35Z
user: login: Arnei
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T14:44:19Z
user: login: wsmirnow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T07:20:40Z
user: login: Arnei
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-16T07:38:12Z

Review comments:
diff_hunk: @@ -298,17 +298,16 @@ export const updateSeriesAccess = createAppAsyncThunk('seriesDetails/updateSerie
 export const updateSeriesTheme = createAppAsyncThunk('seriesDetails/updateSeriesTheme', async (params: {
 	id: string,
 	values: { theme: SeriesDetailsState["theme"] },
-}, {dispatch, getState}) => {
+}, {dispatch}) => {
 	const { id, values } = params;
 
-	let themeNames = getSeriesDetailsThemeNames(getState());
+	let themeId = values.theme?.id;
 
-	let themeId = themeNames.find((theme) => theme.value === values.theme)?.id;
-
-	if (!values.theme) {
+	if (!themeId || themeId === '') {
         axios
             .delete(`/admin-ng/series/${id}/theme`)
             .then((response) => {
+				dispatch(setSeriesDetailsTheme(values.theme));
path: src/slices/seriesDetailsSlice.ts
user: login: Arnei
body: Indentation
created_at: 2025-05-14T09:25:50Z
author_association: MEMBER
diff_hunk: @@ -19,7 +19,10 @@ const SeriesDetailsThemeTab = ({
 	themeNames,
 	seriesId,
 }: {
-	theme: string,
+	theme: {
+		id: string;
+		value: string;
+	} | null,
path: src/components/events/partials/ModalTabsAndPages/SeriesDetailsThemeTab.tsx
user: login: Arnei
body: Consider turning this into a type, e.g.

```
type SeriesTheme: {
		id: string;
		value: string;
	} | null;
```

This helps avoid copy & paste mistakes, and also makes it more clear to other developers that info for the theme of a series is expected here, not some arbitrary key-value pair.
created_at: 2025-05-14T09:28:14Z
author_association: MEMBER
diff_hunk: @@ -165,12 +165,12 @@ export const fetchSeriesDetailsTheme = createAppAsyncThunk('seriesDetails/fetchS
 	const res = await axios.get(`/admin-ng/series/${id}/theme.json`);
 	const themeResponse = res.data;
 
-	let seriesTheme = "";
+	let seriesTheme = null;
path: src/slices/seriesDetailsSlice.ts
user: login: Arnei
body: Consider typing this variable (typescript inferences `any`).

Also ` = null` is not actually necessary I think.
created_at: 2025-05-14T09:30:48Z
author_association: MEMBER
diff_hunk: @@ -165,12 +165,12 @@ export const fetchSeriesDetailsTheme = createAppAsyncThunk('seriesDetails/fetchS
 	const res = await axios.get(`/admin-ng/series/${id}/theme.json`);
 	const themeResponse = res.data;
 
-	let seriesTheme = "";
+	let seriesTheme = null;
path: src/slices/seriesDetailsSlice.ts
user: login: wsmirnow
body: I'm not sure, this variable may never be set. Isn't a better way to initialize it with null?
created_at: 2025-05-15T13:31:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -165,12 +165,12 @@ export const fetchSeriesDetailsTheme = createAppAsyncThunk('seriesDetails/fetchS
 	const res = await axios.get(`/admin-ng/series/${id}/theme.json`);
 	const themeResponse = res.data;
 
-	let seriesTheme = "";
+	let seriesTheme = null;
path: src/slices/seriesDetailsSlice.ts
user: login: Arnei
body: That is not necessary in Javascript, as Javascript will make sure to set not-initialized variables to `undefined` .
created_at: 2025-05-15T13:41:50Z
author_association: MEMBER
diff_hunk: @@ -165,12 +165,12 @@ export const fetchSeriesDetailsTheme = createAppAsyncThunk('seriesDetails/fetchS
 	const res = await axios.get(`/admin-ng/series/${id}/theme.json`);
 	const themeResponse = res.data;
 
-	let seriesTheme = "";
+	let seriesTheme = null;
path: src/slices/seriesDetailsSlice.ts
user: login: wsmirnow
body: Without initialization the build process end up with an error:

```
$ npm run build

> admin-ui-picard@0.1.0 build
> tsc && eslint . --max-warnings=0 && vite build

src/slices/seriesDetailsSlice.ts:567:5 - error TS2769: No overload matches this call.
  Overload 1 of 2, '(actionCreator: AsyncThunkFulfilledActionCreator<{ id: string; value: string; } | undefined, string, { state: { tableFilters: TableFilterState; tableFilterProfiles: TableFilterProfilesState & PersistPartial; ... 22 more ...; statistics: StatisticsState; } & PersistPartial; ... 6 more ...; rejectedMeta?: unknown; }>, reducer: CaseReducer<...>): ActionReducerMapBuilder<...>', gave the following error.
    Argument of type '(state: WritableDraft<SeriesDetailsState>, action: { payload: { id: string; value: string; } | null; type: string; }) => void' is not assignable to parameter of type 'CaseReducer<SeriesDetailsState, PayloadAction<{ id: string; value: string; } | undefined, string, { arg: string; requestId: string; requestStatus: "fulfilled"; }, never>>'.
      Types of parameters 'action' and 'action' are incompatible.
        Type 'PayloadAction<{ id: string; value: string; } | undefined, string, { arg: string; requestId: string; requestStatus: "fulfilled"; }, never>' is not assignable to type '{ payload: { id: string; value: string; } | null; type: string; }'.
          Types of property 'payload' are incompatible.
            Type '{ id: string; value: string; } | undefined' is not assignable to type '{ id: string; value: string; } | null'.
              Type 'undefined' is not assignable to type '{ id: string; value: string; } | null'.
  Overload 2 of 2, '(type: string, reducer: CaseReducer<SeriesDetailsState, { payload: { id: string; value: string; } | null; type: string; }>): ActionReducerMapBuilder<SeriesDetailsState>', gave the following error.
    Argument of type 'AsyncThunkFulfilledActionCreator<{ id: string; value: string; } | undefined, string, { state: { tableFilters: TableFilterState; tableFilterProfiles: TableFilterProfilesState & PersistPartial; ... 22 more ...; statistics: StatisticsState; } & PersistPartial; ... 6 more ...; rejectedMeta?: unknown; }>' is not assignable to parameter of type 'string'.

567    .addCase(fetchSeriesDetailsTheme.fulfilled, (state, action: PayloadAction<
        ~~~~~~~

Found 1 error in src/slices/seriesDetailsSlice.ts:567
```

I will leave it initialized
created_at: 2025-05-15T14:25:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -298,17 +298,16 @@ export const updateSeriesAccess = createAppAsyncThunk('seriesDetails/updateSerie
 export const updateSeriesTheme = createAppAsyncThunk('seriesDetails/updateSeriesTheme', async (params: {
 	id: string,
 	values: { theme: SeriesDetailsState["theme"] },
-}, {dispatch, getState}) => {
+}, {dispatch}) => {
 	const { id, values } = params;
 
-	let themeNames = getSeriesDetailsThemeNames(getState());
+	let themeId = values.theme?.id;
 
-	let themeId = themeNames.find((theme) => theme.value === values.theme)?.id;
-
-	if (!values.theme) {
+	if (!themeId || themeId === '') {
         axios
             .delete(`/admin-ng/series/${id}/theme`)
             .then((response) => {
+				dispatch(setSeriesDetailsTheme(values.theme));
path: src/slices/seriesDetailsSlice.ts
user: login: wsmirnow
body: Reformated code to use tabs instead of whitespaces.
created_at: 2025-05-15T14:30:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -165,12 +165,12 @@ export const fetchSeriesDetailsTheme = createAppAsyncThunk('seriesDetails/fetchS
 	const res = await axios.get(`/admin-ng/series/${id}/theme.json`);
 	const themeResponse = res.data;
 
-	let seriesTheme = "";
+	let seriesTheme = null;
path: src/slices/seriesDetailsSlice.ts
user: login: Arnei
body: Aah, because the reducer expects `null` instead of `undefined`. Fair enough, did not think about that.

I'd still suggest typing the variable though to avoid the `any` inference, for example:
```
let seriesTheme: SeriesDetailsState["theme"] | null = null;
```
created_at: 2025-05-15T14:44:19Z
author_association: MEMBER
diff_hunk: @@ -165,12 +165,12 @@ export const fetchSeriesDetailsTheme = createAppAsyncThunk('seriesDetails/fetchS
 	const res = await axios.get(`/admin-ng/series/${id}/theme.json`);
 	const themeResponse = res.data;
 
-	let seriesTheme = "";
+	let seriesTheme = null;
path: src/slices/seriesDetailsSlice.ts
user: login: wsmirnow
body: Make sens. Is done.
created_at: 2025-05-16T07:20:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-13T16:08:57Z
body: Use `docker` or `podman` to test this pull request locally.

Run test server using develop.opencast.org as backend:
```
podman run --rm -it -p 127.0.0.1:3000:3000 ghcr.io/opencast/opencast-admin-interface:pr-1254
```

Specify a different backend like stable.opencast.org:
```
podman run --rm -it -p 127.0.0.1:3000:3000 -e PROXY_TARGET=https://stable.opencast.org ghcr.io/opencast/opencast-admin-interface:pr-1254
```

It may take a few seconds for the interface to spin up.
It will then be available at http://127.0.0.1:3000.
For more options you can pass on to the proxy, take a look at the `README.md`.
<!-- thollander/actions-comment-pull-request "container-image" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-13T16:11:01Z
body: This pull request is deployed at [test.admin-interface.opencast.org/1254/2025-05-16_07-20-19/ ](https://test.admin-interface.opencast.org/1254/2025-05-16_07-20-19/).
It might take a few minutes for it to become available.
<!-- thollander/actions-comment-pull-request "static-test-deployment" -->
author_association: CONTRIBUTOR