diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx
index 984bea70e..32132fada 100644
--- a/app/components/chart-selection-tabs.tsx
+++ b/app/components/chart-selection-tabs.tsx
@@ -1,6 +1,8 @@
-import { Trans } from "@lingui/macro";
-import { Box, Button, Popover, Tab, Tabs, Theme } from "@mui/material";
+import { Trans, t } from "@lingui/macro";
+import { Box, Button, Popover, Tab, Tabs, Theme, Tooltip } from "@mui/material";
import { makeStyles } from "@mui/styles";
+import { useSession } from "next-auth/react";
+import { useRouter } from "next/router";
import React from "react";
import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
@@ -23,8 +25,11 @@ import {
} from "@/graphql/query-hooks";
import { Icon, IconName } from "@/icons";
import { useLocale } from "@/src";
+import { fetchChartConfig } from "@/utils/chart-config/api";
import { createChartId } from "@/utils/create-chart-id";
+import { getRouterChartId } from "@/utils/router/helpers";
import useEvent from "@/utils/use-event";
+import { useFetchData } from "@/utils/use-fetch-data";
type TabsState = {
popoverOpen: boolean;
@@ -277,14 +282,46 @@ const PublishChartButton = () => {
}
});
- return (
+ const { asPath } = useRouter();
+ const session = useSession();
+ const chartId = getRouterChartId(asPath);
+ const queryFn = React.useCallback(
+ () => fetchChartConfig(chartId ?? ""),
+ [chartId]
+ );
+ const { data: config, status } = useFetchData(queryFn, {
+ enable: !!(session.data?.user && chartId),
+ initialStatus: "fetching",
+ });
+
+ const editingPublishedChart =
+ session.data?.user.id && config?.user_id === session.data.user.id;
+
+ return status === "fetching" ? null : (
);
};
diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx
index b9ec4043b..faf65b724 100644
--- a/app/configurator/configurator-state.tsx
+++ b/app/configurator/configurator-state.tsx
@@ -57,6 +57,7 @@ import {
import { mapValueIrisToColor } from "@/configurator/components/ui-helpers";
import { FIELD_VALUE_NONE } from "@/configurator/constants";
import { toggleInteractiveFilterDataDimension } from "@/configurator/interactive-filters/interactive-filters-config-state";
+import { ParsedConfig } from "@/db/config";
import { DimensionValue, isGeoDimension } from "@/domain/data";
import { DEFAULT_DATA_SOURCE } from "@/domain/datasource";
import { client } from "@/graphql/client";
@@ -79,17 +80,23 @@ import {
} from "@/graphql/types";
import { Locale } from "@/locales/locales";
import { useLocale } from "@/locales/use-locale";
+import { useUser } from "@/login/utils";
import { findInHierarchy } from "@/rdf/tree-utils";
import {
getDataSourceFromLocalStorage,
useDataSourceStore,
} from "@/stores/data-source";
-import { createConfig, fetchChartConfig } from "@/utils/chart-config/api";
+import {
+ createConfig,
+ fetchChartConfig,
+ updateConfig,
+} from "@/utils/chart-config/api";
import {
CONFIGURATOR_STATE_VERSION,
migrateConfiguratorState,
} from "@/utils/chart-config/versioning";
import { createChartId } from "@/utils/create-chart-id";
+import { getRouterChartId } from "@/utils/router/helpers";
import { unreachableError } from "@/utils/unreachable";
export type ConfiguratorStateAction =
@@ -340,7 +347,8 @@ const INITIAL_STATE: ConfiguratorState = {
dataSource: DEFAULT_DATA_SOURCE,
};
-const emptyState: ConfiguratorStateSelectingDataSet = {
+const EMPTY_STATE: ConfiguratorStateSelectingDataSet = {
+ ...INITIAL_STATE,
version: CONFIGURATOR_STATE_VERSION,
state: "SELECTING_DATASET",
dataSet: undefined,
@@ -917,7 +925,7 @@ const reducer: Reducer = (
case "INITIALIZED":
// Never restore from an UNINITIALIZED state
return action.value.state === "INITIAL"
- ? getStateWithCurrentDataSource(emptyState)
+ ? getStateWithCurrentDataSource(EMPTY_STATE)
: action.value;
case "DATASET_SELECTED":
if (draft.state === "SELECTING_DATASET") {
@@ -1330,6 +1338,19 @@ export const initChartStateFromChart = async (
}
};
+export const initChartStateFromChartEdit = async (
+ from: ChartId
+): Promise => {
+ const config = await fetchChartConfig(from);
+
+ if (config?.data) {
+ return migrateConfiguratorState({
+ ...config.data,
+ state: "CONFIGURING_CHART",
+ });
+ }
+};
+
export const initChartStateFromCube = async (
client: Client,
datasetIri: DatasetIri,
@@ -1361,7 +1382,7 @@ export const initChartStateFromCube = async (
if (metadata?.dataCubeByIri && components?.dataCubeByIri) {
return transitionStepNext(
- getStateWithCurrentDataSource({ ...emptyState, dataSet: datasetIri }),
+ getStateWithCurrentDataSource({ ...EMPTY_STATE, dataSet: datasetIri }),
{ ...metadata.dataCubeByIri, ...components.dataCubeByIri }
);
}
@@ -1421,6 +1442,7 @@ const ConfiguratorStateProviderInternal = ({
const [state, dispatch] = stateAndDispatch;
const { asPath, push, replace, query } = useRouter();
const client = useClient();
+ const user = useUser();
// Initialize state on page load.
useEffect(() => {
@@ -1433,6 +1455,8 @@ const ConfiguratorStateProviderInternal = ({
if (chartId === "new") {
if (query.from && typeof query.from === "string") {
newChartState = await initChartStateFromChart(query.from);
+ } else if (query.edit && typeof query.edit === "string") {
+ newChartState = await initChartStateFromChartEdit(query.edit);
} else if (query.cube && typeof query.cube === "string") {
newChartState = await initChartStateFromCube(
client,
@@ -1477,12 +1501,20 @@ const ConfiguratorStateProviderInternal = ({
switch (state.state) {
case "CONFIGURING_CHART":
if (chartId === "new") {
- const newChartId = createChartId();
- window.localStorage.setItem(
- getLocalStorageKey(newChartId),
- JSON.stringify(state)
- );
- replace(`/create/${newChartId}`);
+ if (query.edit && typeof query.edit === "string") {
+ replace(`/create/${query.edit}`);
+ window.localStorage.setItem(
+ getLocalStorageKey(query.edit),
+ JSON.stringify(state)
+ );
+ } else {
+ const newChartId = createChartId();
+ window.localStorage.setItem(
+ getLocalStorageKey(newChartId),
+ JSON.stringify(state)
+ );
+ replace(`/create/${newChartId}`);
+ }
} else {
// Store current state in localstorage
window.localStorage.setItem(
@@ -1495,7 +1527,18 @@ const ConfiguratorStateProviderInternal = ({
case "PUBLISHING":
(async () => {
try {
- const result = await createConfig({
+ let dbConfig: ParsedConfig | undefined;
+ const key = getRouterChartId(asPath);
+
+ if (key && user) {
+ const config = await fetchChartConfig(key);
+
+ if (config && config.user_id === user.id) {
+ dbConfig = config;
+ }
+ }
+
+ const preparedConfig: ConfiguratorStatePublishing = {
...state,
chartConfigs: [
...state.chartConfigs.map((d) => {
@@ -1520,7 +1563,14 @@ const ConfiguratorStateProviderInternal = ({
// the story from a specific point and e.g. toggle back and forth between
// the different charts).
activeChartKey: state.chartConfigs[0].key,
- });
+ };
+
+ const result = await (dbConfig && user
+ ? updateConfig(preparedConfig, {
+ key: dbConfig.key,
+ userId: user.id,
+ })
+ : createConfig(preparedConfig));
/**
* EXPERIMENTAL: Post back created chart ID to opener and close window.
@@ -1551,7 +1601,18 @@ const ConfiguratorStateProviderInternal = ({
} catch (e) {
console.error(e);
}
- }, [state, dispatch, chartId, push, asPath, locale, query.from, replace]);
+ }, [
+ state,
+ dispatch,
+ chartId,
+ push,
+ asPath,
+ locale,
+ query.from,
+ replace,
+ user,
+ query.edit,
+ ]);
return (
diff --git a/app/db/config.ts b/app/db/config.ts
index 24ce8cbb1..a41a7c585 100644
--- a/app/db/config.ts
+++ b/app/db/config.ts
@@ -7,26 +7,55 @@ import { Config, Prisma, User } from "@prisma/client";
import { ChartConfig, ConfiguratorStatePublished } from "@/configurator";
import { migrateConfiguratorState } from "@/utils/chart-config/versioning";
-import { createChartId } from "../utils/create-chart-id";
-
import prisma from "./client";
/**
* Store data in the DB.
* If the user is logged, the chart is linked to the user.
*
+ * @param key Key of the config to be stored
* @param data Data to be stored as configuration
*/
export const createConfig = async ({
+ key,
data,
userId,
}: {
+ key: string;
data: Prisma.ConfigCreateInput["data"];
userId?: User["id"] | undefined;
}): Promise<{ key: string }> => {
return await prisma.config.create({
data: {
- key: createChartId(),
+ key,
+ data,
+ user_id: userId,
+ },
+ });
+};
+
+/**
+ * Update config in the DB.
+ * Only valid for logged in users.
+ *
+ * @param key Key of the config to be updated
+ * @param data Data to be stored as configuration
+ */
+export const updateConfig = async ({
+ key,
+ data,
+ userId,
+}: {
+ key: string;
+ data: Prisma.ConfigCreateInput["data"];
+ userId: User["id"];
+}): Promise<{ key: string }> => {
+ return await prisma.config.update({
+ where: {
+ key,
+ },
+ data: {
+ key,
data,
user_id: userId,
},
diff --git a/app/locales/de/messages.po b/app/locales/de/messages.po
index 7190c47a6..1512f3325 100644
--- a/app/locales/de/messages.po
+++ b/app/locales/de/messages.po
@@ -115,6 +115,14 @@ msgstr "Diese Visualisierung veröffentlichen"
msgid "button.share"
msgstr "Teilen"
+#: app/components/chart-selection-tabs.tsx
+msgid "button.update"
+msgstr "Diese Visualisierung aktualisieren"
+
+#: app/components/chart-selection-tabs.tsx
+msgid "button.update.warning"
+msgstr "Denken Sie daran, dass sich die Aktualisierung dieser Visualisierung auf alle Stellen auswirkt, an denen sie bereits eingebettet ist!"
+
#: app/configurator/components/field-i18n.ts
msgid "chart.map.layers.area"
msgstr "Flächen"
diff --git a/app/locales/en/messages.po b/app/locales/en/messages.po
index 324bb6281..d1eb04374 100644
--- a/app/locales/en/messages.po
+++ b/app/locales/en/messages.po
@@ -115,6 +115,14 @@ msgstr "Publish this visualization"
msgid "button.share"
msgstr "Share"
+#: app/components/chart-selection-tabs.tsx
+msgid "button.update"
+msgstr "Update this visualization"
+
+#: app/components/chart-selection-tabs.tsx
+msgid "button.update.warning"
+msgstr "Keep in mind that updating this visualization will affect all the places where it might be already embedded!"
+
#: app/configurator/components/field-i18n.ts
msgid "chart.map.layers.area"
msgstr "Areas"
diff --git a/app/locales/fr/messages.po b/app/locales/fr/messages.po
index 1d758fdb7..c358b0150 100644
--- a/app/locales/fr/messages.po
+++ b/app/locales/fr/messages.po
@@ -115,6 +115,14 @@ msgstr "Publier cette visualisation"
msgid "button.share"
msgstr "Partager"
+#: app/components/chart-selection-tabs.tsx
+msgid "button.update"
+msgstr "Actualiser cette visualisation"
+
+#: app/components/chart-selection-tabs.tsx
+msgid "button.update.warning"
+msgstr "Gardez à l'esprit que la mise à jour de cette visualisation affectera tous les endroits où elle est déjà intégrée !"
+
#: app/configurator/components/field-i18n.ts
msgid "chart.map.layers.area"
msgstr "Zones"
diff --git a/app/locales/it/messages.po b/app/locales/it/messages.po
index 236e77480..9cc5a8572 100644
--- a/app/locales/it/messages.po
+++ b/app/locales/it/messages.po
@@ -115,6 +115,14 @@ msgstr "Pubblica questa visualizzazione"
msgid "button.share"
msgstr "Condividi"
+#: app/components/chart-selection-tabs.tsx
+msgid "button.update"
+msgstr "Aggiornare questa visualizzazione"
+
+#: app/components/chart-selection-tabs.tsx
+msgid "button.update.warning"
+msgstr "Tenete presente che l'aggiornamento di questa visualizzazione avrà effetto su tutti i luoghi in cui potrebbe essere già incorporata!"
+
#: app/configurator/components/field-i18n.ts
msgid "chart.map.layers.area"
msgstr "Aree"
diff --git a/app/login/components/login-menu.tsx b/app/login/components/login-menu.tsx
index 0611341d9..84a4b0537 100644
--- a/app/login/components/login-menu.tsx
+++ b/app/login/components/login-menu.tsx
@@ -1,48 +1,18 @@
import { Box, Button, Typography } from "@mui/material";
-import { getProviders, signIn, useSession } from "next-auth/react";
+import { signIn } from "next-auth/react";
import Link from "next/link";
-import { useEffect, useState } from "react";
-import { Awaited } from "@/domain/types";
-
-type Providers = Awaited>;
-
-const useProviders = () => {
- const [state, setState] = useState({
- status: "loading",
- data: undefined as Providers | undefined,
- });
-
- useEffect(() => {
- const run = async () => {
- const providers = await getProviders();
- setState({ status: "loaded", data: providers });
- };
-
- run();
- }, []);
-
- return state;
-};
+import { useUser } from "@/login/utils";
export const LoginMenu = () => {
- const { data: session, status: sessionStatus } = useSession();
- const { data: providers, status: providersStatus } = useProviders();
-
- if (sessionStatus === "loading" || providersStatus === "loading") {
- return null;
- }
-
- if (!providers || !Object.keys(providers).length) {
- return null;
- }
+ const user = useUser();
return (
- {session ? (
+ {user ? (
- {session.user?.name}
+ {user.name}
{" "}
) : (
diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx
index aa49aa0b8..3f2dcb96f 100644
--- a/app/login/components/profile-tables.tsx
+++ b/app/login/components/profile-tables.tsx
@@ -72,6 +72,8 @@ const Row = (props: RowProps) => {
},
});
+ console.log(config);
+
return (
@@ -88,7 +90,11 @@ const Row = (props: RowProps) => {
-
+
diff --git a/app/login/utils.ts b/app/login/utils.ts
index aa5389ce9..667621a04 100644
--- a/app/login/utils.ts
+++ b/app/login/utils.ts
@@ -1,5 +1,7 @@
import { Theme } from "@mui/material";
import { makeStyles } from "@mui/styles";
+import { getProviders, useSession } from "next-auth/react";
+import React from "react";
import { HEADER_HEIGHT } from "@/components/header";
@@ -19,3 +21,42 @@ export const useRootStyles = makeStyles((theme) => ({
margin: "0 auto",
},
}));
+
+export const useUser = () => {
+ const { data: session, status: sessionStatus } = useSession();
+ const { data: providers, status: providersStatus } = useProviders();
+
+ if (sessionStatus === "loading" || providersStatus === "loading") {
+ return null;
+ }
+
+ if (!providers || !Object.keys(providers).length) {
+ return null;
+ }
+
+ if (!session) {
+ return null;
+ }
+
+ return session.user;
+};
+
+type Providers = Awaited>;
+
+const useProviders = () => {
+ const [state, setState] = React.useState({
+ status: "loading",
+ data: undefined as Providers | undefined,
+ });
+
+ React.useEffect(() => {
+ const run = async () => {
+ const providers = await getProviders();
+ setState({ status: "loaded", data: providers });
+ };
+
+ run();
+ }, []);
+
+ return state;
+};
diff --git a/app/pages/__test/[env]/[slug].tsx b/app/pages/__test/[env]/[slug].tsx
index 93d06303a..e53b6039f 100644
--- a/app/pages/__test/[env]/[slug].tsx
+++ b/app/pages/__test/[env]/[slug].tsx
@@ -14,6 +14,7 @@ import { EmbedOptionsProvider } from "@/utils/embed";
// FIXME: keep this in sync with configurator types.
type DbConfig = {
+ key: string;
version: string;
dataSet: string;
dataSource: DataSource;
diff --git a/app/pages/api/config.ts b/app/pages/api/config-create.ts
similarity index 95%
rename from app/pages/api/config.ts
rename to app/pages/api/config-create.ts
index f9f6504ca..761b4fe39 100644
--- a/app/pages/api/config.ts
+++ b/app/pages/api/config-create.ts
@@ -13,6 +13,7 @@ const route = api({
const { data } = req.body;
return await createConfig({
+ key: data.key,
data,
userId,
});
diff --git a/app/pages/api/config-update.ts b/app/pages/api/config-update.ts
new file mode 100644
index 000000000..e448fbe4f
--- /dev/null
+++ b/app/pages/api/config-update.ts
@@ -0,0 +1,27 @@
+import { getServerSession } from "next-auth";
+
+import { updateConfig } from "@/db/config";
+
+import { api } from "../../server/nextkit";
+
+import { nextAuthOptions } from "./auth/[...nextauth]";
+
+const route = api({
+ POST: async ({ req, res }) => {
+ const session = await getServerSession(req, res, nextAuthOptions);
+ const serverUserId = session?.user?.id;
+ const { key, userId, data } = req.body;
+
+ if (serverUserId !== userId) {
+ throw new Error("Unauthorized!");
+ }
+
+ return await updateConfig({
+ key,
+ data,
+ userId,
+ });
+ },
+});
+
+export default route;
diff --git a/app/pages/api/config/[key].ts b/app/pages/api/config/[key].ts
index c21eb4691..aaa303696 100644
--- a/app/pages/api/config/[key].ts
+++ b/app/pages/api/config/[key].ts
@@ -1,5 +1,3 @@
-import { NextkitError } from "nextkit";
-
import { getConfig } from "../../../db/config";
import { api } from "../../../server/nextkit";
@@ -8,8 +6,6 @@ const route = api({
const result = await getConfig(req.query.key as string);
if (result) {
return result;
- } else {
- throw new NextkitError(404, "Not found");
}
},
});
diff --git a/app/utils/chart-config/api.ts b/app/utils/chart-config/api.ts
index 22bee59cf..dececca95 100644
--- a/app/utils/chart-config/api.ts
+++ b/app/utils/chart-config/api.ts
@@ -2,24 +2,64 @@ import { InferAPIResponse } from "nextkit";
import { ConfiguratorStatePublishing } from "../../config-types";
import { apiFetch } from "../api";
+import { createChartId } from "../create-chart-id";
-import type apiConfigs from "../../pages/api/config";
+import type apiConfigCreate from "../../pages/api/config-create";
+import type apiConfigUpdate from "../../pages/api/config-update";
import type apiConfig from "../../pages/api/config/[key]";
export const createConfig = async (state: ConfiguratorStatePublishing) => {
- return apiFetch>("/api/config", {
- method: "POST",
- data: {
+ return apiFetch>(
+ "/api/config-create",
+ {
+ method: "POST",
data: {
- version: state.version,
- dataSet: state.dataSet,
- dataSource: state.dataSource,
- meta: state.meta,
- chartConfigs: state.chartConfigs,
- activeChartKey: state.activeChartKey,
+ data: {
+ // Create a new chart ID, as the one in the state could be already
+ // used by a chart that has been published.
+ key: createChartId(),
+ version: state.version,
+ dataSet: state.dataSet,
+ dataSource: state.dataSource,
+ meta: state.meta,
+ chartConfigs: state.chartConfigs,
+ activeChartKey: state.activeChartKey,
+ },
},
- },
- });
+ }
+ );
+};
+
+type UpdateConfigOptions = {
+ key: string;
+ userId: number;
+};
+
+export const updateConfig = async (
+ state: ConfiguratorStatePublishing,
+ options: UpdateConfigOptions
+) => {
+ const { key, userId } = options;
+
+ return apiFetch>(
+ "/api/config-update",
+ {
+ method: "POST",
+ data: {
+ key,
+ userId,
+ data: {
+ key,
+ version: state.version,
+ dataSet: state.dataSet,
+ dataSource: state.dataSource,
+ meta: state.meta,
+ chartConfigs: state.chartConfigs,
+ activeChartKey: state.activeChartKey,
+ },
+ },
+ }
+ );
};
export const fetchChartConfig = async (id: string) => {
diff --git a/app/utils/router/helpers.ts b/app/utils/router/helpers.ts
index 86017648e..b5f6b731c 100644
--- a/app/utils/router/helpers.ts
+++ b/app/utils/router/helpers.ts
@@ -31,3 +31,7 @@ export const setURLParam = (param: string, value: string) => {
newUrl
);
};
+
+export const getRouterChartId = (asPath: string) => {
+ return asPath.split("?")[0].split("/").pop();
+};
diff --git a/app/utils/use-fetch-data.ts b/app/utils/use-fetch-data.ts
new file mode 100644
index 000000000..2f79c032f
--- /dev/null
+++ b/app/utils/use-fetch-data.ts
@@ -0,0 +1,41 @@
+import React from "react";
+
+type Status = "idle" | "fetching" | "success" | "error";
+
+type UseFetchDataOptions = {
+ enable?: boolean;
+ initialStatus?: Status;
+};
+
+export const useFetchData = (
+ queryFn: () => Promise,
+ options: UseFetchDataOptions = {}
+) => {
+ const { enable = true, initialStatus = "idle" } = options;
+ const [data, setData] = React.useState | null>(null);
+ const [error, setError] = React.useState(null);
+ const [status, setStatus] = React.useState(initialStatus);
+
+ React.useEffect(() => {
+ if (!enable) {
+ setStatus("idle");
+ return;
+ }
+
+ const fetchData = async () => {
+ setStatus("fetching");
+ try {
+ const result = await queryFn();
+ setData(result);
+ setStatus("success");
+ } catch (error) {
+ setError(error as Error);
+ setStatus("error");
+ }
+ };
+
+ fetchData();
+ }, [queryFn, enable]);
+
+ return { data, error, status };
+};