diff --git a/webapp/src/components/common/fieldEditors/ListFE/index.tsx b/webapp/src/components/common/fieldEditors/ListFE/index.tsx
index d72fa71c81..29f3518e26 100644
--- a/webapp/src/components/common/fieldEditors/ListFE/index.tsx
+++ b/webapp/src/components/common/fieldEditors/ListFE/index.tsx
@@ -42,9 +42,9 @@ import {
import { makeLabel, makeListItems } from "./utils";
interface ListFEProps
{
- defaultValue?: readonly TItem[];
- value?: readonly TItem[];
- options: readonly TOption[];
+ defaultValue?: TItem[];
+ value?: TItem[];
+ options?: TOption[];
label?: string;
getOptionLabel?: (option: TOption) => string;
getValueLabel?: (value: TItem) => string;
@@ -64,7 +64,7 @@ function ListFE(props: ListFEProps) {
value,
defaultValue,
label,
- options,
+ options = [],
getOptionLabel = makeLabel,
getValueLabel = makeLabel,
optionToItem = (option: TOption) => option as unknown as TItem,
diff --git a/webapp/src/hooks/useOperationInProgressCount.ts b/webapp/src/hooks/useOperationInProgressCount.ts
new file mode 100644
index 0000000000..bc71fb677a
--- /dev/null
+++ b/webapp/src/hooks/useOperationInProgressCount.ts
@@ -0,0 +1,51 @@
+import { useMemo, useState } from "react";
+import * as R from "ramda";
+
+/**
+ * Hook to tracks the number of CRUD operations in progress.
+ *
+ * @returns An object containing methods to increment, decrement,
+ * and retrieve the count of each operation type.
+ */
+function useOperationInProgressCount() {
+ const [opsInProgressCount, setOpsInProgressCount] = useState({
+ create: 0,
+ read: 0,
+ update: 0,
+ delete: 0,
+ });
+
+ const makeOperationMethods = (
+ operation: keyof typeof opsInProgressCount,
+ ) => ({
+ increment: (number = 1) => {
+ setOpsInProgressCount((prev) => ({
+ ...prev,
+ [operation]: prev[operation] + number,
+ }));
+ },
+ decrement: (number = 1) => {
+ setOpsInProgressCount((prev) => ({
+ ...prev,
+ [operation]: Math.max(prev[operation] - number, 0),
+ }));
+ },
+ total: opsInProgressCount[operation],
+ });
+
+ const methods = useMemo(
+ () => ({
+ createOps: makeOperationMethods("create"),
+ readOps: makeOperationMethods("read"),
+ updateOps: makeOperationMethods("update"),
+ deleteOps: makeOperationMethods("delete"),
+ totalOps: Object.values(opsInProgressCount).reduce(R.add, 0),
+ }),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [opsInProgressCount],
+ );
+
+ return methods;
+}
+
+export default useOperationInProgressCount;
diff --git a/webapp/src/hooks/useUpdateEffectOnce.ts b/webapp/src/hooks/useUpdateEffectOnce.ts
new file mode 100644
index 0000000000..61fedd115e
--- /dev/null
+++ b/webapp/src/hooks/useUpdateEffectOnce.ts
@@ -0,0 +1,23 @@
+import { useEffect, useRef } from "react";
+import { useUpdateEffect } from "react-use";
+
+/**
+ * Hook that runs the effect only at the first dependencies update.
+ * It behaves like the `useEffect` hook, but it skips the initial run,
+ * and the runs following the first update.
+ *
+ * @param effect - The effect function to run.
+ * @param deps - An array of dependencies to watch for changes.
+ */
+const useUpdateEffectOnce: typeof useEffect = (effect, deps) => {
+ const hasUpdated = useRef(false);
+
+ useUpdateEffect(() => {
+ if (!hasUpdated.current) {
+ hasUpdated.current = true;
+ return effect();
+ }
+ }, deps);
+};
+
+export default useUpdateEffectOnce;
diff --git a/webapp/src/i18n.ts b/webapp/src/i18n.ts
index d1d95a0574..980cffbf89 100644
--- a/webapp/src/i18n.ts
+++ b/webapp/src/i18n.ts
@@ -2,34 +2,35 @@ import i18n from "i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
+import { version } from "../package.json";
-export default function i18nInit(version = "unknown") {
- i18n
- // load translation using xhr -> see /public/locales
- // learn more: https://github.com/i18next/i18next-xhr-backend
- .use(Backend)
- // detect user language
- // learn more: https://github.com/i18next/i18next-browser-languageDetector
- .use(LanguageDetector)
- // pass the i18n instance to react-i18next.
- .use(initReactI18next)
- // init i18next
- // for all options read: https://www.i18next.com/overview/configuration-options
- .init({
- fallbackLng: "en",
- backend: {
- loadPath: `${
- import.meta.env.BASE_URL
- }locales/{{lng}}/{{ns}}.json?v=${version}`,
- },
- react: {
- useSuspense: false,
- },
- interpolation: {
- escapeValue: false, // not needed for react as it escapes by default
- },
- ns: ["main"],
- defaultNS: "main",
- returnNull: false,
- });
-}
+i18n
+ // load translation using xhr -> see /public/locales
+ // learn more: https://github.com/i18next/i18next-xhr-backend
+ .use(Backend)
+ // detect user language
+ // learn more: https://github.com/i18next/i18next-browser-languageDetector
+ .use(LanguageDetector)
+ // pass the i18n instance to react-i18next.
+ .use(initReactI18next)
+ // init i18next
+ // for all options read: https://www.i18next.com/overview/configuration-options
+ .init({
+ fallbackLng: "en",
+ backend: {
+ loadPath: `${
+ import.meta.env.BASE_URL
+ }locales/{{lng}}/{{ns}}.json?v=${version}`,
+ },
+ react: {
+ useSuspense: false,
+ },
+ interpolation: {
+ escapeValue: false, // not needed for react as it escapes by default
+ },
+ ns: ["main"],
+ defaultNS: "main",
+ returnNull: false,
+ });
+
+export default i18n;
diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx
index 80dec85813..2c6792f7a8 100644
--- a/webapp/src/index.tsx
+++ b/webapp/src/index.tsx
@@ -1,7 +1,6 @@
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { StyledEngineProvider } from "@mui/material";
-import i18nInit from "./i18n";
import "./index.css";
import App from "./components/App";
import { Config, initConfig } from "./services/config";
@@ -15,8 +14,6 @@ initConfig((config: Config) => {
window.location.reload();
}
- i18nInit(config.version.gitcommit);
-
const container = document.getElementById("root") as HTMLElement;
const root = createRoot(container);
diff --git a/webapp/src/services/api/constants.ts b/webapp/src/services/api/constants.ts
deleted file mode 100644
index 421ab814ed..0000000000
--- a/webapp/src/services/api/constants.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-const API_URL_BASE = "v1";
-const STUDIES_API_URL = `${API_URL_BASE}/studies/{studyId}`;
-
-export const TABLE_MODE_API_URL = `${STUDIES_API_URL}/tablemode`;
diff --git a/webapp/src/services/api/studies/config/thematicTrimming/index.ts b/webapp/src/services/api/studies/config/thematicTrimming/index.ts
new file mode 100644
index 0000000000..0228eb98fc
--- /dev/null
+++ b/webapp/src/services/api/studies/config/thematicTrimming/index.ts
@@ -0,0 +1,25 @@
+import type {
+ GetThematicTrimmingConfigParams,
+ SetThematicTrimmingConfigParams,
+ ThematicTrimmingConfig,
+} from "./types";
+import client from "../../../client";
+import { format } from "../../../../../utils/stringUtils";
+
+const URL = "/v1/studies/{studyId}/config/thematictrimming/form";
+
+export async function getThematicTrimmingConfig({
+ studyId,
+}: GetThematicTrimmingConfigParams) {
+ const url = format(URL, { studyId });
+ const res = await client.get(url);
+ return res.data;
+}
+
+export async function setThematicTrimmingConfig({
+ studyId,
+ config,
+}: SetThematicTrimmingConfigParams) {
+ const url = format(URL, { studyId });
+ await client.put(url, config);
+}
diff --git a/webapp/src/services/api/studies/config/thematicTrimming/types.ts b/webapp/src/services/api/studies/config/thematicTrimming/types.ts
new file mode 100644
index 0000000000..1137a9536f
--- /dev/null
+++ b/webapp/src/services/api/studies/config/thematicTrimming/types.ts
@@ -0,0 +1,111 @@
+import { StudyMetadata } from "../../../../../common/types";
+
+export interface ThematicTrimmingConfig {
+ ovCost: boolean;
+ opCost: boolean;
+ mrgPrice: boolean;
+ co2Emis: boolean;
+ dtgByPlant: boolean;
+ balance: boolean;
+ rowBal: boolean;
+ psp: boolean;
+ miscNdg: boolean;
+ load: boolean;
+ hRor: boolean;
+ wind: boolean;
+ solar: boolean;
+ nuclear: boolean;
+ lignite: boolean;
+ coal: boolean;
+ gas: boolean;
+ oil: boolean;
+ mixFuel: boolean;
+ miscDtg: boolean;
+ hStor: boolean;
+ hPump: boolean;
+ hLev: boolean;
+ hInfl: boolean;
+ hOvfl: boolean;
+ hVal: boolean;
+ hCost: boolean;
+ unspEnrg: boolean;
+ spilEnrg: boolean;
+ lold: boolean;
+ lolp: boolean;
+ avlDtg: boolean;
+ dtgMrg: boolean;
+ maxMrg: boolean;
+ npCost: boolean;
+ npCostByPlant: boolean;
+ nodu: boolean;
+ noduByPlant: boolean;
+ flowLin: boolean;
+ ucapLin: boolean;
+ loopFlow: boolean;
+ flowQuad: boolean;
+ congFeeAlg: boolean;
+ congFeeAbs: boolean;
+ margCost: boolean;
+ congProbPlus: boolean;
+ congProbMinus: boolean;
+ hurdleCost: boolean;
+ // Since v8.1
+ resGenerationByPlant?: boolean;
+ miscDtg2?: boolean;
+ miscDtg3?: boolean;
+ miscDtg4?: boolean;
+ windOffshore?: boolean;
+ windOnshore?: boolean;
+ solarConcrt?: boolean;
+ solarPv?: boolean;
+ solarRooft?: boolean;
+ renw1?: boolean;
+ renw2?: boolean;
+ renw3?: boolean;
+ renw4?: boolean;
+ // Since v8.3
+ dens?: boolean;
+ profitByPlant?: boolean;
+ // Since v8.6
+ stsInjByPlant?: boolean;
+ stsWithdrawalByPlant?: boolean;
+ stsLvlByPlant?: boolean;
+ pspOpenInjection?: boolean;
+ pspOpenWithdrawal?: boolean;
+ pspOpenLevel?: boolean;
+ pspClosedInjection?: boolean;
+ pspClosedWithdrawal?: boolean;
+ pspClosedLevel?: boolean;
+ pondageInjection?: boolean;
+ pondageWithdrawal?: boolean;
+ pondageLevel?: boolean;
+ batteryInjection?: boolean;
+ batteryWithdrawal?: boolean;
+ batteryLevel?: boolean;
+ other1Injection?: boolean;
+ other1Withdrawal?: boolean;
+ other1Level?: boolean;
+ other2Injection?: boolean;
+ other2Withdrawal?: boolean;
+ other2Level?: boolean;
+ other3Injection?: boolean;
+ other3Withdrawal?: boolean;
+ other3Level?: boolean;
+ other4Injection?: boolean;
+ other4Withdrawal?: boolean;
+ other4Level?: boolean;
+ other5Injection?: boolean;
+ other5Withdrawal?: boolean;
+ other5Level?: boolean;
+ // Since v8.8
+ stsCashflowByCluster?: boolean;
+}
+
+export interface GetThematicTrimmingConfigParams {
+ studyId: StudyMetadata["id"];
+}
+
+export interface SetThematicTrimmingConfigParams {
+ studyId: StudyMetadata["id"];
+ config: ThematicTrimmingConfig;
+}
diff --git a/webapp/src/services/api/studies/raw/index.ts b/webapp/src/services/api/studies/raw/index.ts
new file mode 100644
index 0000000000..63e46dc31a
--- /dev/null
+++ b/webapp/src/services/api/studies/raw/index.ts
@@ -0,0 +1,13 @@
+import client from "../../client";
+import type { DownloadMatrixParams } from "./types";
+
+export async function downloadMatrix(params: DownloadMatrixParams) {
+ const { studyId, ...rest } = params;
+ const url = `v1/studies/${studyId}/raw/download`;
+ const res = await client.get(url, {
+ params: rest,
+ responseType: "blob",
+ });
+
+ return res.data;
+}
diff --git a/webapp/src/services/api/studies/raw/types.ts b/webapp/src/services/api/studies/raw/types.ts
new file mode 100644
index 0000000000..e524fbdc72
--- /dev/null
+++ b/webapp/src/services/api/studies/raw/types.ts
@@ -0,0 +1,9 @@
+import type { StudyMetadata } from "../../../../common/types";
+
+export interface DownloadMatrixParams {
+ studyId: StudyMetadata["id"];
+ path: string;
+ format?: "tsv" | "xlsx";
+ header?: boolean;
+ index?: boolean;
+}
diff --git a/webapp/src/services/api/studies/tableMode/constants.ts b/webapp/src/services/api/studies/tableMode/constants.ts
index 70526c7484..acef3d51f6 100644
--- a/webapp/src/services/api/studies/tableMode/constants.ts
+++ b/webapp/src/services/api/studies/tableMode/constants.ts
@@ -1,20 +1,30 @@
-const AREA = "area";
-const LINK = "link";
-const CLUSTER = "cluster";
-const RENEWABLE = "renewable";
-const BINDING_CONSTRAINT = "binding constraint";
+const AREA = "areas";
+const LINK = "links";
+const THERMAL = "thermals";
+const RENEWABLE = "renewables";
+const ST_STORAGE = "st-storages";
+const BINDING_CONSTRAINT = "binding-constraints";
export const TABLE_MODE_TYPES = [
AREA,
LINK,
- CLUSTER,
+ THERMAL,
RENEWABLE,
+ ST_STORAGE,
BINDING_CONSTRAINT,
] as const;
+// Deprecated types (breaking change from v2.16.8)
+export const TABLE_MODE_TYPES_ALIASES = {
+ area: AREA,
+ link: LINK,
+ cluster: THERMAL,
+ renewable: RENEWABLE,
+ "binding constraint": BINDING_CONSTRAINT,
+};
+
export const TABLE_MODE_COLUMNS_BY_TYPE = {
[AREA]: [
- // Optimization - Nodal optimization
"nonDispatchablePower",
"dispatchableHydroPower",
"otherDispatchablePower",
@@ -22,10 +32,9 @@ export const TABLE_MODE_COLUMNS_BY_TYPE = {
"spreadUnsuppliedEnergyCost",
"averageSpilledEnergyCost",
"spreadSpilledEnergyCost",
- // Optimization - Filtering
"filterSynthesis",
"filterYearByYear",
- // Adequacy patch
+ // Since v8.3
"adequacyPatchMode",
],
[LINK]: [
@@ -36,38 +45,79 @@ export const TABLE_MODE_COLUMNS_BY_TYPE = {
"assetType",
"linkStyle",
"linkWidth",
+ "comments",
"displayComments",
"filterSynthesis",
"filterYearByYear",
],
- [CLUSTER]: [
+ [THERMAL]: [
"group",
"enabled",
- "mustRun",
"unitCount",
"nominalCapacity",
+ "genTs",
"minStablePower",
- "spinning",
"minUpTime",
"minDownTime",
- "co2",
- "marginalCost",
- "fixedCost",
- "startupCost",
- "marketBidCost",
- "spreadCost",
- "tsGen",
+ "mustRun",
+ "spinning",
"volatilityForced",
"volatilityPlanned",
"lawForced",
"lawPlanned",
+ "marginalCost",
+ "spreadCost",
+ "fixedCost",
+ "startupCost",
+ "marketBidCost",
+ "co2",
+ // Since v8.6
+ "nh3",
+ "so2",
+ "nox",
+ "pm25",
+ "pm5",
+ "pm10",
+ "nmvoc",
+ "op1",
+ "op2",
+ "op3",
+ "op4",
+ "op5",
+ // Since v8.7
+ "costGeneration",
+ "efficiency",
+ "variableOMCost",
],
[RENEWABLE]: [
+ // Since v8.1
"group",
- "tsInterpretation",
"enabled",
+ "tsInterpretation",
"unitCount",
"nominalCapacity",
],
- [BINDING_CONSTRAINT]: ["type", "operator", "enabled"],
+ [ST_STORAGE]: [
+ // Since v8.6
+ "group",
+ "injectionNominalCapacity",
+ "withdrawalNominalCapacity",
+ "reservoirCapacity",
+ "efficiency",
+ "initialLevel",
+ "initialLevelOptim",
+ // Since v8.8
+ "enabled",
+ ],
+ [BINDING_CONSTRAINT]: [
+ "enabled",
+ "timeStep",
+ "operator",
+ "comments",
+ // Since v8.3
+ "filterSynthesis",
+ "filterYearByYear",
+ // Since v8.7
+ "group",
+ ],
} as const;
diff --git a/webapp/src/services/api/studies/tableMode/index.ts b/webapp/src/services/api/studies/tableMode/index.ts
index c6c4db5b80..03915b259e 100644
--- a/webapp/src/services/api/studies/tableMode/index.ts
+++ b/webapp/src/services/api/studies/tableMode/index.ts
@@ -1,35 +1,29 @@
-import { DeepPartial } from "react-hook-form";
-import { StudyMetadata } from "../../../../common/types";
import client from "../../client";
import { format } from "../../../../utils/stringUtils";
-import { TABLE_MODE_API_URL } from "../../constants";
-import type { TableData, TableModeColumnsForType, TableModeType } from "./type";
-import { toColumnApiName } from "./utils";
+import type {
+ GetTableModeParams,
+ SetTableModeParams,
+ TableData,
+ TableModeType,
+} from "./types";
+
+const TABLE_MODE_API_URL = `v1/studies/{studyId}/table-mode/{tableType}`;
export async function getTableMode(
- studyId: StudyMetadata["id"],
- type: T,
- columns: TableModeColumnsForType,
-): Promise {
- const url = format(TABLE_MODE_API_URL, { studyId });
- const res = await client.get(url, {
- params: {
- table_type: type,
- columns: columns.map(toColumnApiName).join(","),
- },
+ params: GetTableModeParams,
+) {
+ const { studyId, tableType, columns } = params;
+ const url = format(TABLE_MODE_API_URL, { studyId, tableType });
+
+ const res = await client.get(url, {
+ params: columns.length > 0 ? { columns: columns.join(",") } : {},
});
+
return res.data;
}
-export function setTableMode(
- studyId: StudyMetadata["id"],
- type: TableModeType,
- data: DeepPartial,
-): Promise {
- const url = format(TABLE_MODE_API_URL, { studyId });
- return client.put(url, data, {
- params: {
- table_type: type,
- },
- });
+export async function setTableMode(params: SetTableModeParams) {
+ const { studyId, tableType, data } = params;
+ const url = format(TABLE_MODE_API_URL, { studyId, tableType });
+ await client.put(url, data);
}
diff --git a/webapp/src/services/api/studies/tableMode/type.ts b/webapp/src/services/api/studies/tableMode/type.ts
deleted file mode 100644
index 71b751d875..0000000000
--- a/webapp/src/services/api/studies/tableMode/type.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { TABLE_MODE_COLUMNS_BY_TYPE, TABLE_MODE_TYPES } from "./constants";
-
-export type TableModeType = (typeof TABLE_MODE_TYPES)[number];
-
-export type TableModeColumnsForType = Array<
- (typeof TABLE_MODE_COLUMNS_BY_TYPE)[T][number]
->;
-
-export type TableData = Record<
- string,
- Record
->;
diff --git a/webapp/src/services/api/studies/tableMode/types.ts b/webapp/src/services/api/studies/tableMode/types.ts
new file mode 100644
index 0000000000..e20a167e27
--- /dev/null
+++ b/webapp/src/services/api/studies/tableMode/types.ts
@@ -0,0 +1,26 @@
+import { DeepPartial } from "react-hook-form";
+import type { StudyMetadata } from "../../../../common/types";
+import { TABLE_MODE_COLUMNS_BY_TYPE, TABLE_MODE_TYPES } from "./constants";
+
+export type TableModeType = (typeof TABLE_MODE_TYPES)[number];
+
+export type TableModeColumnsForType = Array<
+ (typeof TABLE_MODE_COLUMNS_BY_TYPE)[T][number]
+>;
+
+export type TableData = Record<
+ string,
+ Record
+>;
+
+export interface GetTableModeParams {
+ studyId: StudyMetadata["id"];
+ tableType: T;
+ columns: TableModeColumnsForType;
+}
+
+export interface SetTableModeParams {
+ studyId: StudyMetadata["id"];
+ tableType: TableModeType;
+ data: DeepPartial;
+}
diff --git a/webapp/src/services/api/studies/tableMode/utils.ts b/webapp/src/services/api/studies/tableMode/utils.ts
deleted file mode 100644
index e856785502..0000000000
--- a/webapp/src/services/api/studies/tableMode/utils.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { snakeCase } from "lodash";
-import { TableModeColumnsForType, TableModeType } from "./type";
-
-export function toColumnApiName(
- column: TableModeColumnsForType[number],
-) {
- if (column === "co2") {
- return "co2";
- }
- return snakeCase(column);
-}
diff --git a/webapp/src/services/api/studydata.ts b/webapp/src/services/api/studydata.ts
index 6558a3d15f..a39b55f20b 100644
--- a/webapp/src/services/api/studydata.ts
+++ b/webapp/src/services/api/studydata.ts
@@ -1,5 +1,4 @@
import {
- AllClustersAndLinks,
LinkCreationInfoDTO,
LinkInfoWithUI,
UpdateAreaUi,
@@ -16,7 +15,7 @@ export const createArea = async (
uuid: string,
name: string,
): Promise => {
- const res = await client.post(`/v1/studies/${uuid}/areas?uuid=${uuid}`, {
+ const res = await client.post(`/v1/studies/${uuid}/areas`, {
name,
type: "AREA",
});
@@ -27,10 +26,7 @@ export const createLink = async (
uuid: string,
linkCreationInfo: LinkCreationInfoDTO,
): Promise => {
- const res = await client.post(
- `/v1/studies/${uuid}/links?uuid=${uuid}`,
- linkCreationInfo,
- );
+ const res = await client.post(`/v1/studies/${uuid}/links`, linkCreationInfo);
return res.data;
};
@@ -41,7 +37,7 @@ export const updateAreaUI = async (
areaUi: UpdateAreaUi,
): Promise => {
const res = await client.put(
- `/v1/studies/${uuid}/areas/${areaId}/ui?uuid=${uuid}&area_id=${areaId}&layer=${layerId}`,
+ `/v1/studies/${uuid}/areas/${areaId}/ui?layer=${layerId}`,
areaUi,
);
return res.data;
@@ -51,9 +47,7 @@ export const deleteArea = async (
uuid: string,
areaId: string,
): Promise => {
- const res = await client.delete(
- `/v1/studies/${uuid}/areas/${areaId}?uuid=${uuid}&area_id=${areaId}`,
- );
+ const res = await client.delete(`/v1/studies/${uuid}/areas/${areaId}`);
return res.data;
};
@@ -63,7 +57,7 @@ export const deleteLink = async (
areaIdTo: string,
): Promise => {
const res = await client.delete(
- `/v1/studies/${uuid}/links/${areaIdFrom}/${areaIdTo}?uuid=${uuid}&area_from=${areaIdFrom}&area_to=${areaIdTo}`,
+ `/v1/studies/${uuid}/links/${areaIdFrom}/${areaIdTo}`,
);
return res.data;
};
@@ -156,13 +150,6 @@ export const createBindingConstraint = async (
return res.data;
};
-export const getClustersAndLinks = async (
- uuid: string,
-): Promise => {
- const res = await client.get(`/v1/studies/${uuid}/linksandclusters`);
- return res.data;
-};
-
interface GetAllLinksParams {
uuid: string;
withUi?: boolean;
@@ -176,10 +163,7 @@ export const getAllLinks = async (
params: T,
): Promise>> => {
const { uuid, withUi } = params;
- const res = await client.get(
- `/v1/studies/${uuid}/links${withUi ? `?with_ui=${withUi}` : ""}`,
- );
+ const withUiStr = withUi ? "with_ui=true" : "";
+ const res = await client.get(`/v1/studies/${uuid}/links?${withUiStr}`);
return res.data;
};
-
-export default {};
diff --git a/webapp/src/services/utils/localStorage.ts b/webapp/src/services/utils/localStorage.ts
index fa5c084c56..faa230e17a 100644
--- a/webapp/src/services/utils/localStorage.ts
+++ b/webapp/src/services/utils/localStorage.ts
@@ -4,6 +4,7 @@ import { UserInfo } from "../../common/types";
import { TableTemplate } from "../../components/App/Singlestudy/explore/TableModeList/utils";
import { StudiesSortConf, StudiesState } from "../../redux/ducks/studies";
import { UIState } from "../../redux/ducks/ui";
+import { TABLE_MODE_TYPES_ALIASES } from "../api/studies/tableMode/constants";
export enum StorageKey {
Version = "version",
@@ -46,7 +47,18 @@ function getItem(key: T): TypeFromKey[T] | null {
if (serializedState === null) {
return null;
}
- return JSON.parse(serializedState);
+ const res = JSON.parse(serializedState);
+
+ // Convert deprecated types to new ones (breaking change from v2.16.8)
+ if (key === StorageKey.StudiesModelTableModeTemplates) {
+ return res.map((template: Record) => ({
+ ...template,
+ // @ts-expect-error To ignore error TS2551
+ type: TABLE_MODE_TYPES_ALIASES[template.type] ?? template.type,
+ }));
+ }
+
+ return res;
} catch (err) {
return null;
}
diff --git a/webapp/src/utils/fileUtils.ts b/webapp/src/utils/fileUtils.ts
new file mode 100644
index 0000000000..0233d6f984
--- /dev/null
+++ b/webapp/src/utils/fileUtils.ts
@@ -0,0 +1,13 @@
+/**
+ * Triggers the download of a file with the given data and name.
+ *
+ * @param fileData - The data of the file to be downloaded.
+ * @param fileName - The name of the file to be downloaded.
+ */
+export function downloadFile(fileData: BlobPart, fileName: string) {
+ const link = document.createElement("a");
+ link.href = URL.createObjectURL(new Blob([fileData]));
+ link.download = fileName;
+ link.click();
+ URL.revokeObjectURL(link.href);
+}
diff --git a/webapp/src/utils/fnUtils.ts b/webapp/src/utils/fnUtils.ts
index 226e58c836..dd32ac4c97 100644
--- a/webapp/src/utils/fnUtils.ts
+++ b/webapp/src/utils/fnUtils.ts
@@ -11,3 +11,22 @@
export function voidFn(...args: TArgs) {
// Intentionally empty, as its purpose is to do nothing.
}
+
+/**
+ * A utility function that converts an unknown value to an Error object.
+ * If the value is already an Error object, it is returned as is.
+ * If the value is a string, it is used as the message for the new Error object.
+ * If the value is anything else, a new Error object with a generic message is created.
+ *
+ * @param error - The value to convert to an Error object.
+ * @returns An Error object.
+ */
+export function toError(error: unknown) {
+ if (error instanceof Error) {
+ return error;
+ }
+ if (typeof error === "string") {
+ return new Error(error);
+ }
+ return new Error("An unknown error occurred");
+}
diff --git a/webapp/src/utils/i18nUtils.ts b/webapp/src/utils/i18nUtils.ts
new file mode 100644
index 0000000000..c613deab68
--- /dev/null
+++ b/webapp/src/utils/i18nUtils.ts
@@ -0,0 +1,22 @@
+import i18n from "../i18n";
+
+/**
+ * Gets the current language used in the application.
+ *
+ * @returns The current language.
+ */
+export function getCurrentLanguage() {
+ return i18n.language;
+}
+
+/**
+ * Translates the given key and appends a colon (:) at the end
+ * with the appropriate spacing for the current language.
+ *
+ * @param key - The translation key.
+ * @returns The translated string with a colon (:) appended.
+ */
+export function translateWithColon(key: string): string {
+ const lang = i18n.language;
+ return `${i18n.t(key)}${lang.startsWith("fr") ? " " : ""}:`;
+}
diff --git a/webapp/src/utils/matrixUtils.ts b/webapp/src/utils/matrixUtils.ts
deleted file mode 100644
index c4380f8bcb..0000000000
--- a/webapp/src/utils/matrixUtils.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { MatrixType } from "../common/types";
-
-export function downloadMatrix(matrixData: MatrixType, fileName: string): void {
- const fileData = matrixData.data.map((row) => row.join("\t")).join("\n");
- const blob = new Blob([fileData], { type: "text/plain" });
- const a = document.createElement("a");
- a.download = fileName;
- a.href = URL.createObjectURL(blob);
- a.click();
- URL.revokeObjectURL(a.href);
-}
diff --git a/webapp/src/utils/tsUtils.ts b/webapp/src/utils/tsUtils.ts
index eb60713aa8..7acf6465a2 100644
--- a/webapp/src/utils/tsUtils.ts
+++ b/webapp/src/utils/tsUtils.ts
@@ -1,3 +1,16 @@
+import { O } from "ts-toolbelt";
+
+/**
+ * Allow to use `any` with `Promise` type without disabling ESLint rule.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type PromiseAny = Promise;
+
+/**
+ * Make all properties in T optional, except for those specified by K.
+ */
+export type PartialExceptFor = O.Required, K>;
+
export function tuple(...items: T): T {
return items;
}
diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts
index 94f1f95c30..86fb294d8b 100644
--- a/webapp/src/utils/validationUtils.ts
+++ b/webapp/src/utils/validationUtils.ts
@@ -4,7 +4,12 @@ import { t } from "i18next";
// Types
////////////////////////////////////////////////////////////////
-interface ValidationOptions {
+interface NumberValidationOptions {
+ min?: number;
+ max?: number;
+}
+
+interface StringValidationOptions {
existingValues?: string[];
excludedValues?: string[];
isCaseSensitive?: boolean;
@@ -12,8 +17,8 @@ interface ValidationOptions {
specialChars?: string;
allowSpaces?: boolean;
editedValue?: string;
- min?: number;
- max?: number;
+ minLength?: number;
+ maxLength?: number;
}
////////////////////////////////////////////////////////////////
@@ -35,13 +40,13 @@ interface ValidationOptions {
* @param [options.specialChars="&()_-"] - A string representing additional allowed characters outside the typical alphanumeric scope.
* @param [options.allowSpaces=true] - Flags if spaces are allowed in the value.
* @param [options.editedValue=""] - The current value being edited, to exclude it from duplicate checks.
- * @param [options.min=0] - Minimum length required for the string. Defaults to 0.
- * @param [options.max=255] - Maximum allowed length for the string. Defaults to 255.
+ * @param [options.minLength=0] - Minimum length required for the string. Defaults to 0.
+ * @param [options.maxLength=255] - Maximum allowed length for the string. Defaults to 255.
* @returns True if validation is successful, or a localized error message if it fails.
*/
export function validateString(
value: string,
- options?: ValidationOptions,
+ options?: StringValidationOptions,
): string | true {
const {
existingValues = [],
@@ -51,8 +56,8 @@ export function validateString(
allowSpaces = true,
specialChars = "&()_-",
editedValue = "",
- min = 0,
- max = 255,
+ minLength = 0,
+ maxLength = 255,
} = options || {};
const trimmedValue = value.trim();
@@ -65,12 +70,12 @@ export function validateString(
return t("form.field.spacesNotAllowed");
}
- if (trimmedValue.length < min) {
- return t("form.field.minValue", { 0: min });
+ if (trimmedValue.length < minLength) {
+ return t("form.field.minLength", { length: minLength });
}
- if (trimmedValue.length > max) {
- return t("form.field.maxValue", { 0: max });
+ if (trimmedValue.length > maxLength) {
+ return t("form.field.maxLength", { length: maxLength });
}
// Compiles a regex pattern based on allowed characters and flags.
@@ -99,7 +104,7 @@ export function validateString(
// Check for duplication against existing values.
if (existingValues.map(normalize).includes(comparisonValue)) {
- return t("form.field.duplicate", { 0: value });
+ return t("form.field.duplicate");
}
// Check for inclusion in the list of excluded values.
@@ -124,11 +129,11 @@ export function validatePassword(password: string): string | true {
}
if (trimmedPassword.length < 8) {
- return t("form.field.minValue", { 0: 8 });
+ return t("form.field.minLength", { length: 8 });
}
if (trimmedPassword.length > 50) {
- return t("form.field.maxValue", { 0: 50 });
+ return t("form.field.maxLength", { length: 50 });
}
if (!/[a-z]/.test(trimmedPassword)) {
@@ -150,6 +155,62 @@ export function validatePassword(password: string): string | true {
return true;
}
+/**
+ * Validates a number against specified numerical limits.
+ *
+ * @example
+ * validateNumber(5, { min: 0, max: 10 }); // true
+ * validateNumber(9, { min: 10, max: 20 }); // Error message
+ *
+ *
+ * @example With currying.
+ * const fn = validateNumber({ min: 0, max: 10 });
+ * fn(5); // true
+ * fn(11); // Error message
+ *
+ * @param value - The number to validate.
+ * @param [options] - Configuration options for validation.
+ * @param [options.min=Number.MIN_SAFE_INTEGER] - Minimum allowed value.
+ * @param [options.max=Number.MAX_SAFE_INTEGER] - Maximum allowed value.
+ * @returns True if validation is successful, or a localized error message if it fails.
+ */
+export function validateNumber(
+ value: number,
+ options?: NumberValidationOptions,
+): string | true;
+
+export function validateNumber(
+ options?: NumberValidationOptions,
+): (value: number) => string | true;
+
+export function validateNumber(
+ valueOrOpts?: number | NumberValidationOptions,
+ options: NumberValidationOptions = {},
+): (string | true) | ((value: number) => string | true) {
+ if (typeof valueOrOpts !== "number") {
+ return (v: number) => validateNumber(v, valueOrOpts);
+ }
+
+ const value = valueOrOpts;
+
+ if (!isFinite(value)) {
+ return t("form.field.invalidNumber", { value });
+ }
+
+ const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } =
+ options;
+
+ if (value < min) {
+ return t("form.field.minValue", { 0: min });
+ }
+
+ if (value > max) {
+ return t("form.field.maxValue", { 0: max });
+ }
+
+ return true;
+}
+
////////////////////////////////////////////////////////////////
// Utils
////////////////////////////////////////////////////////////////