diff --git a/docs/api.rst b/docs/api.rst index 3d0314b14..1d3ec40ad 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -23,9 +23,9 @@ Human-in-the-loop :nosignatures: optuna_dashboard.register_objective_form_widgets - optuna_dashboard.ObjectiveChoiceWidget - optuna_dashboard.ObjectiveSliderWidget - optuna_dashboard.ObjectiveTextInputWidget + optuna_dashboard.ChoiceWidget + optuna_dashboard.SliderWidget + optuna_dashboard.TextInputWidget optuna_dashboard.ObjectiveUserAttrRef Artifact diff --git a/optuna_dashboard/__init__.py b/optuna_dashboard/__init__.py index ee30219ef..8c275eea9 100644 --- a/optuna_dashboard/__init__.py +++ b/optuna_dashboard/__init__.py @@ -1,12 +1,16 @@ from ._app import run_server # noqa from ._app import wsgi # noqa +from ._form_widget import ChoiceWidget # noqa +from ._form_widget import ObjectiveChoiceWidget # noqa +from ._form_widget import ObjectiveSliderWidget # noqa +from ._form_widget import ObjectiveTextInputWidget # noqa +from ._form_widget import ObjectiveUserAttrRef # noqa +from ._form_widget import register_objective_form_widgets # noqa +from ._form_widget import register_user_attr_form_widgets # noqa +from ._form_widget import SliderWidget # noqa +from ._form_widget import TextInputWidget # noqa from ._named_objectives import set_objective_names # noqa from ._note import save_note # noqa -from ._objective_form_widget import ObjectiveChoiceWidget # noqa -from ._objective_form_widget import ObjectiveSliderWidget # noqa -from ._objective_form_widget import ObjectiveTextInputWidget # noqa -from ._objective_form_widget import ObjectiveUserAttrRef # noqa -from ._objective_form_widget import register_objective_form_widgets # noqa __version__ = "0.9.0" diff --git a/optuna_dashboard/_app.py b/optuna_dashboard/_app.py index d91710f66..a72c60c21 100644 --- a/optuna_dashboard/_app.py +++ b/optuna_dashboard/_app.py @@ -447,6 +447,24 @@ def tell_trial(trial_id: int) -> dict[str, Any]: response.status = 204 return {} + @app.post("/api/trials//user-attrs") + @json_api_view + def save_trial_user_attrs(trial_id: int) -> dict[str, Any]: + user_attrs = request.json.get("user_attrs", {}) + if not user_attrs: + response.status = 400 # Bad request + return {"reason": "user_attrs must be specified."} + + try: + for key, val in user_attrs.items(): + storage.set_trial_user_attr(trial_id, key, val) + except Exception as e: + response.status = 500 + return {"reason": f"Internal server error: {e}"} + + response.status = 204 + return {} + @app.put("/api/studies///note") @json_api_view def save_trial_note(study_id: int, trial_id: int) -> dict[str, Any]: diff --git a/optuna_dashboard/_form_widget.py b/optuna_dashboard/_form_widget.py new file mode 100644 index 000000000..7b076ec12 --- /dev/null +++ b/optuna_dashboard/_form_widget.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import TYPE_CHECKING +from typing import Union +import warnings + +import optuna + + +if TYPE_CHECKING: + from typing import Any + from typing import Literal + from typing import Optional + from typing import TypedDict + + ChoiceWidgetJSON = TypedDict( + "ChoiceWidgetJSON", + { + "type": Literal["choice"], + "description": Optional[str], + "choices": list[str], + "values": list[float], + "user_attr_key": Optional[str], + }, + ) + SliderWidgetLabel = TypedDict( + "SliderWidgetLabel", + {"value": float, "label": str}, + ) + SliderWidgetJSON = TypedDict( + "SliderWidgetJSON", + { + "type": Literal["slider"], + "description": Optional[str], + "min": float, + "max": float, + "step": Optional[float], + "labels": Optional[list[SliderWidgetLabel]], + "user_attr_key": Optional[str], + }, + ) + TextInputWidgetJSON = TypedDict( + "TextInputWidgetJSON", + {"type": Literal["text"], "description": Optional[str], "user_attr_key": Optional[str]}, + ) + UserAttrRefJSON = TypedDict( + "UserAttrRefJSON", + {"type": Literal["user_attr"], "key": str, "user_attr_key": Optional[str]}, + ) + FormWidgetJSON = TypedDict( + "FormWidgetJSON", + { + "output_type": Literal["objective", "user_attr"], + "widgets": list[ + Union[ChoiceWidgetJSON, SliderWidgetJSON, TextInputWidgetJSON, UserAttrRefJSON] + ], + }, + ) + + +@dataclass +class ChoiceWidget: + choices: list[str] + values: list[float] + description: Optional[str] = None + user_attr_key: Optional[str] = None + + def to_dict(self) -> ChoiceWidgetJSON: + return { + "type": "choice", + "description": self.description, + "choices": self.choices, + "values": self.values, + "user_attr_key": self.user_attr_key, + } + + +@dataclass +class SliderWidget: + min: float + max: float + step: Optional[float] = None + labels: Optional[list[tuple[float, str]]] = None + description: Optional[str] = None + user_attr_key: Optional[str] = None + + def to_dict(self) -> SliderWidgetJSON: + labels: Optional[list[SliderWidgetLabel]] = None + if self.labels is not None: + labels = [{"value": value, "label": label} for value, label in self.labels] + return { + "type": "slider", + "description": self.description, + "min": self.min, + "max": self.max, + "step": self.step, + "labels": labels, + "user_attr_key": self.user_attr_key, + } + + +@dataclass +class TextInputWidget: + description: Optional[str] = None + user_attr_key: Optional[str] = None + + def to_dict(self) -> TextInputWidgetJSON: + return { + "type": "text", + "description": self.description, + "user_attr_key": self.user_attr_key, + } + + +@dataclass +class ObjectiveUserAttrRef: + key: str + user_attr_key: Optional[str] = None + + def to_dict(self) -> UserAttrRefJSON: + return { + "type": "user_attr", + "key": self.key, + "user_attr_key": self.user_attr_key, + } + + +ObjectiveFormWidget = Union[ChoiceWidget, SliderWidget, TextInputWidget, ObjectiveUserAttrRef] +# For backward compatibility. +ObjectiveChoiceWidget = ChoiceWidget +ObjectiveSliderWidget = SliderWidget +ObjectiveTextInputWidget = TextInputWidget +FORM_WIDGETS_KEY = "dashboard:form_widgets:v2" + + +def register_objective_form_widgets( + study: optuna.Study, widgets: list[ObjectiveFormWidget] +) -> None: + if len(study.directions) != len(widgets): + raise ValueError("The length of actions must be the same with the number of objectives.") + if any(w.user_attr_key is not None for w in widgets): + warnings.warn("`user_attr_key` specified, but it will not be used.") + form_widgets: FormWidgetJSON = { + "output_type": "objective", + "widgets": [w.to_dict() for w in widgets], + } + study._storage.set_study_system_attr(study._study_id, FORM_WIDGETS_KEY, form_widgets) + + +def register_user_attr_form_widgets( + study: optuna.Study, widgets: list[ObjectiveFormWidget] +) -> None: + if any(w.user_attr_key is None for w in widgets): + raise ValueError("`user_attr_key` is not specified.") + if len(widgets) != len(set(w.user_attr_key for w in widgets)): + raise ValueError("`user_attr_key` must be unique for each widget.") + form_widgets: FormWidgetJSON = { + "output_type": "user_attr", + "widgets": [w.to_dict() for w in widgets], + } + study._storage.set_study_system_attr(study._study_id, FORM_WIDGETS_KEY, form_widgets) + + +def get_form_widgets_json(study_system_attr: dict[str, Any]) -> Optional[FormWidgetJSON]: + if FORM_WIDGETS_KEY in study_system_attr: + return study_system_attr[FORM_WIDGETS_KEY] + + # For optuna-dashboard v0.9.0 and v0.9.0b6 users + if "dashboard:objective_form_widgets:v1" in study_system_attr: + return { + "output_type": "objective", + "widgets": study_system_attr["dashboard:objective_form_widgets:v1"], + } + + # For optuna-dashboard v0.9.0b5 users + if "dashboard:objective_form_widgets" in study_system_attr: + return { + "output_type": "objective", + "widgets": json.loads(study_system_attr["dashboard:objective_form_widgets"]), + } + return None diff --git a/optuna_dashboard/_objective_form_widget.py b/optuna_dashboard/_objective_form_widget.py deleted file mode 100644 index 3f19631ee..000000000 --- a/optuna_dashboard/_objective_form_widget.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -import json -from typing import TYPE_CHECKING -from typing import Union - -import optuna - - -if TYPE_CHECKING: - from typing import Any - from typing import Literal - from typing import Optional - from typing import TypedDict - - ChoiceWidgetJSON = TypedDict( - "ChoiceWidgetJSON", - { - "type": Literal["choice"], - "description": Optional[str], - "choices": list[str], - "values": list[float], - }, - ) - SliderWidgetLabel = TypedDict( - "SliderWidgetLabel", - {"value": float, "label": str}, - ) - SliderWidgetJSON = TypedDict( - "SliderWidgetJSON", - { - "type": Literal["slider"], - "description": Optional[str], - "min": float, - "max": float, - "step": Optional[float], - "labels": Optional[list[SliderWidgetLabel]], - }, - ) - TextInputWidgetJSON = TypedDict( - "TextInputWidgetJSON", - {"type": Literal["text"], "description": Optional[str]}, - ) - UserAttrRefJSON = TypedDict("UserAttrRefJSON", {"type": Literal["user_attr"], "key": str}) - ObjectiveFormWidgetJSON = Union[ - ChoiceWidgetJSON, SliderWidgetJSON, TextInputWidgetJSON, UserAttrRefJSON - ] - - -@dataclass -class ObjectiveChoiceWidget: - choices: list[str] - values: list[float] - description: Optional[str] = None - - def to_dict(self) -> ChoiceWidgetJSON: - return { - "type": "choice", - "description": self.description, - "choices": self.choices, - "values": self.values, - } - - -@dataclass -class ObjectiveSliderWidget: - min: float - max: float - step: Optional[float] = None - labels: Optional[list[tuple[float, str]]] = None - description: Optional[str] = None - - def to_dict(self) -> SliderWidgetJSON: - labels: Optional[list[SliderWidgetLabel]] = None - if self.labels is not None: - labels = [{"value": value, "label": label} for value, label in self.labels] - return { - "type": "slider", - "description": self.description, - "min": self.min, - "max": self.max, - "step": self.step, - "labels": labels, - } - - -@dataclass -class ObjectiveTextInputWidget: - description: Optional[str] = None - - def to_dict(self) -> TextInputWidgetJSON: - return { - "type": "text", - "description": self.description, - } - - -@dataclass -class ObjectiveUserAttrRef: - key: str - - def to_dict(self) -> UserAttrRefJSON: - return { - "type": "user_attr", - "key": self.key, - } - - -ObjectiveFormWidget = Union[ - ObjectiveChoiceWidget, ObjectiveSliderWidget, ObjectiveTextInputWidget, ObjectiveUserAttrRef -] -SYSTEM_ATTR_KEY = "dashboard:objective_form_widgets:v1" - - -def register_objective_form_widgets( - study: optuna.Study, widgets: list[ObjectiveFormWidget] -) -> None: - if len(study.directions) != len(widgets): - raise ValueError("The length of actions must be the same with the number of objectives.") - widget_dicts = [w.to_dict() for w in widgets] - study._storage.set_study_system_attr(study._study_id, SYSTEM_ATTR_KEY, widget_dicts) - - -def get_objective_form_widgets_json( - study_system_attr: dict[str, Any] -) -> Optional[list[ObjectiveFormWidgetJSON]]: - if SYSTEM_ATTR_KEY in study_system_attr: - return study_system_attr[SYSTEM_ATTR_KEY] - # For optuna-dashboard v0.9.0b5 users - if "dashboard:objective_form_widgets" in study_system_attr: - return json.loads(study_system_attr["dashboard:objective_form_widgets"]) - return None diff --git a/optuna_dashboard/_serializer.py b/optuna_dashboard/_serializer.py index 021705703..e789e18a9 100644 --- a/optuna_dashboard/_serializer.py +++ b/optuna_dashboard/_serializer.py @@ -12,8 +12,8 @@ from optuna.trial import FrozenTrial from . import _note as note +from ._form_widget import get_form_widgets_json from ._named_objectives import get_objective_names -from ._objective_form_widget import get_objective_form_widgets_json from .artifact._backend import list_trial_artifacts @@ -144,9 +144,9 @@ def serialize_study_detail( objective_names = get_objective_names(system_attrs) if objective_names: serialized["objective_names"] = objective_names - objective_form_widgets = get_objective_form_widgets_json(system_attrs) - if objective_form_widgets: - serialized["objective_form_widgets"] = objective_form_widgets + form_widgets = get_form_widgets_json(system_attrs) + if form_widgets: + serialized["form_widgets"] = form_widgets return serialized diff --git a/optuna_dashboard/ts/action.ts b/optuna_dashboard/ts/action.ts index 9f8db5814..657adbd5e 100644 --- a/optuna_dashboard/ts/action.ts +++ b/optuna_dashboard/ts/action.ts @@ -9,6 +9,7 @@ import { saveStudyNoteAPI, saveTrialNoteAPI, tellTrialAPI, + saveTrialUserAttrsAPI, renameStudyAPI, uploadArtifactAPI, getMetaInfoAPI, @@ -151,6 +152,26 @@ export const actionCreator = () => { setStudyDetailState(studyId, newStudy) } + const setTrialUserAttrs = ( + studyId: number, + index: number, + user_attrs: { [key: string]: number } + ) => { + const newTrial: Trial = Object.assign( + {}, + studyDetails[studyId].trials[index] + ) + newTrial.user_attrs = Object.keys(user_attrs).map((key) => ({ + key: key, + value: user_attrs[key].toString(), + })) + const newTrials: Trial[] = [...studyDetails[studyId].trials] + newTrials[index] = newTrial + const newStudy: StudyDetail = Object.assign({}, studyDetails[studyId]) + newStudy.trials = newTrials + setStudyDetailState(studyId, newStudy) + } + const setStudyParamImportanceState = ( studyId: number, importance: ParamImportance[][] @@ -508,6 +529,40 @@ export const actionCreator = () => { }) } + const saveTrialUserAttrs = ( + studyId: number, + trialId: number, + user_attrs: { [key: string]: number } + ): void => { + console.log("user_attrs", user_attrs) + const message = `id=${trialId}, user_attrs=${JSON.stringify(user_attrs)}` + saveTrialUserAttrsAPI(trialId, user_attrs) + .then(() => { + const index = studyDetails[studyId].trials.findIndex( + (t) => t.trial_id === trialId + ) + if (index === -1) { + enqueueSnackbar(`Unexpected error happens. Please reload the page.`, { + variant: "error", + }) + return + } + setTrialUserAttrs(studyId, index, user_attrs) + enqueueSnackbar(`Successfully updated trial (${message})`, { + variant: "success", + }) + }) + .catch((err) => { + const reason = err.response?.data.reason + enqueueSnackbar( + `Failed to update trial (${message}). Reason: ${reason}`, + { + variant: "error", + } + ) + console.log(err) + }) + } return { updateAPIMeta, updateStudyDetail, @@ -526,6 +581,7 @@ export const actionCreator = () => { deleteArtifact, makeTrialComplete, makeTrialFail, + saveTrialUserAttrs, } } diff --git a/optuna_dashboard/ts/apiClient.ts b/optuna_dashboard/ts/apiClient.ts index e263117c7..578bcc035 100644 --- a/optuna_dashboard/ts/apiClient.ts +++ b/optuna_dashboard/ts/apiClient.ts @@ -67,7 +67,7 @@ interface StudyDetailResponse { has_intermediate_values: boolean note: Note objective_names?: string[] - objective_form_widgets?: ObjectiveFormWidget[] + form_widgets?: FormWidgets } export const getStudyDetailAPI = ( @@ -100,7 +100,7 @@ export const getStudyDetailAPI = ( has_intermediate_values: res.data.has_intermediate_values, note: res.data.note, objective_names: res.data.objective_names, - objective_form_widgets: res.data.objective_form_widgets, + form_widgets: res.data.form_widgets, } }) } @@ -281,6 +281,19 @@ export const tellTrialAPI = ( }) } +export const saveTrialUserAttrsAPI = ( + trialId: number, + user_attrs: { [key: string]: number } +): Promise => { + const req = { user_attrs: user_attrs } + + return axiosInstance + .post(`/api/trials/${trialId}/user-attrs`, req) + .then((res) => { + return + }) +} + interface ParamImportancesResponse { param_importances: ParamImportance[][] } diff --git a/optuna_dashboard/ts/components/ObjectiveForm.tsx b/optuna_dashboard/ts/components/ObjectiveForm.tsx index 0e61ce096..09a447769 100644 --- a/optuna_dashboard/ts/components/ObjectiveForm.tsx +++ b/optuna_dashboard/ts/components/ObjectiveForm.tsx @@ -20,13 +20,12 @@ export const ObjectiveForm: FC<{ trial: Trial directions: StudyDirection[] names: string[] - widgets: ObjectiveFormWidget[] -}> = ({ trial, directions, names, widgets }) => { + formWidgets: FormWidgets +}> = ({ trial, directions, names, formWidgets }) => { const theme = useTheme() const action = actionCreator() const [values, setValues] = useState<(number | null)[]>( - directions.map((d, i) => { - const widget = widgets.at(i) + formWidgets.widgets.map((widget) => { if (widget === undefined) { return null } else if (widget.type === "text") { @@ -65,23 +64,38 @@ export const ObjectiveForm: FC<{ const handleSubmit = (e: React.MouseEvent): void => { e.preventDefault() - const filtered = values.filter((v): v is number => v !== null) - if (filtered.length !== directions.length) { - return + if (formWidgets.output_type == "objective") { + const filtered = values.filter((v): v is number => v !== null) + if (filtered.length !== directions.length) { + return + } + action.makeTrialComplete(trial.study_id, trial.trial_id, filtered) + } else if (formWidgets.output_type == "user_attr") { + const user_attrs = Object.fromEntries( + formWidgets.widgets.map((widget, i) => [ + widget.user_attr_key, + values[i], + ]) + ) + action.saveTrialUserAttrs(trial.study_id, trial.trial_id, user_attrs) } - action.makeTrialComplete(trial.study_id, trial.trial_id, filtered) } - const getObjectiveName = (i: number): string => { - const n = names.at(i) - if (n !== undefined) { - return n - } - if (directions.length == 1) { - return `Objective` - } else { - return `Objective ${i}` + const getMetricName = (i: number): string => { + if (formWidgets.output_type == "objective") { + const n = names.at(i) + if (n !== undefined) { + return n + } + if (directions.length == 1) { + return `Objective` + } else { + return `Objective ${i}` + } + } else if (formWidgets.output_type == "user_attr") { + return formWidgets.widgets[i].user_attr_key as string } + return "Unkown metric name" } return ( @@ -102,44 +116,14 @@ export const ObjectiveForm: FC<{ p: theme.spacing(1), }} > - {directions.map((d, i) => { - const widget = widgets.at(i) + {formWidgets.widgets.map((widget, i) => { const value = values.at(i) const key = `objective-${i}` - if (widget === undefined) { - return ( - - {getObjectiveName(i)} - { - const n = Number(s) - if (s.length > 0 && valid && !isNaN(n)) { - setValue(i, n) - return - } else if (values.at(i) !== null) { - setValue(i, null) - } - }} - delay={500} - textFieldProps={{ - required: true, - autoFocus: true, - fullWidth: true, - helperText: - value === null || value === undefined - ? `Please input the float number.` - : "", - label: getObjectiveName(i), - type: "text", - }} - /> - - ) - } else if (widget.type === "text") { + if (widget.type === "text") { return ( - {getObjectiveName(i)} - {widget.description} + {getMetricName(i)} - {widget.description} { @@ -172,7 +156,7 @@ export const ObjectiveForm: FC<{ return ( - {getObjectiveName(i)} - {widget.description} + {getMetricName(i)} - {widget.description} {widget.choices.map((c, j) => ( @@ -202,7 +186,7 @@ export const ObjectiveForm: FC<{ return ( - {getObjectiveName(i)} - {widget.description} + {getMetricName(i)} - {widget.description} - {getObjectiveName(i)} + {getMetricName(i)} = ({ trial, directions, names, widgets }) => { + formWidgets: FormWidgets +}> = ({ trial, directions, names, formWidgets }) => { const theme = useTheme() - const getObjectiveName = (i: number): string => { - const n = names.at(i) - if (n !== undefined) { - return n - } - if (directions.length == 1) { - return `Objective` - } else { - return `Objective ${i}` + const getMetricName = (i: number): string => { + if (formWidgets.output_type == "objective") { + const n = names.at(i) + if (n !== undefined) { + return n + } + if (directions.length == 1) { + return `Objective` + } else { + return `Objective ${i}` + } + } else if (formWidgets.output_type == "user_attr") { + return formWidgets.widgets[i].user_attr_key as string } + return "Unkown metric name" } return ( <> @@ -312,24 +301,13 @@ export const ReadonlyObjectiveForm: FC<{ p: theme.spacing(1), }} > - {directions.map((d, i) => { - const widget = widgets.at(i) + {formWidgets.widgets.map((widget, i) => { const key = `objective-${i}` - if (widget === undefined) { - return ( - - {getObjectiveName(i)} - - - ) - } else if (widget.type === "text") { + if (widget.type === "text") { return ( - {getObjectiveName(i)} - {widget.description} + {getMetricName(i)} - {widget.description} - {getObjectiveName(i)} - {widget.description} + {getMetricName(i)} - {widget.description} {widget.choices.map((c, j) => ( @@ -366,7 +344,7 @@ export const ReadonlyObjectiveForm: FC<{ return ( - {getObjectiveName(i)} - {widget.description} + {getMetricName(i)} - {widget.description} - {getObjectiveName(i)} + {getMetricName(i)} boolean directions: StudyDirection[] objectiveNames: string[] - objectiveFormWidgets: ObjectiveFormWidget[] -}> = ({ - trial, - isBestTrial, - directions, - objectiveNames, - objectiveFormWidgets, -}) => { + formWidgets?: FormWidgets +}> = ({ trial, isBestTrial, directions, objectiveNames, formWidgets }) => { const theme = useTheme() const artifactEnabled = useRecoilValue(artifactIsAvailable) const startMs = trial.datetime_start?.getTime() @@ -291,22 +285,26 @@ const TrialListDetail: FC<{ latestNote={trial.note} cardSx={{ marginBottom: theme.spacing(2) }} /> - {trial.state === "Running" && directions.length > 0 && ( - - )} - {trial.state === "Complete" && directions.length > 0 && ( - - )} + {trial.state === "Running" && + directions.length > 0 && + formWidgets !== undefined && ( + + )} + {trial.state === "Complete" && + directions.length > 0 && + formWidgets !== undefined && ( + + )} {artifactEnabled && } ) @@ -813,9 +811,7 @@ export const TrialList: FC<{ studyDetail: StudyDetail | null }> = ({ isBestTrial={isBestTrial} directions={studyDetail?.directions || []} objectiveNames={studyDetail?.objective_names || []} - objectiveFormWidgets={ - studyDetail?.objective_form_widgets || [] - } + formWidgets={studyDetail?.form_widgets} /> ))} diff --git a/optuna_dashboard/ts/types/index.d.ts b/optuna_dashboard/ts/types/index.d.ts index 1d30bc574..9e8b4456a 100644 --- a/optuna_dashboard/ts/types/index.d.ts +++ b/optuna_dashboard/ts/types/index.d.ts @@ -128,6 +128,7 @@ type StudySummary = { type ObjectiveChoiceWidget = { type: "choice" description: string + user_attr_key?: string choices: string[] values: number[] } @@ -135,6 +136,7 @@ type ObjectiveChoiceWidget = { type ObjectiveSliderWidget = { type: "slider" description: string + user_attr_key?: string min: number max: number step: number @@ -149,19 +151,27 @@ type ObjectiveSliderWidget = { type ObjectiveTextInputWidget = { type: "text" description: string + user_attr_key?: string } type ObjectiveUserAttrRef = { type: "user_attr" key: string + user_attr_key?: string } +// TODO(kenshin): Rename this type to FormWidget or something. type ObjectiveFormWidget = | ObjectiveChoiceWidget | ObjectiveSliderWidget | ObjectiveTextInputWidget | ObjectiveUserAttrRef +type FormWidgets = { + output_type: "objective" | "user_attr" + widgets: ObjectiveFormWidget[] +} + type StudyDetail = { id: number name: string @@ -175,7 +185,7 @@ type StudyDetail = { has_intermediate_values: boolean note: Note objective_names?: string[] - objective_form_widgets?: ObjectiveFormWidget[] + form_widgets?: FormWidgets } type StudyDetails = {