diff --git a/frontend_tests/TrialTable.test.tsx b/frontend_tests/TrialTable.test.tsx index 7a0fda2d..a3bf1e33 100644 --- a/frontend_tests/TrialTable.test.tsx +++ b/frontend_tests/TrialTable.test.tsx @@ -43,7 +43,8 @@ const trials = [ const study_direction: StudyDirection = "minimize" as StudyDirection -const study_detail = { +const studyDetail = { + id: 1, name: "study_0", directions: [study_direction], datetime_start: new Date("2021-06-15T00:00:00"), @@ -74,11 +75,15 @@ const study_detail = { }, ], has_intermediate_values: false, + note: { + version: 0, + body: "", + }, } it("Sort TrialTable by trial number", () => { const { getAllByRole, getByText } = render( - + ) const rows = getAllByRole("row") expect(within(rows[1]).getByText("0")).toBeTruthy() @@ -93,7 +98,7 @@ it("Sort TrialTable by trial number", () => { it("Sort TrialTable by value", () => { const { getAllByRole, getByText } = render( - + ) fireEvent.click(getByText("Value")) const rows = getAllByRole("row") @@ -108,7 +113,7 @@ it("Sort TrialTable by value", () => { it("Sort TrialTable by duration", () => { const { getAllByRole, getByText } = render( - + ) fireEvent.click(getByText("Duration(ms)")) const rows = getAllByRole("row") @@ -123,7 +128,7 @@ it("Sort TrialTable by duration", () => { it("Sort TrialTable by state", () => { const { getAllByRole, getByText } = render( - + ) fireEvent.click(getByText("State")) const rows = getAllByRole("row") @@ -137,7 +142,7 @@ it("Sort TrialTable by state", () => { }) it("Filter trials by state", () => { - const { queryAllByText } = render() + const { queryAllByText } = render() expect(queryAllByText("Fail").length).toBe(1) // Click 'Complete' state diff --git a/optuna_dashboard/_app.py b/optuna_dashboard/_app.py index 831e6093..2bd94c3d 100644 --- a/optuna_dashboard/_app.py +++ b/optuna_dashboard/_app.py @@ -34,6 +34,7 @@ from optuna.study import StudySummary from optuna.trial import FrozenTrial from optuna.trial import TrialState +from . import _note as note from ._intermediate_values import has_intermediate_values from ._search_space import get_search_space from ._serializer import serialize_study_detail @@ -71,10 +72,11 @@ trials_cache: Dict[int, List[FrozenTrial]] = {} trials_last_fetched_at: Dict[int, datetime] = {} -def handle_json_api_exception(view: BottleView) -> BottleView: +def json_api_view(view: BottleView) -> BottleView: @functools.wraps(view) def decorated(*args: List[Any], **kwargs: Dict[str, Any]) -> BottleViewReturn: try: + response.content_type = "application/json" response_body = view(*args, **kwargs) return response_body except Exception as e: @@ -140,9 +142,8 @@ def create_app(storage: BaseStorage) -> Bottle: return INDEX_HTML @app.get("/api/studies") - @handle_json_api_exception + @json_api_view def list_study_summaries() -> BottleViewReturn: - response.content_type = "application/json" summaries = [ serialize_study_summary(summary) for summary in storage.get_all_study_summaries() @@ -152,10 +153,8 @@ def create_app(storage: BaseStorage) -> Bottle: } @app.post("/api/studies") - @handle_json_api_exception + @json_api_view def create_study() -> BottleViewReturn: - response.content_type = "application/json" - study_name = request.json.get("study_name", None) directions = request.json.get("directions", []) if ( @@ -190,10 +189,8 @@ def create_app(storage: BaseStorage) -> Bottle: return {"study_summary": serialize_study_summary(summary)} @app.delete("/api/studies/") - @handle_json_api_exception + @json_api_view def delete_study(study_id: int) -> BottleViewReturn: - response.content_type = "application/json" - try: storage.delete_study(study_id) except KeyError: @@ -203,9 +200,8 @@ def create_app(storage: BaseStorage) -> Bottle: return "" @app.get("/api/studies/") - @handle_json_api_exception + @json_api_view def get_study_detail(study_id: int) -> BottleViewReturn: - response.content_type = "application/json" try: after = int(request.params["after"]) assert after >= 0 @@ -229,10 +225,9 @@ def create_app(storage: BaseStorage) -> Bottle: ) @app.get("/api/studies//param_importances") - @handle_json_api_exception + @json_api_view def get_param_importances(study_id: int) -> BottleViewReturn: # TODO(chenghuzi): add support for selecting params via query parameters. - response.content_type = "application/json" objective_id = int(request.params.get("objective_id", 0)) try: study_name = storage.get_study_name_from_id(study_id) @@ -281,6 +276,28 @@ def create_app(storage: BaseStorage) -> Bottle: ], } + @app.put("/api/studies//note") + @json_api_view + def save_note(study_id: int) -> BottleViewReturn: + req_note_ver = request.json.get("version", None) + req_note_body = request.json.get("body", None) + if req_note_ver is None or req_note_body is None: + response.status = 400 # Bad request + return {"reason": "Invalid request."} + + system_attrs = storage.get_study_system_attrs(study_id) + if not note.version_is_incremented(system_attrs, req_note_ver): + response.status = 409 # Conflict + return { + "reason": "The text you are editing has changed. " + "Please copy your edits and refresh the page.", + "note": note.get_note_from_system_attrs(system_attrs), + } + + note.save_note(storage, study_id, req_note_ver, req_note_body) + response.status = 204 # No content + return {} + @app.get("/static/") def send_static(filename: str) -> BottleViewReturn: return static_file(filename, root=STATIC_DIR) diff --git a/optuna_dashboard/_note.py b/optuna_dashboard/_note.py new file mode 100644 index 00000000..be263d63 --- /dev/null +++ b/optuna_dashboard/_note.py @@ -0,0 +1,77 @@ +import math +from typing import Any +from typing import Dict + +from optuna.storages import BaseStorage + + +try: + from typing import TypedDict +except ImportError: + from typing_extensions import TypedDict + +SYSTEM_ATTR_MAX_LENGTH = 2045 +NOTE_VER_KEY = "dashboard:note_ver" +NOTE_STR_KEY_PREFIX = "dashboard:note_str:" + +NoteType = TypedDict( + "NoteType", + { + "version": int, + "body": str, + }, +) + + +def get_note_from_system_attrs(system_attrs: Dict[str, Any]) -> NoteType: + if NOTE_VER_KEY not in system_attrs: + return { + "version": 0, + "body": "", + } + note_ver = int(system_attrs[NOTE_VER_KEY]) + note_attrs: Dict[str, str] = { + key: value + for key, value in system_attrs.items() + if key.startswith(NOTE_STR_KEY_PREFIX) + } + return {"version": note_ver, "body": concat_body(note_attrs)} + + +def version_is_incremented(system_attrs: Dict[str, Any], req_note_ver: int) -> bool: + db_note_ver = system_attrs.get(NOTE_VER_KEY, 0) + return req_note_ver == db_note_ver + 1 + + +def save_note(storage: BaseStorage, study_id: int, ver: int, body: str) -> None: + storage.set_study_system_attr(study_id, NOTE_VER_KEY, ver) + + attrs = split_body(body) + for k, v in attrs.items(): + storage.set_study_system_attr(study_id, k, v) + + # Clear previous messages + all_note_attrs: Dict[str, str] = { + key: value + for key, value in storage.get_study_system_attrs(study_id).items() + if key.startswith(NOTE_STR_KEY_PREFIX) + } + if len(all_note_attrs) > len(attrs): + for i in range(len(attrs), len(all_note_attrs)): + storage.set_study_system_attr(study_id, f"{NOTE_STR_KEY_PREFIX}{i}", "") + + +def split_body(note_str: str) -> Dict[str, str]: + note_len = len(note_str) + attrs = {} + for i in range(math.ceil(note_len / SYSTEM_ATTR_MAX_LENGTH)): + start = i * SYSTEM_ATTR_MAX_LENGTH + end = min((i + 1) * SYSTEM_ATTR_MAX_LENGTH, note_len) + attrs[f"{NOTE_STR_KEY_PREFIX}{i}"] = note_str[start:end] + return attrs + + +def concat_body(note_attrs: Dict[str, str]) -> str: + return "".join( + note_attrs[f"{NOTE_STR_KEY_PREFIX}{i}"] for i in range(len(note_attrs)) + ) diff --git a/optuna_dashboard/_serializer.py b/optuna_dashboard/_serializer.py index fc155214..d33ec75f 100644 --- a/optuna_dashboard/_serializer.py +++ b/optuna_dashboard/_serializer.py @@ -10,6 +10,8 @@ from optuna.distributions import BaseDistribution from optuna.study import StudySummary from optuna.trial import FrozenTrial +from . import _note as note + try: from typing import TypedDict @@ -111,6 +113,7 @@ def serialize_study_detail( serialized["intersection_search_space"] = serialize_search_space(intersection) serialized["union_search_space"] = serialize_search_space(union) serialized["has_intermediate_values"] = has_intermediate_values + serialized["note"] = note.get_note_from_system_attrs(summary.system_attrs) return serialized diff --git a/optuna_dashboard/static/action.ts b/optuna_dashboard/static/action.ts index 5f1061c9..e2f86a17 100644 --- a/optuna_dashboard/static/action.ts +++ b/optuna_dashboard/static/action.ts @@ -5,6 +5,7 @@ import { getStudySummariesAPI, createNewStudyAPI, deleteStudyAPI, + saveNoteAPI, } from "./apiClient" import { studyDetailsState, studySummariesState } from "./state" @@ -15,6 +16,12 @@ export const actionCreator = () => { const [studyDetails, setStudyDetails] = useRecoilState(studyDetailsState) + const setStudyDetailState = (studyId: number, study: StudyDetail) => { + const newVal = Object.assign({}, studyDetails) + newVal[studyId] = study + setStudyDetails(newVal) + } + const updateStudySummaries = (successMsg?: string) => { getStudySummariesAPI() .then((studySummaries: StudySummary[]) => { @@ -49,15 +56,15 @@ export const actionCreator = () => { ? studyDetails[studyId].trials.slice(0, nLocalFixedTrials) : [] study.trials = study.trials.concat(currentFixedTrials) - const newVal = Object.assign({}, studyDetails) - newVal[studyId] = study - setStudyDetails(newVal) + setStudyDetailState(studyId, study) }) .catch((err) => { const reason = err.response?.data.reason - enqueueSnackbar(`Failed to fetch study (reason=${reason})`, { - variant: "error", - }) + if (reason !== undefined) { + enqueueSnackbar(`Failed to fetch study (reason=${reason})`, { + variant: "error", + }) + } console.log(err) }) } @@ -95,11 +102,38 @@ export const actionCreator = () => { }) } + const saveNote = (studyId: number, note: Note): Promise => { + return saveNoteAPI(studyId, note) + .then(() => { + const newStudy = Object.assign({}, studyDetails[studyId]) + newStudy.note = note + setStudyDetailState(studyId, newStudy) + enqueueSnackbar(`Success to save the note`, { + variant: "success", + }) + }) + .catch((err) => { + if (err.response.status === 409) { + const newStudy = Object.assign({}, studyDetails[studyId]) + newStudy.note = err.response.data.note + setStudyDetailState(studyId, newStudy) + } + const reason = err.response?.data.reason + if (reason !== undefined) { + enqueueSnackbar(`Failed: ${reason}`, { + variant: "error", + }) + } + throw err + }) + } + return { updateStudyDetail, updateStudySummaries, createNewStudy, deleteStudy, + saveNote, } } diff --git a/optuna_dashboard/static/apiClient.ts b/optuna_dashboard/static/apiClient.ts index 6dd934a4..55495b77 100644 --- a/optuna_dashboard/static/apiClient.ts +++ b/optuna_dashboard/static/apiClient.ts @@ -45,6 +45,10 @@ interface StudyDetailResponse { intersection_search_space: SearchSpace[] union_search_space: SearchSpace[] has_intermediate_values: boolean + note: { + version: number + body: string + } } export const getStudyDetailAPI = ( @@ -62,6 +66,7 @@ export const getStudyDetailAPI = ( return convertTrialResponse(trial) }) return { + id: studyId, name: res.data.name, datetime_start: new Date(res.data.datetime_start), directions: res.data.directions, @@ -72,6 +77,7 @@ export const getStudyDetailAPI = ( union_search_space: res.data.union_search_space, intersection_search_space: res.data.intersection_search_space, has_intermediate_values: res.data.has_intermediate_values, + note: res.data.note, } }) } @@ -178,6 +184,17 @@ export const deleteStudyAPI = (studyId: number) => { }) } +export const saveNoteAPI = ( + studyId: number, + note: { version: number; body: string } +): Promise => { + return axiosInstance + .put(`/api/studies/${studyId}/note`, note) + .then((res) => { + return + }) +} + interface ParamImportancesResponse { target_name: string param_importances: ParamImportance[] diff --git a/optuna_dashboard/static/components/Note.tsx b/optuna_dashboard/static/components/Note.tsx new file mode 100644 index 00000000..82176327 --- /dev/null +++ b/optuna_dashboard/static/components/Note.tsx @@ -0,0 +1,128 @@ +import { + Box, + Button, + Card, + CardContent, + TextField, + Typography, + useTheme, +} from "@mui/material" +import React, { FC, createRef, useState, useEffect } from "react" +import LoadingButton from "@mui/lab/LoadingButton" +import SaveIcon from "@mui/icons-material/Save" + +import { actionCreator } from "../action" + +export const Note: FC<{ + studyId: number + latestNote: Note +}> = ({ studyId, latestNote }) => { + const theme = useTheme() + const [saving, setSaving] = useState(false) + const [edited, setEdited] = useState(false) + const [curNote, setCurNote] = useState({ version: 0, body: "" }) + const textAreaRef = createRef() + const action = actionCreator() + const notLatest = latestNote.version > curNote.version + + useEffect(() => { + setCurNote(latestNote) + }, []) + useEffect(() => { + if (edited) { + window.onbeforeunload = (e) => { + e.returnValue = "Are you okay to discard your changes?" + } + } else { + window.onbeforeunload = null + } + }, [edited]) + const handleSave = () => { + const nextVersion = curNote.version + 1 + const newNote = { + version: nextVersion, + body: textAreaRef.current ? textAreaRef.current.value : "", + } + setSaving(true) + action + .saveNote(studyId, newNote) + .then(() => { + setCurNote(newNote) + window.onbeforeunload = null + }) + .finally(() => { + setSaving(false) + }) + } + const handleRefresh = () => { + if (!textAreaRef.current) { + console.log("Unexpectedly, textarea is not found.") + return + } + textAreaRef.current.value = latestNote.body + setCurNote(latestNote) + window.onbeforeunload = null + } + + return ( + + + + Note + + { + const cur = textAreaRef.current ? textAreaRef.current.value : "" + setEdited(cur !== curNote.body) + }} + /> + + {notLatest && !saving && ( + <> + + The text you are editing has updated. Do you want to discard + your changes and refresh the textarea? + + + + )} + + } + variant="contained" + disabled={!edited} + > + Save + + + + + ) +} diff --git a/optuna_dashboard/static/components/StudyDetail.tsx b/optuna_dashboard/static/components/StudyDetail.tsx index b7f4abc4..1715e09d 100644 --- a/optuna_dashboard/static/components/StudyDetail.tsx +++ b/optuna_dashboard/static/components/StudyDetail.tsx @@ -37,6 +37,7 @@ import { GraphIntermediateValues } from "./GraphIntermediateValues" import { GraphSlice } from "./GraphSlice" import { GraphHistory } from "./GraphHistory" import { GraphParetoFront } from "./GraphParetoFront" +import { Note } from "./Note" import { actionCreator } from "../action" import { studyDetailsState } from "../state" @@ -58,9 +59,10 @@ interface Preference { graphParetoFrontChecked: boolean graphParallelCoordinateChecked: boolean graphIntermediateValuesChecked: boolean - edfChecked: boolean + graphEdfChecked: boolean graphHyperparameterImportancesChecked: boolean graphSliceChecked: boolean + noteEditorChecked: boolean reloadInterval: number } @@ -78,9 +80,10 @@ export const StudyDetail: FC<{ graphParetoFrontChecked: true, graphParallelCoordinateChecked: true, graphIntermediateValuesChecked: true, - edfChecked: true, + graphEdfChecked: true, graphHyperparameterImportancesChecked: true, graphSliceChecked: true, + noteEditorChecked: true, reloadInterval: 10, }) useEffect(() => { @@ -101,7 +104,7 @@ export const StudyDetail: FC<{ const handleClose = () => { setPrefOpen(false) } - const handleChartShownChange = ( + const handlePreferenceOnChange = ( event: React.ChangeEvent ) => { setPreferences({ @@ -159,7 +162,7 @@ export const StudyDetail: FC<{ control={ } @@ -172,7 +175,7 @@ export const StudyDetail: FC<{ control={ } @@ -182,7 +185,7 @@ export const StudyDetail: FC<{ control={ } @@ -197,7 +200,7 @@ export const StudyDetail: FC<{ control={ } @@ -206,8 +209,8 @@ export const StudyDetail: FC<{ } @@ -217,7 +220,7 @@ export const StudyDetail: FC<{ control={ } @@ -227,12 +230,23 @@ export const StudyDetail: FC<{ control={ } label="Slice" /> + Editor + + } + label="NoteEditor" + /> @@ -354,7 +368,7 @@ export const StudyDetail: FC<{ ) : null} - {preferences.edfChecked ? ( + {preferences.graphEdfChecked ? ( @@ -382,6 +396,9 @@ export const StudyDetail: FC<{ + {studyDetail !== null && preferences.noteEditorChecked ? ( + + ) : null} diff --git a/optuna_dashboard/static/types/index.d.ts b/optuna_dashboard/static/types/index.d.ts index 4e62e683..d62102ce 100644 --- a/optuna_dashboard/static/types/index.d.ts +++ b/optuna_dashboard/static/types/index.d.ts @@ -45,6 +45,11 @@ declare interface Attribute { value: string } +declare interface Note { + version: number + body: string +} + declare interface Trial { trial_id: number study_id: number @@ -70,6 +75,7 @@ declare interface StudySummary { } declare interface StudyDetail { + id: number name: string directions: StudyDirection[] datetime_start: Date @@ -78,6 +84,7 @@ declare interface StudyDetail { intersection_search_space: SearchSpace[] union_search_space: SearchSpace[] has_intermediate_values: boolean + note: Note } declare interface StudyDetails { diff --git a/package-lock.json b/package-lock.json index 90d2df10..4c468579 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@mui/icons-material": "^5.4.4", + "@mui/lab": "^5.0.0-alpha.73", "@mui/material": "^5.4.4", "axios": "^0.21.2", "notistack": "^2.0.3", @@ -2740,6 +2741,75 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@date-io/core": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/core/-/core-2.13.1.tgz", + "integrity": "sha512-pVI9nfkf2qClb2Cxdq0Q4zJhdawMG4ybWZUVGifT78FDwzRMX2SwXBb55s5NRJk0HcIicDuxktmCtemZqMH1Zg==" + }, + "node_modules/@date-io/date-fns": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-2.13.1.tgz", + "integrity": "sha512-8fmfwjiLMpFLD+t4NBwDx0eblWnNcgt4NgfT/uiiQTGI81fnPu9tpBMYdAcuWxaV7LLpXgzLBx1SYWAMDVUDQQ==", + "dependencies": { + "@date-io/core": "^2.13.1" + }, + "peerDependencies": { + "date-fns": "^2.0.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + } + } + }, + "node_modules/@date-io/dayjs": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/dayjs/-/dayjs-2.13.1.tgz", + "integrity": "sha512-5bL4WWWmlI4uGZVScANhHJV7Mjp93ec2gNeUHDqqLaMZhp51S0NgD25oqj/k0LqBn1cdU2MvzNpk/ObMmVv5cQ==", + "dependencies": { + "@date-io/core": "^2.13.1" + }, + "peerDependencies": { + "dayjs": "^1.8.17" + }, + "peerDependenciesMeta": { + "dayjs": { + "optional": true + } + } + }, + "node_modules/@date-io/luxon": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.13.1.tgz", + "integrity": "sha512-yG+uM7lXfwLyKKEwjvP8oZ7qblpmfl9gxQYae55ifbwiTs0CoCTkYkxEaQHGkYtTqGTzLqcb0O9Pzx6vgWg+yg==", + "dependencies": { + "@date-io/core": "^2.13.1" + }, + "peerDependencies": { + "luxon": "^1.21.3 || ^2.x" + }, + "peerDependenciesMeta": { + "luxon": { + "optional": true + } + } + }, + "node_modules/@date-io/moment": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-2.13.1.tgz", + "integrity": "sha512-XX1X/Tlvl3TdqQy2j0ZUtEJV6Rl8tOyc5WOS3ki52He28Uzme4Ro/JuPWTMBDH63weSWIZDlbR7zBgp3ZA2y1A==", + "dependencies": { + "@date-io/core": "^2.13.1" + }, + "peerDependencies": { + "moment": "^2.24.0" + }, + "peerDependenciesMeta": { + "moment": { + "optional": true + } + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.3.tgz", @@ -3681,6 +3751,91 @@ } } }, + "node_modules/@mui/lab": { + "version": "5.0.0-alpha.73", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.73.tgz", + "integrity": "sha512-10Uj0Atc7gBTXKX4VV38P6RdqTQrJZxcl3HeEcytIO1S3NAGfc7gZ3Hdpnhtj5U8kcRJZZPH9LtrBbMZzxU/1A==", + "dependencies": { + "@babel/runtime": "^7.17.2", + "@date-io/date-fns": "^2.13.1", + "@date-io/dayjs": "^2.13.1", + "@date-io/luxon": "^2.13.1", + "@date-io/moment": "^2.13.1", + "@mui/base": "5.0.0-alpha.72", + "@mui/system": "^5.5.1", + "@mui/utils": "^5.4.4", + "clsx": "^1.1.1", + "prop-types": "^15.7.2", + "react-is": "^17.0.2", + "react-transition-group": "^4.4.2", + "rifm": "^0.12.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^16.8.6 || ^17.0.0", + "date-fns": "^2.25.0", + "dayjs": "^1.10.7", + "luxon": "^1.28.0 || ^2.0.0", + "moment": "^2.29.1", + "react": "^17.0.0", + "react-dom": "^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@mui/lab/node_modules/@mui/base": { + "version": "5.0.0-alpha.72", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.72.tgz", + "integrity": "sha512-WCAooa9eqbsC68LhyKtDBRumH4hV1eRZ0A3SDKFHSwYG9fCOdsFv/H1dIYRJM0rwD45bMnuDiG3Qmx7YsTiptw==", + "dependencies": { + "@babel/runtime": "^7.17.2", + "@emotion/is-prop-valid": "^1.1.2", + "@mui/utils": "^5.4.4", + "@popperjs/core": "^2.11.3", + "clsx": "^1.1.1", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui" + }, + "peerDependencies": { + "@types/react": "^16.8.6 || ^17.0.0", + "react": "^17.0.0", + "react-dom": "^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.4.4.tgz", @@ -3782,17 +3937,17 @@ } }, "node_modules/@mui/system": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.4.4.tgz", - "integrity": "sha512-Zjbztq2o/VRuRRCWjG44juRrPKYLQMqtQpMHmMttGu5BnvK6PAPW3WOY0r1JCAwLhbd8Kug9nyhGQYKETjo+tQ==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.5.1.tgz", + "integrity": "sha512-2hynI4hN8304hOCT8sc4knJviwUUYJ7XK3mXwQ0nagVGOPnWSOad/nYADm7K0vdlCeUXLIbDbe7oNN3Kaiu2kA==", "dependencies": { "@babel/runtime": "^7.17.2", "@mui/private-theming": "^5.4.4", "@mui/styled-engine": "^5.4.4", - "@mui/types": "^7.1.2", + "@mui/types": "^7.1.3", "@mui/utils": "^5.4.4", "clsx": "^1.1.1", - "csstype": "^3.0.10", + "csstype": "^3.0.11", "prop-types": "^15.7.2" }, "engines": { @@ -3821,9 +3976,9 @@ } }, "node_modules/@mui/types": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.2.tgz", - "integrity": "sha512-SD7O1nVzqG+ckQpFjDhXPZjRceB8HQFHEvdLLrPhlJy4lLbwEBbxK74Tj4t6Jgk0fTvLJisuwOutrtYe9P/xBQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.3.tgz", + "integrity": "sha512-DDF0UhMBo4Uezlk+6QxrlDbchF79XG6Zs0zIewlR4c0Dt6GKVFfUtzPtHCH1tTbcSlq/L2bGEdiaoHBJ9Y1gSA==", "peerDependencies": { "@types/react": "*" }, @@ -3891,9 +4046,9 @@ } }, "node_modules/@popperjs/core": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", - "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.4.tgz", + "integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==", "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -10287,6 +10442,14 @@ "node": ">=0.10.0" } }, + "node_modules/rifm": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.12.1.tgz", + "integrity": "sha512-OGA1Bitg/dSJtI/c4dh90svzaUPt228kzFsUkJbtA2c964IqEAwWXeL9ZJi86xWv3j5SMqRvGULl7bA6cK0Bvg==", + "peerDependencies": { + "react": ">=16.8" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -13674,6 +13837,43 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@date-io/core": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/core/-/core-2.13.1.tgz", + "integrity": "sha512-pVI9nfkf2qClb2Cxdq0Q4zJhdawMG4ybWZUVGifT78FDwzRMX2SwXBb55s5NRJk0HcIicDuxktmCtemZqMH1Zg==" + }, + "@date-io/date-fns": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-2.13.1.tgz", + "integrity": "sha512-8fmfwjiLMpFLD+t4NBwDx0eblWnNcgt4NgfT/uiiQTGI81fnPu9tpBMYdAcuWxaV7LLpXgzLBx1SYWAMDVUDQQ==", + "requires": { + "@date-io/core": "^2.13.1" + } + }, + "@date-io/dayjs": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/dayjs/-/dayjs-2.13.1.tgz", + "integrity": "sha512-5bL4WWWmlI4uGZVScANhHJV7Mjp93ec2gNeUHDqqLaMZhp51S0NgD25oqj/k0LqBn1cdU2MvzNpk/ObMmVv5cQ==", + "requires": { + "@date-io/core": "^2.13.1" + } + }, + "@date-io/luxon": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/luxon/-/luxon-2.13.1.tgz", + "integrity": "sha512-yG+uM7lXfwLyKKEwjvP8oZ7qblpmfl9gxQYae55ifbwiTs0CoCTkYkxEaQHGkYtTqGTzLqcb0O9Pzx6vgWg+yg==", + "requires": { + "@date-io/core": "^2.13.1" + } + }, + "@date-io/moment": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-2.13.1.tgz", + "integrity": "sha512-XX1X/Tlvl3TdqQy2j0ZUtEJV6Rl8tOyc5WOS3ki52He28Uzme4Ro/JuPWTMBDH63weSWIZDlbR7zBgp3ZA2y1A==", + "requires": { + "@date-io/core": "^2.13.1" + } + }, "@discoveryjs/json-ext": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.3.tgz", @@ -14375,6 +14575,42 @@ "@babel/runtime": "^7.17.2" } }, + "@mui/lab": { + "version": "5.0.0-alpha.73", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.73.tgz", + "integrity": "sha512-10Uj0Atc7gBTXKX4VV38P6RdqTQrJZxcl3HeEcytIO1S3NAGfc7gZ3Hdpnhtj5U8kcRJZZPH9LtrBbMZzxU/1A==", + "requires": { + "@babel/runtime": "^7.17.2", + "@date-io/date-fns": "^2.13.1", + "@date-io/dayjs": "^2.13.1", + "@date-io/luxon": "^2.13.1", + "@date-io/moment": "^2.13.1", + "@mui/base": "5.0.0-alpha.72", + "@mui/system": "^5.5.1", + "@mui/utils": "^5.4.4", + "clsx": "^1.1.1", + "prop-types": "^15.7.2", + "react-is": "^17.0.2", + "react-transition-group": "^4.4.2", + "rifm": "^0.12.1" + }, + "dependencies": { + "@mui/base": { + "version": "5.0.0-alpha.72", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.72.tgz", + "integrity": "sha512-WCAooa9eqbsC68LhyKtDBRumH4hV1eRZ0A3SDKFHSwYG9fCOdsFv/H1dIYRJM0rwD45bMnuDiG3Qmx7YsTiptw==", + "requires": { + "@babel/runtime": "^7.17.2", + "@emotion/is-prop-valid": "^1.1.2", + "@mui/utils": "^5.4.4", + "@popperjs/core": "^2.11.3", + "clsx": "^1.1.1", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + } + } + } + }, "@mui/material": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.4.4.tgz", @@ -14415,24 +14651,24 @@ } }, "@mui/system": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.4.4.tgz", - "integrity": "sha512-Zjbztq2o/VRuRRCWjG44juRrPKYLQMqtQpMHmMttGu5BnvK6PAPW3WOY0r1JCAwLhbd8Kug9nyhGQYKETjo+tQ==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.5.1.tgz", + "integrity": "sha512-2hynI4hN8304hOCT8sc4knJviwUUYJ7XK3mXwQ0nagVGOPnWSOad/nYADm7K0vdlCeUXLIbDbe7oNN3Kaiu2kA==", "requires": { "@babel/runtime": "^7.17.2", "@mui/private-theming": "^5.4.4", "@mui/styled-engine": "^5.4.4", - "@mui/types": "^7.1.2", + "@mui/types": "^7.1.3", "@mui/utils": "^5.4.4", "clsx": "^1.1.1", - "csstype": "^3.0.10", + "csstype": "^3.0.11", "prop-types": "^15.7.2" } }, "@mui/types": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.2.tgz", - "integrity": "sha512-SD7O1nVzqG+ckQpFjDhXPZjRceB8HQFHEvdLLrPhlJy4lLbwEBbxK74Tj4t6Jgk0fTvLJisuwOutrtYe9P/xBQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.3.tgz", + "integrity": "sha512-DDF0UhMBo4Uezlk+6QxrlDbchF79XG6Zs0zIewlR4c0Dt6GKVFfUtzPtHCH1tTbcSlq/L2bGEdiaoHBJ9Y1gSA==", "requires": {} }, "@mui/utils": { @@ -14474,9 +14710,9 @@ } }, "@popperjs/core": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.2.tgz", - "integrity": "sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA==" + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.4.tgz", + "integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==" }, "@sinonjs/commons": { "version": "1.8.3", @@ -19265,6 +19501,12 @@ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true }, + "rifm": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.12.1.tgz", + "integrity": "sha512-OGA1Bitg/dSJtI/c4dh90svzaUPt228kzFsUkJbtA2c964IqEAwWXeL9ZJi86xWv3j5SMqRvGULl7bA6cK0Bvg==", + "requires": {} + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", diff --git a/package.json b/package.json index 3338baf6..39663336 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "license": "MIT", "dependencies": { "@mui/icons-material": "^5.4.4", + "@mui/lab": "^5.0.0-alpha.73", "@mui/material": "^5.4.4", "axios": "^0.21.2", "notistack": "^2.0.3", diff --git a/python_tests/test_note.py b/python_tests/test_note.py new file mode 100644 index 00000000..f69de768 --- /dev/null +++ b/python_tests/test_note.py @@ -0,0 +1,19 @@ +from unittest import TestCase +from unittest.mock import patch + +from optuna_dashboard import _note as note + + +class NoteTestCase(TestCase): + @patch("optuna_dashboard._note.SYSTEM_ATTR_MAX_LENGTH", 5) + def test_split_and_concat_note_body(self) -> None: + for dummy_body_str, attr_len in [ + ("012", 1), + ("01234", 1), + ("012345", 2), + ]: + with self.subTest(f"with_{dummy_body_str}_{attr_len}"): + attrs = note.split_body(dummy_body_str) + assert len(attrs) == attr_len + actual = note.concat_body(attrs) + assert dummy_body_str == actual