From e62a823cc79b2f812161bd627a13c841ac2475ee Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Tue, 10 Oct 2023 13:28:02 +0200 Subject: [PATCH 1/7] refactor: Add key to configurator state --- app/config-types.ts | 3 + app/configurator/configurator-state.spec.tsx | 5 +- app/configurator/configurator-state.tsx | 24 +- app/db/config.ts | 4 +- app/docs/columns.docs.tsx | 4 +- app/docs/lines.docs.tsx | 4 +- app/docs/scatterplot.docs.tsx | 4 +- app/homepage/examples.tsx | 305 ++++++++++--------- app/pages/__test/[env]/[slug].tsx | 5 +- app/utils/chart-config/versioning.ts | 19 +- 10 files changed, 213 insertions(+), 164 deletions(-) diff --git a/app/config-types.ts b/app/config-types.ts index 48a6b90ee..ea31dbfe4 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -1069,6 +1069,7 @@ export type DataSource = t.TypeOf; const Config = t.type( { + key: t.string, version: t.string, dataSet: t.string, dataSource: DataSource, @@ -1089,6 +1090,7 @@ export const decodeConfig = (config: unknown) => { }; const ConfiguratorStateInitial = t.type({ + key: t.string, version: t.string, state: t.literal("INITIAL"), dataSet: t.undefined, @@ -1099,6 +1101,7 @@ export type ConfiguratorStateInitial = t.TypeOf< >; const ConfiguratorStateSelectingDataSet = t.type({ + key: t.string, version: t.string, state: t.literal("SELECTING_DATASET"), dataSet: t.union([t.string, t.undefined]), diff --git a/app/configurator/configurator-state.spec.tsx b/app/configurator/configurator-state.spec.tsx index d50b0c4c5..617c6a101 100644 --- a/app/configurator/configurator-state.spec.tsx +++ b/app/configurator/configurator-state.spec.tsx @@ -42,6 +42,7 @@ import { migrateChartConfig, migrateConfiguratorState, } from "@/utils/chart-config/versioning"; +import { createChartId } from "@/utils/create-chart-id"; const mockedApi = api as jest.Mocked; @@ -113,7 +114,9 @@ describe("initChartStateFromChart", () => { activeChartKey: migratedActiveChartKey, chartConfigs: migratedChartsConfigs, ...migratedRest - } = migrateConfiguratorState(fakeVizFixture); + } = migrateConfiguratorState(fakeVizFixture, { + migrationProps: { key: createChartId() }, + }); const { key: migratedChartConfigKey, ...migratedChartConfig } = migratedChartsConfigs[0]; diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index b9ec4043b..ff5450096 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -334,13 +334,15 @@ const getStateWithCurrentDataSource = (state: ConfiguratorState) => { }; const INITIAL_STATE: ConfiguratorState = { + key: createChartId(), version: CONFIGURATOR_STATE_VERSION, state: "INITIAL", dataSet: undefined, dataSource: DEFAULT_DATA_SOURCE, }; -const emptyState: ConfiguratorStateSelectingDataSet = { +const EMPTY_STATE: ConfiguratorStateSelectingDataSet = { + ...INITIAL_STATE, version: CONFIGURATOR_STATE_VERSION, state: "SELECTING_DATASET", dataSet: undefined, @@ -628,6 +630,7 @@ const transitionStepNext = ( ); return { + key: draft.key, version: CONFIGURATOR_STATE_VERSION, state: "CONFIGURING_CHART", dataSet: draft.dataSet, @@ -917,7 +920,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") { @@ -1323,10 +1326,13 @@ export const initChartStateFromChart = async ( const config = await fetchChartConfig(from); if (config?.data) { - return migrateConfiguratorState({ - ...config.data, - state: "CONFIGURING_CHART", - }); + return migrateConfiguratorState( + { + ...config.data, + state: "CONFIGURING_CHART", + }, + { migrationProps: { key: config.key } } + ); } }; @@ -1361,7 +1367,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 } ); } @@ -1381,7 +1387,9 @@ export const initChartStateFromLocalStorage = async ( let parsedState; try { const rawParsedState = JSON.parse(storedState); - const migratedState = migrateConfiguratorState(rawParsedState); + const migratedState = migrateConfiguratorState(rawParsedState, { + migrationProps: { key: chartId }, + }); parsedState = decodeConfiguratorState(migratedState); } catch (e) { console.error("Error while parsing stored state", e); diff --git a/app/db/config.ts b/app/db/config.ts index 24ce8cbb1..9f966b774 100644 --- a/app/db/config.ts +++ b/app/db/config.ts @@ -67,7 +67,9 @@ const parseDbConfig = ( user_id: number | null; } => { const data = d.data as ConfiguratorStatePublished; - const migratedData = migrateConfiguratorState(data); + const migratedData = migrateConfiguratorState(data, { + migrationProps: { key: d.key }, + }); return { ...d, diff --git a/app/docs/columns.docs.tsx b/app/docs/columns.docs.tsx index 3f68e83ed..9ac313cc8 100644 --- a/app/docs/columns.docs.tsx +++ b/app/docs/columns.docs.tsx @@ -15,6 +15,7 @@ import { ConfiguratorStateProvider } from "@/configurator/configurator-state"; import { DimensionMetadataFragment } from "@/graphql/query-hooks"; import { InteractiveFiltersProvider } from "@/stores/interactive-filters"; import { CHART_CONFIG_VERSION } from "@/utils/chart-config/versioning"; +import { createChartId } from "@/utils/create-chart-id"; export const Docs = () => markdown` @@ -25,7 +26,8 @@ ${( markdown` @@ -27,7 +28,8 @@ ${( markdown` @@ -30,7 +31,8 @@ ${( {headline} @@ -138,83 +142,86 @@ export const Examples = ({ { ).default; setConfig({ ...importedConfig, - data: migrateConfiguratorState(importedConfig.data), + data: migrateConfiguratorState(importedConfig.data, { + migrationProps: { key: importedConfig.key }, + }), }); }; diff --git a/app/utils/chart-config/versioning.ts b/app/utils/chart-config/versioning.ts index 76e3b5edd..6ef813a6d 100644 --- a/app/utils/chart-config/versioning.ts +++ b/app/utils/chart-config/versioning.ts @@ -691,7 +691,7 @@ export const migrateChartConfig = makeMigrate(chartConfigMigrations, { defaultToVersion: CHART_CONFIG_VERSION, }); -export const CONFIGURATOR_STATE_VERSION = "2.0.0"; +export const CONFIGURATOR_STATE_VERSION = "2.1.0"; const configuratorStateMigrations: Migration[] = [ { @@ -728,6 +728,23 @@ const configuratorStateMigrations: Migration[] = [ }); }, }, + { + description: "ALL + key", + from: "2.0.0", + to: "2.1.0", + up: (config: any, props: { key: string }) => { + const newConfig = { ...config, key: props.key, version: "2.1.0" }; + + return newConfig; + }, + down: (config: any) => { + const newConfig = { ...config, version: "2.0.0" }; + + return produce(newConfig, (draft: any) => { + delete draft.key; + }); + }, + }, ]; export const migrateConfiguratorState = makeMigrate( From c06a35099c21252994ec4dc5027e63ab10134b8e Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Tue, 10 Oct 2023 15:01:20 +0200 Subject: [PATCH 2/7] refactor: Extract useUser hook --- app/login/components/login-menu.tsx | 40 ++++------------------------ app/login/utils.ts | 41 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 35 deletions(-) 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/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; +}; From 8df29237e394292a2049a461552c2bbf0f0c1bd5 Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Tue, 10 Oct 2023 15:39:00 +0200 Subject: [PATCH 3/7] feat: Allow edition of published charts --- app/configurator/configurator-state.tsx | 68 +++++++++++++++++-- app/db/config.ts | 35 +++++++++- app/login/components/profile-tables.tsx | 6 +- app/pages/api/{config.ts => config-create.ts} | 1 + app/pages/api/config-update.ts | 27 ++++++++ app/pages/api/config/[key].ts | 4 -- app/utils/chart-config/api.ts | 64 +++++++++++++---- 7 files changed, 180 insertions(+), 25 deletions(-) rename app/pages/api/{config.ts => config-create.ts} (95%) create mode 100644 app/pages/api/config-update.ts diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index ff5450096..83e9cc3da 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,12 +80,17 @@ 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, @@ -1324,6 +1330,27 @@ export const initChartStateFromChart = async ( from: ChartId ): Promise => { const config = await fetchChartConfig(from); + const newId = createChartId(); + + if (config?.data) { + return { + ...migrateConfiguratorState( + { + ...config.data, + state: "CONFIGURING_CHART", + }, + // We create a new key for the chart, in order to create a new chart. + { migrationProps: { key: newId } } + ), + key: newId, + }; + } +}; + +export const initChartStateFromChartEdit = async ( + from: ChartId +): Promise => { + const config = await fetchChartConfig(from); if (config?.data) { return migrateConfiguratorState( @@ -1331,6 +1358,7 @@ export const initChartStateFromChart = async ( ...config.data, state: "CONFIGURING_CHART", }, + // We keep the same key as the original chart, in order to update it. { migrationProps: { key: config.key } } ); } @@ -1429,6 +1457,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(() => { @@ -1441,6 +1470,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, @@ -1485,7 +1516,7 @@ const ConfiguratorStateProviderInternal = ({ switch (state.state) { case "CONFIGURING_CHART": if (chartId === "new") { - const newChartId = createChartId(); + const newChartId = state.key ?? createChartId(); window.localStorage.setItem( getLocalStorageKey(newChartId), JSON.stringify(state) @@ -1503,7 +1534,17 @@ const ConfiguratorStateProviderInternal = ({ case "PUBLISHING": (async () => { try { - const result = await createConfig({ + let dbConfig: ParsedConfig | undefined; + + if (user) { + const config = await fetchChartConfig(state.key); + + if (config && config.user_id === user.id) { + dbConfig = config; + } + } + + const preparedConfig: ConfiguratorStatePublishing = { ...state, chartConfigs: [ ...state.chartConfigs.map((d) => { @@ -1528,7 +1569,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. @@ -1559,7 +1607,17 @@ 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, + ]); return ( diff --git a/app/db/config.ts b/app/db/config.ts index 9f966b774..8c1814d41 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/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index aa49aa0b8..d90e816c2 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -88,7 +88,11 @@ const Row = (props: RowProps) => { - + 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) => { From 7addeb412dd79689f04431233f39cd6a7d991cee Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Tue, 10 Oct 2023 15:39:10 +0200 Subject: [PATCH 4/7] fix: Add missing properties --- app/docs/fixtures.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/docs/fixtures.ts b/app/docs/fixtures.ts index bd7e1108d..084dadd55 100644 --- a/app/docs/fixtures.ts +++ b/app/docs/fixtures.ts @@ -1,11 +1,13 @@ import { DEFAULT_DATA_SOURCE } from "@/domain/datasource"; import { CONFIGURATOR_STATE_VERSION } from "@/utils/chart-config/versioning"; +import { createChartId } from "@/utils/create-chart-id"; import { ColumnFields, ConfiguratorState, TableConfig } from "../configurator"; import { DimensionMetadataFragment, TimeUnit } from "../graphql/query-hooks"; export const states: ConfiguratorState[] = [ { + key: createChartId(), state: "SELECTING_DATASET", version: CONFIGURATOR_STATE_VERSION, dataSet: undefined, @@ -28,6 +30,7 @@ export const states: ConfiguratorState[] = [ activeChartKey: undefined, }, { + key: createChartId(), state: "CONFIGURING_CHART", version: CONFIGURATOR_STATE_VERSION, dataSet: "foo", From e52034d84b3a046f3f29434143ab6d137b241d63 Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Tue, 10 Oct 2023 16:00:41 +0200 Subject: [PATCH 5/7] refactor: Do not store configurator key in config --- app/config-types.ts | 3 - app/configurator/configurator-state.spec.tsx | 5 +- app/configurator/configurator-state.tsx | 60 ++-- app/db/config.ts | 4 +- app/docs/columns.docs.tsx | 4 +- app/docs/fixtures.ts | 3 - app/docs/lines.docs.tsx | 4 +- app/docs/scatterplot.docs.tsx | 4 +- app/homepage/examples.tsx | 305 +++++++++---------- app/login/components/profile-tables.tsx | 2 + app/pages/__test/[env]/[slug].tsx | 4 +- app/utils/chart-config/versioning.ts | 19 +- 12 files changed, 185 insertions(+), 232 deletions(-) diff --git a/app/config-types.ts b/app/config-types.ts index ea31dbfe4..48a6b90ee 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -1069,7 +1069,6 @@ export type DataSource = t.TypeOf; const Config = t.type( { - key: t.string, version: t.string, dataSet: t.string, dataSource: DataSource, @@ -1090,7 +1089,6 @@ export const decodeConfig = (config: unknown) => { }; const ConfiguratorStateInitial = t.type({ - key: t.string, version: t.string, state: t.literal("INITIAL"), dataSet: t.undefined, @@ -1101,7 +1099,6 @@ export type ConfiguratorStateInitial = t.TypeOf< >; const ConfiguratorStateSelectingDataSet = t.type({ - key: t.string, version: t.string, state: t.literal("SELECTING_DATASET"), dataSet: t.union([t.string, t.undefined]), diff --git a/app/configurator/configurator-state.spec.tsx b/app/configurator/configurator-state.spec.tsx index 617c6a101..d50b0c4c5 100644 --- a/app/configurator/configurator-state.spec.tsx +++ b/app/configurator/configurator-state.spec.tsx @@ -42,7 +42,6 @@ import { migrateChartConfig, migrateConfiguratorState, } from "@/utils/chart-config/versioning"; -import { createChartId } from "@/utils/create-chart-id"; const mockedApi = api as jest.Mocked; @@ -114,9 +113,7 @@ describe("initChartStateFromChart", () => { activeChartKey: migratedActiveChartKey, chartConfigs: migratedChartsConfigs, ...migratedRest - } = migrateConfiguratorState(fakeVizFixture, { - migrationProps: { key: createChartId() }, - }); + } = migrateConfiguratorState(fakeVizFixture); const { key: migratedChartConfigKey, ...migratedChartConfig } = migratedChartsConfigs[0]; diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index 83e9cc3da..4a158c2c7 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -340,7 +340,6 @@ const getStateWithCurrentDataSource = (state: ConfiguratorState) => { }; const INITIAL_STATE: ConfiguratorState = { - key: createChartId(), version: CONFIGURATOR_STATE_VERSION, state: "INITIAL", dataSet: undefined, @@ -636,7 +635,6 @@ const transitionStepNext = ( ); return { - key: draft.key, version: CONFIGURATOR_STATE_VERSION, state: "CONFIGURING_CHART", dataSet: draft.dataSet, @@ -1330,20 +1328,12 @@ export const initChartStateFromChart = async ( from: ChartId ): Promise => { const config = await fetchChartConfig(from); - const newId = createChartId(); if (config?.data) { - return { - ...migrateConfiguratorState( - { - ...config.data, - state: "CONFIGURING_CHART", - }, - // We create a new key for the chart, in order to create a new chart. - { migrationProps: { key: newId } } - ), - key: newId, - }; + return migrateConfiguratorState({ + ...config.data, + state: "CONFIGURING_CHART", + }); } }; @@ -1353,14 +1343,10 @@ export const initChartStateFromChartEdit = async ( const config = await fetchChartConfig(from); if (config?.data) { - return migrateConfiguratorState( - { - ...config.data, - state: "CONFIGURING_CHART", - }, - // We keep the same key as the original chart, in order to update it. - { migrationProps: { key: config.key } } - ); + return migrateConfiguratorState({ + ...config.data, + state: "CONFIGURING_CHART", + }); } }; @@ -1415,9 +1401,7 @@ export const initChartStateFromLocalStorage = async ( let parsedState; try { const rawParsedState = JSON.parse(storedState); - const migratedState = migrateConfiguratorState(rawParsedState, { - migrationProps: { key: chartId }, - }); + const migratedState = migrateConfiguratorState(rawParsedState); parsedState = decodeConfiguratorState(migratedState); } catch (e) { console.error("Error while parsing stored state", e); @@ -1516,12 +1500,20 @@ const ConfiguratorStateProviderInternal = ({ switch (state.state) { case "CONFIGURING_CHART": if (chartId === "new") { - const newChartId = state.key ?? 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( @@ -1535,9 +1527,10 @@ const ConfiguratorStateProviderInternal = ({ (async () => { try { let dbConfig: ParsedConfig | undefined; + const key = asPath.split("?")[0].split("/").pop(); - if (user) { - const config = await fetchChartConfig(state.key); + if (key && user) { + const config = await fetchChartConfig(key); if (config && config.user_id === user.id) { dbConfig = config; @@ -1617,6 +1610,7 @@ const ConfiguratorStateProviderInternal = ({ query.from, replace, user, + query.edit, ]); return ( diff --git a/app/db/config.ts b/app/db/config.ts index 8c1814d41..a41a7c585 100644 --- a/app/db/config.ts +++ b/app/db/config.ts @@ -96,9 +96,7 @@ const parseDbConfig = ( user_id: number | null; } => { const data = d.data as ConfiguratorStatePublished; - const migratedData = migrateConfiguratorState(data, { - migrationProps: { key: d.key }, - }); + const migratedData = migrateConfiguratorState(data); return { ...d, diff --git a/app/docs/columns.docs.tsx b/app/docs/columns.docs.tsx index 9ac313cc8..3f68e83ed 100644 --- a/app/docs/columns.docs.tsx +++ b/app/docs/columns.docs.tsx @@ -15,7 +15,6 @@ import { ConfiguratorStateProvider } from "@/configurator/configurator-state"; import { DimensionMetadataFragment } from "@/graphql/query-hooks"; import { InteractiveFiltersProvider } from "@/stores/interactive-filters"; import { CHART_CONFIG_VERSION } from "@/utils/chart-config/versioning"; -import { createChartId } from "@/utils/create-chart-id"; export const Docs = () => markdown` @@ -26,8 +25,7 @@ ${( markdown` @@ -28,8 +27,7 @@ ${( markdown` @@ -31,8 +30,7 @@ ${( {headline} @@ -142,86 +138,83 @@ export const Examples = ({ { }, }); + console.log(config); + return ( diff --git a/app/pages/__test/[env]/[slug].tsx b/app/pages/__test/[env]/[slug].tsx index feb461e9a..e53b6039f 100644 --- a/app/pages/__test/[env]/[slug].tsx +++ b/app/pages/__test/[env]/[slug].tsx @@ -35,9 +35,7 @@ const Page: NextPage = () => { ).default; setConfig({ ...importedConfig, - data: migrateConfiguratorState(importedConfig.data, { - migrationProps: { key: importedConfig.key }, - }), + data: migrateConfiguratorState(importedConfig.data), }); }; diff --git a/app/utils/chart-config/versioning.ts b/app/utils/chart-config/versioning.ts index 6ef813a6d..76e3b5edd 100644 --- a/app/utils/chart-config/versioning.ts +++ b/app/utils/chart-config/versioning.ts @@ -691,7 +691,7 @@ export const migrateChartConfig = makeMigrate(chartConfigMigrations, { defaultToVersion: CHART_CONFIG_VERSION, }); -export const CONFIGURATOR_STATE_VERSION = "2.1.0"; +export const CONFIGURATOR_STATE_VERSION = "2.0.0"; const configuratorStateMigrations: Migration[] = [ { @@ -728,23 +728,6 @@ const configuratorStateMigrations: Migration[] = [ }); }, }, - { - description: "ALL + key", - from: "2.0.0", - to: "2.1.0", - up: (config: any, props: { key: string }) => { - const newConfig = { ...config, key: props.key, version: "2.1.0" }; - - return newConfig; - }, - down: (config: any) => { - const newConfig = { ...config, version: "2.0.0" }; - - return produce(newConfig, (draft: any) => { - delete draft.key; - }); - }, - }, ]; export const migrateConfiguratorState = makeMigrate( From 7a6ac8f53f32ba3a9ede18c9553602063e8a46a9 Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Tue, 10 Oct 2023 16:31:44 +0200 Subject: [PATCH 6/7] feat: Add indication that user is editing published chart --- app/components/chart-selection-tabs.tsx | 56 ++++++++++++++++++++++--- app/configurator/configurator-state.tsx | 3 +- app/locales/de/messages.po | 8 ++++ app/locales/en/messages.po | 8 ++++ app/locales/fr/messages.po | 8 ++++ app/locales/it/messages.po | 8 ++++ app/utils/router/helpers.ts | 4 ++ 7 files changed, 89 insertions(+), 6 deletions(-) diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index 984bea70e..979574cca 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,7 +25,9 @@ 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"; type TabsState = { @@ -277,16 +281,58 @@ const PublishChartButton = () => { } }); - return ( + const [editingPublishedChart, setEditingPublishedChart] = + React.useState(false); + const [loaded, setLoaded] = React.useState(false); + const { asPath } = useRouter(); + const session = useSession(); + const chartId = getRouterChartId(asPath); + + React.useEffect(() => { + const run = async () => { + if (session.data?.user && chartId) { + const config = await fetchChartConfig(chartId); + + if (config?.user_id === session.data.user.id) { + setEditingPublishedChart(true); + } + } + + if (session.status !== "loading") { + setLoaded(true); + } + }; + + run(); + }, [chartId, session]); + + return loaded ? ( - ); + ) : null; }; type TabsInnerProps = { diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index 4a158c2c7..faf65b724 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -96,6 +96,7 @@ import { 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 = @@ -1527,7 +1528,7 @@ const ConfiguratorStateProviderInternal = ({ (async () => { try { let dbConfig: ParsedConfig | undefined; - const key = asPath.split("?")[0].split("/").pop(); + const key = getRouterChartId(asPath); if (key && user) { const config = await fetchChartConfig(key); 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/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(); +}; From 8318ef107743aa76a95f05dc7c471dc906b1d3cf Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Wed, 11 Oct 2023 09:24:34 +0200 Subject: [PATCH 7/7] refactor: useFetchData --- app/components/chart-selection-tabs.tsx | 35 ++++++++------------- app/utils/use-fetch-data.ts | 41 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 22 deletions(-) create mode 100644 app/utils/use-fetch-data.ts diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index 979574cca..32132fada 100644 --- a/app/components/chart-selection-tabs.tsx +++ b/app/components/chart-selection-tabs.tsx @@ -29,6 +29,7 @@ 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; @@ -281,32 +282,22 @@ const PublishChartButton = () => { } }); - const [editingPublishedChart, setEditingPublishedChart] = - React.useState(false); - const [loaded, setLoaded] = React.useState(false); 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", + }); - React.useEffect(() => { - const run = async () => { - if (session.data?.user && chartId) { - const config = await fetchChartConfig(chartId); - - if (config?.user_id === session.data.user.id) { - setEditingPublishedChart(true); - } - } - - if (session.status !== "loading") { - setLoaded(true); - } - }; - - run(); - }, [chartId, session]); + const editingPublishedChart = + session.data?.user.id && config?.user_id === session.data.user.id; - return loaded ? ( + return status === "fetching" ? null : ( - ) : null; + ); }; type TabsInnerProps = { 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 }; +};