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 }; +};