From d974f432100f9205ca8cdaf678777bfc7301e154 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Mon, 12 Feb 2024 14:50:42 +0100 Subject: [PATCH 01/59] fix: Add optional access to navigator.vendor deprecated property --- app/themes/federal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/themes/federal.tsx b/app/themes/federal.tsx index c89c79d4f..b6eb6f482 100644 --- a/app/themes/federal.tsx +++ b/app/themes/federal.tsx @@ -1,5 +1,5 @@ import { Fade, Grow } from "@mui/material"; -import { Breakpoint, Theme, createTheme } from "@mui/material/styles"; +import { Breakpoint, createTheme, Theme } from "@mui/material/styles"; import merge from "lodash/merge"; import omit from "lodash/omit"; @@ -7,7 +7,7 @@ import { Icon } from "@/icons"; import shadows from "@/themes/shadows"; const isSafari15 = - typeof navigator !== "undefined" && navigator.vendor.indexOf("Apple") >= 0 + typeof navigator !== "undefined" && navigator.vendor?.indexOf("Apple") >= 0 ? navigator.userAgent .match(/Version[/\s]([\d]+)/g)?.[0] ?.split("/")?.[1] === "15" From 0294863434e37127c1bcce2b0c34278a5d07a184 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Mon, 12 Feb 2024 14:54:19 +0100 Subject: [PATCH 02/59] docs: Add table of contents --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 46cf611d0..c97aa0659 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,37 @@ # Visualization Tool + + +- 1. [Documentation](#Documentation) +- 2. [Development Environment](#DevelopmentEnvironment) + - 2.1. [Setting up the dev environment](#Settingupthedevenvironment) + - 2.2. [Dev server](#Devserver) + - 2.3. [Postgres database](#Postgresdatabase) + - 2.4. [Building the Embed script `/dist/embed.js`](#BuildingtheEmbedscriptdistembed.js) + - 2.4.1. [Database migrations](#Databasemigrations) +- 3. [Versioning](#Versioning) +- 4. [Deployment](#Deployment) + - 4.1. [Heroku](#Heroku) + - 4.2. [Abraxas](#Abraxas) + - 4.3. [Docker (anywhere)](#Dockeranywhere) +- 5. [E2E tests](#E2Etests) +- 6. [GraphQL performance tests](#GraphQLperformancetests) + - 6.1. [Automation](#Automation) + - 6.2. [How to add or modify the tests](#Howtoaddormodifythetests) +- 7. [Load tests](#Loadtests) + - 7.1. [Automation](#Automation-1) + - 7.2. [Local setup](#Localsetup) + - 7.3. [Running the tests locally](#Runningthetestslocally) + - 7.4. [Recording the tests using Playwright](#RecordingthetestsusingPlaywright) +- 8. [Authentication](#Authentication) + - 8.1. [Locally](#Locally) + + + + ## Documentation A public documentation is available at https://visualize.admin.ch/docs/. From af8a1028ccb4949a546f92bd706c9a78ceb5eb5a Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 12:08:21 +0100 Subject: [PATCH 03/59] refactor: Extract keys early --- app/configurator/configurator-state.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index fde61dfc1..48e9186cf 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -5,7 +5,7 @@ import setWith from "lodash/setWith"; import sortBy from "lodash/sortBy"; import unset from "lodash/unset"; import { NextRouter, useRouter } from "next/router"; -import { Dispatch, createContext, useContext, useEffect, useMemo } from "react"; +import { createContext, Dispatch, useContext, useEffect, useMemo } from "react"; import { Reducer, useImmerReducer } from "use-immer"; import { @@ -33,20 +33,20 @@ import { ConfiguratorStatePublishing, ConfiguratorStateSelectingDataSet, DataSource, - FilterValue, + decodeConfiguratorState, + enableLayouting, Filters, + FilterValue, GenericField, GenericFields, - ImputationType, - InteractiveFiltersConfig, - Layout, - decodeConfiguratorState, - enableLayouting, getChartConfig, getChartConfigFilters, + ImputationType, + InteractiveFiltersConfig, isAreaConfig, isColorFieldInConfig, isTableConfig, + Layout, makeMultiFilter, } from "@/config-types"; import { mapValueIrisToColor } from "@/configurator/components/ui-helpers"; @@ -58,9 +58,9 @@ import { DataCubeComponents, Dimension, DimensionValue, - ObservationValue, isGeoDimension, isMeasure, + ObservationValue, } from "@/domain/data"; import { DEFAULT_DATA_SOURCE } from "@/domain/datasource"; import { client } from "@/graphql/client"; @@ -1624,7 +1624,8 @@ const ConfiguratorStateProviderInternal = ( try { switch (state.layout.type) { case "singleURLs": { - const reversedChartKeys = state.layout.publishableChartKeys + const { publishableChartKeys, meta } = state.layout; + const reversedChartKeys = publishableChartKeys .slice() .reverse(); @@ -1637,7 +1638,7 @@ const ConfiguratorStateProviderInternal = ( // Ensure that the layout is reset to single-chart mode layout: { type: "tab", - meta: state.layout.meta, + meta: meta, activeField: undefined, }, }, From 56929a92935c7798ec468e84eac76cb5e84dfea5 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 12:06:13 +0100 Subject: [PATCH 04/59] refactor: Use query cache & invalidation to facilitate mutation Introduction of a QueryCache mechanism similar to react-query Instead of modifying in place the data that we receive from the server, we invalidate it & refetch it --- app/components/chart-selection-tabs.tsx | 14 +- app/domain/user-configs.ts | 28 ++++ app/login/components/profile-content-tabs.tsx | 14 +- app/login/components/profile-tables.tsx | 23 ++- app/pages/api/config/list.ts | 21 +++ app/pages/profile.tsx | 10 +- app/utils/api.ts | 6 +- app/utils/chart-config/api.ts | 7 + app/utils/use-fetch-data.ts | 132 ++++++++++++++---- 9 files changed, 196 insertions(+), 59 deletions(-) create mode 100644 app/domain/user-configs.ts create mode 100644 app/pages/api/config/list.ts diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index 724b7dbee..71fa9e708 100644 --- a/app/components/chart-selection-tabs.tsx +++ b/app/components/chart-selection-tabs.tsx @@ -1,4 +1,4 @@ -import { Trans, t } from "@lingui/macro"; +import { t, Trans } from "@lingui/macro"; import { Box, BoxProps, @@ -33,14 +33,13 @@ import { } from "@/configurator"; import { ChartTypeSelector } from "@/configurator/components/chart-type-selector"; import { getIconName } from "@/configurator/components/ui-helpers"; +import { useUserConfig } from "@/domain/user-configs"; import { useDataCubesComponentsQuery } from "@/graphql/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; @@ -318,14 +317,7 @@ export const PublishChartButton = () => { 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 { data: config, status } = useUserConfig(chartId); const editingPublishedChart = session.data?.user.id && config?.user_id === session.data.user.id; diff --git a/app/domain/user-configs.ts b/app/domain/user-configs.ts new file mode 100644 index 000000000..9afbdab29 --- /dev/null +++ b/app/domain/user-configs.ts @@ -0,0 +1,28 @@ +import { useCallback } from "react"; + +import { fetchChartConfig, fetchChartConfigs } from "@/utils/chart-config/api"; +import { useFetchData, UseFetchDataOptions } from "@/utils/use-fetch-data"; + +export const userConfigsKey = ["userConfigs"]; +export const useConfigKey = (t: string) => ["userConfigs", t]; + +export const useUserConfigs = (options?: UseFetchDataOptions) => + useFetchData( + userConfigsKey, + async () => { + const d = await fetchChartConfigs(); + return d; + }, + options + ); + +export const useUserConfig = ( + chartId: string | undefined, + options?: UseFetchDataOptions +) => { + let queryFn = useCallback(() => fetchChartConfig(chartId ?? ""), [chartId]); + return useFetchData(userConfigsKey, queryFn, { + enable: !!chartId, + ...options, + }); +}; diff --git a/app/login/components/profile-content-tabs.tsx b/app/login/components/profile-content-tabs.tsx index 1eb7f875a..f532e7721 100644 --- a/app/login/components/profile-content-tabs.tsx +++ b/app/login/components/profile-content-tabs.tsx @@ -5,7 +5,7 @@ import { makeStyles } from "@mui/styles"; import clsx from "clsx"; import React from "react"; -import { ParsedConfig } from "@/db/config"; +import { useUserConfigs } from "@/domain/user-configs"; import { ProfileVisualizationsTable } from "@/login/components/profile-tables"; import { useRootStyles } from "@/login/utils"; import useEvent from "@/utils/use-event"; @@ -38,12 +38,12 @@ const useStyles = makeStyles((theme) => ({ type ProfileContentTabsProps = { userId: number; - userConfigs: ParsedConfig[]; }; export const ProfileContentTabs = (props: ProfileContentTabsProps) => { - const { userId, userConfigs: _userConfigs } = props; - const [userConfigs, setUserConfigs] = React.useState(_userConfigs); + const { userId } = props; + + const { data: userConfigs } = useUserConfigs(); const [value, setValue] = React.useState("Home"); const handleChange = useEvent((_: React.SyntheticEvent, v: string) => { setValue(v); @@ -51,6 +51,10 @@ export const ProfileContentTabs = (props: ProfileContentTabsProps) => { const rootClasses = useRootStyles(); const classes = useStyles(); + if (!userConfigs) { + return null; + } + return ( @@ -78,7 +82,6 @@ export const ProfileContentTabs = (props: ProfileContentTabsProps) => { setValue( @@ -102,7 +105,6 @@ export const ProfileContentTabs = (props: ProfileContentTabsProps) => { diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index 16edd9c62..0bad8f41f 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -1,4 +1,4 @@ -import { Trans, t } from "@lingui/macro"; +import { t, Trans } from "@lingui/macro"; import { Box, Button, @@ -27,6 +27,7 @@ import useDisclosure from "@/components/use-disclosure"; import { ParsedConfig } from "@/db/config"; import { sourceToLabel } from "@/domain/datasource"; import { truthy } from "@/domain/types"; +import { useUserConfigs } from "@/domain/user-configs"; import { useDataCubesMetadataQuery } from "@/graphql/hooks"; import { Icon, IconName } from "@/icons"; import { useRootStyles } from "@/login/utils"; @@ -71,7 +72,6 @@ const ProfileTable = (props: ProfileTableProps) => { type ProfileVisualizationsTableProps = { userId: number; userConfigs: ParsedConfig[]; - setUserConfigs: React.Dispatch>; preview?: boolean; onShowAll?: () => void; }; @@ -79,13 +79,7 @@ type ProfileVisualizationsTableProps = { export const ProfileVisualizationsTable = ( props: ProfileVisualizationsTableProps ) => { - const { userId, userConfigs, setUserConfigs, preview, onShowAll } = props; - const onRemoveSuccess = React.useCallback( - (key: string) => { - setUserConfigs((prev) => prev.filter((d) => d.key !== key)); - }, - [setUserConfigs] - ); + const { userId, userConfigs, preview, onShowAll } = props; return ( ))} @@ -161,11 +154,10 @@ export const ProfileVisualizationsTable = ( type ProfileVisualizationsRowProps = { userId: number; config: ParsedConfig; - onRemoveSuccess: (key: string) => void; }; const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { - const { userId, config, onRemoveSuccess } = props; + const { userId, config } = props; const { dataSource } = config.data; const dataSets = Array.from( new Set(config.data.chartConfigs.flatMap((d) => d.cubes.map((d) => d.iri))) @@ -181,6 +173,9 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { }, pause: !dataSet, }); + + const { invalidate: invalidateUserConfigs } = useUserConfigs(); + const actions = React.useMemo(() => { const actions: ActionProps[] = [ { @@ -219,13 +214,13 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { await removeConfig({ key: config.key, userId }); }, onSuccess: () => { - onRemoveSuccess(config.key); + invalidateUserConfigs(); }, }, ]; return actions; - }, [config.key, onRemoveSuccess, userId]); + }, [config.key, invalidateUserConfigs, userId]); const chartTitle = React.useMemo(() => { const title = config.data.chartConfigs diff --git a/app/pages/api/config/list.ts b/app/pages/api/config/list.ts new file mode 100644 index 000000000..03fa57f2e --- /dev/null +++ b/app/pages/api/config/list.ts @@ -0,0 +1,21 @@ +import { getServerSession } from "next-auth"; +import SuperJSON from "superjson"; + +import { nextAuthOptions } from "@/pages/api/auth/[...nextauth]"; + +import { getUserConfigs } from "../../../db/config"; +import { api } from "../../../server/nextkit"; + +/** + * Endpoint to read configuration from + */ +const route = api({ + GET: async ({ req, res }) => { + const session = await getServerSession(req, res, nextAuthOptions); + return SuperJSON.serialize( + session?.user.id ? await getUserConfigs(session?.user.id) : [] + ); + }, +}); + +export default route; diff --git a/app/pages/profile.tsx b/app/pages/profile.tsx index e9909209c..96f02658c 100644 --- a/app/pages/profile.tsx +++ b/app/pages/profile.tsx @@ -4,12 +4,14 @@ import { GetServerSideProps } from "next"; import { getServerSession } from "next-auth"; import { AppLayout } from "@/components/layout"; -import { ParsedConfig, getUserConfigs } from "@/db/config"; -import { Serialized, deserializeProps, serializeProps } from "@/db/serialize"; +import { getUserConfigs, ParsedConfig } from "@/db/config"; +import { deserializeProps, Serialized, serializeProps } from "@/db/serialize"; import { findBySub } from "@/db/user"; +import { userConfigsKey } from "@/domain/user-configs"; import { ProfileContentTabs } from "@/login/components/profile-content-tabs"; import { ProfileHeader } from "@/login/components/profile-header"; import { useRootStyles } from "@/login/utils"; +import { useHydrate } from "@/utils/use-fetch-data"; import { nextAuthOptions } from "./api/auth/[...nextauth]"; @@ -49,11 +51,13 @@ const ProfilePage = (props: Serialized) => { const { user, userConfigs } = deserializeProps(props); const rootClasses = useRootStyles(); + useHydrate(userConfigsKey, userConfigs); + return ( - + ); diff --git a/app/utils/api.ts b/app/utils/api.ts index bf1c99422..fe01bdd52 100644 --- a/app/utils/api.ts +++ b/app/utils/api.ts @@ -1,3 +1,5 @@ +import SuperJSON from "superjson"; + export const apiFetch = async ( relativeUrl: string, options?: { @@ -21,7 +23,9 @@ export const apiFetch = async ( ); const json = await res.json(); if (json.success) { - return json.data; + return json.data && "json" in json.data && "meta" in json.data + ? (SuperJSON.deserialize(json.data) as T) + : (json.data as T); } else { throw new Error(json.message); } diff --git a/app/utils/chart-config/api.ts b/app/utils/chart-config/api.ts index 849cd9d55..a6827437d 100644 --- a/app/utils/chart-config/api.ts +++ b/app/utils/chart-config/api.ts @@ -6,6 +6,7 @@ import { createChartId } from "../create-chart-id"; import type apiConfigCreate from "../../pages/api/config-create"; import type apiConfigUpdate from "../../pages/api/config-update"; +import type apiConfigList from "../../pages/api/config/list"; import type apiConfig from "../../pages/api/config/[key]"; export const createConfig = async (state: ConfiguratorStatePublishing) => { @@ -80,3 +81,9 @@ export const fetchChartConfig = async (id: string) => { `/api/config/${id}` ); }; + +export const fetchChartConfigs = async () => { + return await apiFetch>( + `/api/config/list` + ); +}; diff --git a/app/utils/use-fetch-data.ts b/app/utils/use-fetch-data.ts index 2f79c032f..a6e7913b3 100644 --- a/app/utils/use-fetch-data.ts +++ b/app/utils/use-fetch-data.ts @@ -1,41 +1,125 @@ -import React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { stringifyVariables } from "urql"; type Status = "idle" | "fetching" | "success" | "error"; -type UseFetchDataOptions = { +export type UseFetchDataOptions = { enable?: boolean; initialStatus?: Status; + defaultData: TDefault; }; -export const useFetchData = ( - queryFn: () => Promise, - options: UseFetchDataOptions = {} +type QueryKey = string[]; + +type QueryCacheValue = { + data: T | null; + error: Error | null; + status: Status; +}; + +class QueryCache { + cache: Map>; + listeners: [string, () => void][]; + + constructor() { + this.cache = new Map(); + this.listeners = []; + } + + set(queryKey: QueryKey, value: QueryCacheValue) { + console.log("setting", queryKey, value); + this.cache.set(stringifyVariables(queryKey), value); + this.fire(queryKey); + } + + get(queryKey: QueryKey) { + return ( + this.cache.get(stringifyVariables(queryKey)) || { + data: null, + error: null, + status: "idle", + } + ); + } + + fire(queryKey: QueryKey) { + const key = stringifyVariables(queryKey); + this.listeners.forEach(([k, cb]) => key === k && cb()); + } + + listen(queryKey: QueryKey, cb: () => void) { + this.listeners.push([stringifyVariables(queryKey), cb]); + return () => + void this.listeners.splice( + this.listeners.findIndex((c) => c[1] === cb), + 1 + ); + } +} + +const cache = new QueryCache(); + +export const useFetchData = ( + queryKey: any[], + queryFn: () => Promise, + options: Partial> = {} ) => { - 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); + const { enable = true, defaultData } = options; - React.useEffect(() => { + const [, setState] = useState(0); + const cached = cache.get(queryKey) as QueryCacheValue; + const { data, error, status } = cached ?? {}; + + const fetchData = useCallback(async () => { + const cached = cache.get(queryKey); + if (cached?.status === "fetching") { + return; + } + cache.set(queryKey, { ...cache.get(queryKey), status: "fetching" }); + try { + const result = await queryFn(); + console.log("fetched", { result }); + cache.set(queryKey, { data: result, error: null, status: "success" }); + } catch (error) { + cache.set(queryKey, { + data: null, + error: error as Error, + status: "error", + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryKey]); + + useEffect(() => { + return cache.listen(queryKey, () => { + setState((n) => n + 1); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { if (!enable) { - setStatus("idle"); + cache.set(queryKey, { ...cache.get(queryKey), status: "idle" }); return; } - const fetchData = async () => { - setStatus("fetching"); - try { - const result = await queryFn(); - setData(result); - setStatus("success"); - } catch (error) { - setError(error as Error); - setStatus("error"); - } - }; + if (!cached) { + fetchData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enable, fetchData]); + const invalidate = useCallback(() => { fetchData(); - }, [queryFn, enable]); + }, [fetchData]); + + return { data: data ?? defaultData, error, status, invalidate }; +}; - return { data, error, status }; +export const useHydrate = (queryKey: QueryKey, data: T) => { + const hasHydrated = useRef(false); + if (!hasHydrated.current) { + cache.set(queryKey, { data, error: null, status: "idle" }); + hasHydrated.current = true; + } }; From aea33b72fe80551b557b486809ef4e0a5d6a9b64 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 12:08:21 +0100 Subject: [PATCH 05/59] feat: Add useMutate helper --- app/utils/use-fetch-data.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/app/utils/use-fetch-data.ts b/app/utils/use-fetch-data.ts index a6e7913b3..77128283b 100644 --- a/app/utils/use-fetch-data.ts +++ b/app/utils/use-fetch-data.ts @@ -123,3 +123,34 @@ export const useHydrate = (queryKey: QueryKey, data: T) => { hasHydrated.current = true; } }; + +export const useMutate = ( + queryFn: (...args: TArgs) => Promise +) => { + const [data, setData] = useState | null>(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState("idle"); + + const mutate = useCallback( + async (...args: TArgs) => { + setStatus("fetching"); + try { + const result = await queryFn(...args); + setData(result); + setStatus("success"); + } catch (error) { + setError(error as Error); + setStatus("error"); + } + }, + [queryFn] + ); + + const reset = useCallback(() => { + setData(null); + setError(null); + setStatus("idle"); + }, []); + + return { data, error, status, mutate, reset }; +}; From 38dbb984a13c32e8537c39a7b97b9a7780fb29bb Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 12:08:21 +0100 Subject: [PATCH 06/59] refactor: Remove config through useMutate --- app/login/components/profile-tables.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index 0bad8f41f..a07f47f9c 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -33,6 +33,7 @@ import { Icon, IconName } from "@/icons"; import { useRootStyles } from "@/login/utils"; import { useLocale } from "@/src"; import { removeConfig } from "@/utils/chart-config/api"; +import { useMutate } from "@/utils/use-fetch-data"; const PREVIEW_LIMIT = 3; @@ -176,6 +177,7 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { const { invalidate: invalidateUserConfigs } = useUserConfigs(); + const removeMut = useMutate(removeConfig); const actions = React.useMemo(() => { const actions: ActionProps[] = [ { @@ -199,7 +201,7 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { { type: "button", label: t({ id: "login.chart.delete", message: "Delete" }), - iconName: "trash", + iconName: removeMut.status === "fetching" ? "loading" : "linkExternal", requireConfirmation: true, confirmationTitle: t({ id: "login.chart.delete.confirmation", @@ -210,8 +212,8 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { message: "Keep in mind that removing this visualization will affect all the places where it might be already embedded!", }), - onClick: async () => { - await removeConfig({ key: config.key, userId }); + onClick: () => { + removeMut.mutate({ key: config.key, userId }); }, onSuccess: () => { invalidateUserConfigs(); @@ -220,7 +222,14 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { ]; return actions; - }, [config.key, invalidateUserConfigs, userId]); + }, [ + config.data, + config.key, + config.published_state, + invalidateUserConfigs, + removeMut, + userId, + ]); const chartTitle = React.useMemo(() => { const title = config.data.chartConfigs @@ -380,7 +389,7 @@ type ActionButtonProps = { requireConfirmation?: boolean; confirmationTitle?: string; confirmationText?: string; - onClick: () => Promise; + onClick: () => Promise | void; onDialogClose?: () => void; onSuccess?: () => void; }; From 42d977ab7e8a0f192b7262ff22dbe33b3c5db180 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 12:08:21 +0100 Subject: [PATCH 07/59] feat: Add draft published state --- app/config-types.ts | 25 +++++++++++++++---------- app/utils/chart-config/api.ts | 1 + 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/app/config-types.ts b/app/config-types.ts index 60177e198..f30742b1d 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -1127,16 +1127,21 @@ const Layout = t.intersection([ export type Layout = t.TypeOf; export type LayoutType = Layout["type"]; -const Config = t.type( - { - version: t.string, - dataSource: DataSource, - layout: Layout, - chartConfigs: t.array(ChartConfig), - activeChartKey: t.string, - }, - "Config" -); +const Config = t.intersection([ + t.type( + { + version: t.string, + dataSource: DataSource, + layout: Layout, + chartConfigs: t.array(ChartConfig), + activeChartKey: t.string, + }, + "Config" + ), + t.partial({ + publishedState: t.string, + }), +]); export type Config = t.TypeOf; export const isValidConfig = (config: unknown): config is Config => { diff --git a/app/utils/chart-config/api.ts b/app/utils/chart-config/api.ts index a6827437d..1161314bd 100644 --- a/app/utils/chart-config/api.ts +++ b/app/utils/chart-config/api.ts @@ -55,6 +55,7 @@ export const updateConfig = async ( layout: state.layout, chartConfigs: state.chartConfigs, activeChartKey: state.activeChartKey, + publishedState: state.publishedState, }, }, } From f81c1496de9d745ddafa11003f54b449ffeb9aa3 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 12:08:21 +0100 Subject: [PATCH 08/59] feat: Ability to turn config into draft/publish it from chart list --- app/db/config.ts | 18 +++++------ app/domain/user-configs.ts | 12 ++------ app/login/components/profile-tables.tsx | 40 ++++++++++++++++++++++++- app/pages/api/config-update.ts | 3 +- app/prisma/schema.prisma | 9 ++++++ app/utils/chart-config/api.ts | 38 +++++++++++++---------- 6 files changed, 82 insertions(+), 38 deletions(-) diff --git a/app/db/config.ts b/app/db/config.ts index d837d2b04..954acd3a7 100644 --- a/app/db/config.ts +++ b/app/db/config.ts @@ -45,10 +45,12 @@ export const updateConfig = async ({ key, data, userId, + published_state, }: { key: string; - data: Prisma.ConfigCreateInput["data"]; + data: Prisma.ConfigUpdateInput["data"]; userId: User["id"]; + published_state: Prisma.ConfigUpdateInput["published_state"]; }): Promise<{ key: string }> => { return await prisma.config.update({ where: { @@ -58,6 +60,8 @@ export const updateConfig = async ({ key, data, user_id: userId, + updated_at: new Date(), + published_state: published_state, }, }); }; @@ -109,15 +113,7 @@ const ensureFiltersOrder = (chartConfig: ChartConfig) => { }; }; -const parseDbConfig = ( - d: Config -): { - id: number; - key: string; - data: ConfiguratorStatePublished; - created_at: Date; - user_id: number | null; -} => { +const parseDbConfig = (d: Config) => { const data = d.data as ConfiguratorStatePublished; const migratedData = migrateConfiguratorState(data); @@ -134,7 +130,7 @@ const parseDbConfig = ( iri: migrateCubeIri(cube.iri), })), })), - }, + } as ConfiguratorStatePublished, }; }; diff --git a/app/domain/user-configs.ts b/app/domain/user-configs.ts index 9afbdab29..1858909de 100644 --- a/app/domain/user-configs.ts +++ b/app/domain/user-configs.ts @@ -1,20 +1,14 @@ import { useCallback } from "react"; +import { ParsedConfig } from "@/db/config"; import { fetchChartConfig, fetchChartConfigs } from "@/utils/chart-config/api"; import { useFetchData, UseFetchDataOptions } from "@/utils/use-fetch-data"; export const userConfigsKey = ["userConfigs"]; export const useConfigKey = (t: string) => ["userConfigs", t]; -export const useUserConfigs = (options?: UseFetchDataOptions) => - useFetchData( - userConfigsKey, - async () => { - const d = await fetchChartConfigs(); - return d; - }, - options - ); +export const useUserConfigs = (options?: UseFetchDataOptions) => + useFetchData(userConfigsKey, fetchChartConfigs, options); export const useUserConfig = ( chartId: string | undefined, diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index a07f47f9c..4321cb136 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -20,6 +20,7 @@ import { Tooltip, Typography, } from "@mui/material"; +import { PUBLISHED_STATE } from "@prisma/client"; import NextLink from "next/link"; import React from "react"; @@ -32,7 +33,7 @@ import { useDataCubesMetadataQuery } from "@/graphql/hooks"; import { Icon, IconName } from "@/icons"; import { useRootStyles } from "@/login/utils"; import { useLocale } from "@/src"; -import { removeConfig } from "@/utils/chart-config/api"; +import { removeConfig, updateConfig } from "@/utils/chart-config/api"; import { useMutate } from "@/utils/use-fetch-data"; const PREVIEW_LIMIT = 3; @@ -177,6 +178,7 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { const { invalidate: invalidateUserConfigs } = useUserConfigs(); + const updatePublishedStateMut = useMutate(updateConfig); const removeMut = useMutate(removeConfig); const actions = React.useMemo(() => { const actions: ActionProps[] = [ @@ -198,6 +200,41 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { label: t({ id: "login.chart.share", message: "Share" }), iconName: "linkExternal", }, + { + type: "button", + label: t({ + id: "login.chart.draft", + message: + config.published_state === PUBLISHED_STATE.DRAFT + ? `Publish` + : "Turn into draft", + }), + iconName: + updatePublishedStateMut.status === "fetching" + ? "loading" + : "linkExternal", + + onClick: async () => { + await updatePublishedStateMut.mutate( + { + ...config.data, + state: "PUBLISHING", + }, + { + key: config.key, + userId, + published_state: + config.published_state === PUBLISHED_STATE.DRAFT + ? PUBLISHED_STATE.PUBLISHED + : PUBLISHED_STATE.DRAFT, + } + ); + invalidateUserConfigs(); + }, + onSuccess: () => { + invalidateUserConfigs(); + }, + }, { type: "button", label: t({ id: "login.chart.delete", message: "Delete" }), @@ -228,6 +265,7 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { config.published_state, invalidateUserConfigs, removeMut, + updatePublishedStateMut, userId, ]); diff --git a/app/pages/api/config-update.ts b/app/pages/api/config-update.ts index e448fbe4f..72cd432f1 100644 --- a/app/pages/api/config-update.ts +++ b/app/pages/api/config-update.ts @@ -10,7 +10,7 @@ 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; + const { key, userId, data, published_state } = req.body; if (serverUserId !== userId) { throw new Error("Unauthorized!"); @@ -20,6 +20,7 @@ const route = api({ key, data, userId, + published_state, }); }, }); diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index beedfd0f7..a0a2f2f7b 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -7,15 +7,24 @@ datasource db { url = env("DATABASE_URL") } +enum PUBLISHED_STATE { + PUBLISHED + ARCHIVED + DRAFT +} + model Config { id Int @id @default(autoincrement()) key String @unique @db.Char(12) data Json created_at DateTime @default(now()) @db.Timestamp(6) + updated_at DateTime @default(now()) @db.Timestamp(6) user User? @relation(fields: [user_id], references: [id]) user_id Int? + published_state PUBLISHED_STATE @default(DRAFT) + @@map("config") } diff --git a/app/utils/chart-config/api.ts b/app/utils/chart-config/api.ts index 1161314bd..6395027ac 100644 --- a/app/utils/chart-config/api.ts +++ b/app/utils/chart-config/api.ts @@ -1,12 +1,16 @@ +import { PUBLISHED_STATE } from "@prisma/client"; +import isUndefined from "lodash/isUndefined"; +import omitBy from "lodash/omitBy"; import { InferAPIResponse } from "nextkit"; +import { ParsedConfig } from "@/db/config"; + import { ConfiguratorStatePublishing } from "../../config-types"; import { apiFetch } from "../api"; import { createChartId } from "../create-chart-id"; import type apiConfigCreate from "../../pages/api/config-create"; import type apiConfigUpdate from "../../pages/api/config-update"; -import type apiConfigList from "../../pages/api/config/list"; import type apiConfig from "../../pages/api/config/[key]"; export const createConfig = async (state: ConfiguratorStatePublishing) => { @@ -33,31 +37,35 @@ export const createConfig = async (state: ConfiguratorStatePublishing) => { type UpdateConfigOptions = { key: string; userId: number; + published_state?: PUBLISHED_STATE; }; export const updateConfig = async ( state: ConfiguratorStatePublishing, options: UpdateConfigOptions ) => { - const { key, userId } = options; + const { key, userId, published_state } = options; return apiFetch>( "/api/config-update", { method: "POST", - data: { - key, - userId, - data: { + data: omitBy( + { key, - version: state.version, - dataSource: state.dataSource, - layout: state.layout, - chartConfigs: state.chartConfigs, - activeChartKey: state.activeChartKey, - publishedState: state.publishedState, + userId, + data: { + key, + version: state.version, + dataSource: state.dataSource, + layout: state.layout, + chartConfigs: state.chartConfigs, + activeChartKey: state.activeChartKey, + }, + published_state, }, - }, + isUndefined + ), } ); }; @@ -84,7 +92,5 @@ export const fetchChartConfig = async (id: string) => { }; export const fetchChartConfigs = async () => { - return await apiFetch>( - `/api/config/list` - ); + return await apiFetch(`/api/config/list`); }; From c995497bcddcce243f5953b76ffe69ac25685c38 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 14:55:15 +0100 Subject: [PATCH 09/59] refactor: Extract saveState from component --- app/configurator/configurator-state.tsx | 152 +++++++++++++++--------- 1 file changed, 93 insertions(+), 59 deletions(-) diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index 48e9186cf..f6887c2a4 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -1622,70 +1622,33 @@ const ConfiguratorStateProviderInternal = ( case "PUBLISHING": (async () => { try { + const key = getRouterChartId(asPath); + if (!key) { + return; + } switch (state.layout.type) { case "singleURLs": { - const { publishableChartKeys, meta } = state.layout; - const reversedChartKeys = publishableChartKeys - .slice() - .reverse(); - - // Do not use Promise.all here, as we want to publish the charts in order - // and not in parallel and keep the current tab open with first chart - return reversedChartKeys.forEach(async (chartKey, i) => { - const preparedConfig = preparePublishingState( - { - ...state, - // Ensure that the layout is reset to single-chart mode - layout: { - type: "tab", - meta: meta, - activeField: undefined, - }, - }, - state.chartConfigs.filter( - (d) => d.key === chartKey - ) as ChartConfig[], - chartKey - ); - - const result = await createConfig(preparedConfig); - - if (i < reversedChartKeys.length - 1) { - // Open new tab for each chart, except the first one - return window.open( - `/${locale}/v/${result.key}`, - "_blank" - ); + return saveState( + user, + key, + state, + async (result, i, total) => { + if (i < total) { + // Open new tab for each chart, except the first one + return window.open( + `/${locale}/v/${result.key}`, + "_blank" + ); + } else { + await handlePublishSuccess(result.key, push); + } } - - await handlePublishSuccess(result.key, push); - }); + ); } default: { - 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 = preparePublishingState( - state, - state.chartConfigs - ); - - const result = await (dbConfig && user - ? updateConfig(preparedConfig, { - key: dbConfig.key, - userId: user.id, - }) - : createConfig(preparedConfig)); - - await handlePublishSuccess(result.key, push); + return saveState(user, key, state, async (result) => { + await handlePublishSuccess(result.key, push); + }); } } } catch (e) { @@ -1881,3 +1844,74 @@ export type ConfiguratorStateWithChartConfigs = | ConfiguratorStateLayouting | ConfiguratorStatePublishing | ConfiguratorStatePublished; + +async function saveState( + user: ReturnType, + key: string, + state: Extract, + onSaveConfig: (savedConfig: { key: string }, i: number, total: number) => void +) { + switch (state.layout.type) { + case "singleURLs": + const { publishableChartKeys, meta } = state.layout; + const reversedChartKeys = publishableChartKeys.slice().reverse(); + + // Charts are published in order, keep the current tab open with first chart + // subSequent charts are opened in a new window + return allSequential(reversedChartKeys, async (chartKey, i) => { + const preparedConfig = preparePublishingState( + { + ...state, + // Ensure that the layout is reset to single-chart mode + layout: { + type: "tab", + meta: meta, + activeField: undefined, + }, + }, + state.chartConfigs.filter((d) => d.key === chartKey) as ChartConfig[], + chartKey + ); + + const result = await createConfig(preparedConfig); + + onSaveConfig(result, i, reversedChartKeys.length); + return result; + }); + default: + let dbConfig: ParsedConfig | undefined; + + if (key && user) { + const config = await fetchChartConfig(key); + + if (config && config.user_id === user.id) { + dbConfig = config; + } + } + + const preparedConfig = preparePublishingState(state, state.chartConfigs); + + const result = await (dbConfig && user + ? updateConfig(preparedConfig, { + key: dbConfig.key, + userId: user.id, + }) + : createConfig(preparedConfig)); + + onSaveConfig(result, 0, 1); + return result; + } +} + +const allSequential = async ( + input: TInput[], + cb: (item: TInput, i: number) => TOutput +) => { + const res = []; + for (let i = 0; i < input.length; i++) { + const r = input[i]; + const result = await cb(r, i); + res.push(result); + } + return res; +}; From 384427903c730f02a99e781ac39138fab47bae03 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 15:28:31 +0100 Subject: [PATCH 10/59] fix: Config for existing chart is correctly fetched --- app/domain/user-configs.ts | 4 ++-- app/utils/use-fetch-data.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/domain/user-configs.ts b/app/domain/user-configs.ts index 1858909de..af08434d5 100644 --- a/app/domain/user-configs.ts +++ b/app/domain/user-configs.ts @@ -5,7 +5,7 @@ import { fetchChartConfig, fetchChartConfigs } from "@/utils/chart-config/api"; import { useFetchData, UseFetchDataOptions } from "@/utils/use-fetch-data"; export const userConfigsKey = ["userConfigs"]; -export const useConfigKey = (t: string) => ["userConfigs", t]; +export const userConfigKey = (t: string) => ["userConfigs", t]; export const useUserConfigs = (options?: UseFetchDataOptions) => useFetchData(userConfigsKey, fetchChartConfigs, options); @@ -15,7 +15,7 @@ export const useUserConfig = ( options?: UseFetchDataOptions ) => { let queryFn = useCallback(() => fetchChartConfig(chartId ?? ""), [chartId]); - return useFetchData(userConfigsKey, queryFn, { + return useFetchData(userConfigKey(chartId!), queryFn, { enable: !!chartId, ...options, }); diff --git a/app/utils/use-fetch-data.ts b/app/utils/use-fetch-data.ts index 77128283b..407def8da 100644 --- a/app/utils/use-fetch-data.ts +++ b/app/utils/use-fetch-data.ts @@ -103,7 +103,7 @@ export const useFetchData = ( return; } - if (!cached) { + if (!cached.data) { fetchData(); } // eslint-disable-next-line react-hooks/exhaustive-deps From 23dae00df01d83a3ba935f8f3f5555ecd2f44529 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 16:38:48 +0100 Subject: [PATCH 11/59] refactor: Rename for clarity --- app/components/hint.tsx | 2 +- app/docs/hint.docs.tsx | 12 ++++++------ app/pages/v/[chartId].tsx | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/components/hint.tsx b/app/components/hint.tsx index 9669031c8..10941533a 100644 --- a/app/components/hint.tsx +++ b/app/components/hint.tsx @@ -265,7 +265,7 @@ export const OnlyNegativeDataHint = () => ( ); -export const Success = () => ( +export const PublishSuccess = () => ( }> Your visualization is now published. You can share and embed it using the diff --git a/app/docs/hint.docs.tsx b/app/docs/hint.docs.tsx index a09ffd960..d82a1c9d7 100644 --- a/app/docs/hint.docs.tsx +++ b/app/docs/hint.docs.tsx @@ -2,13 +2,13 @@ import { Stack } from "@mui/material"; import { markdown, ReactSpecimen } from "catalog"; import { - Loading, - Error, - Success, - OnlyNegativeDataHint, ChartUnexpectedError, - LoadingGeoDimensionsError, + Error, + Loading, LoadingDataError, + LoadingGeoDimensionsError, + OnlyNegativeDataHint, + PublishSuccess, } from "@/components/hint"; export default () => markdown` @@ -37,7 +37,7 @@ ${( ## Success message ${( - + )} diff --git a/app/pages/v/[chartId].tsx b/app/pages/v/[chartId].tsx index 7498c75a7..eb0aeb939 100644 --- a/app/pages/v/[chartId].tsx +++ b/app/pages/v/[chartId].tsx @@ -9,7 +9,7 @@ import { useRouter } from "next/router"; import React, { useState } from "react"; import { ChartPublished } from "@/components/chart-published"; -import { Success } from "@/components/hint"; +import { PublishSuccess } from "@/components/hint"; import { ContentLayout } from "@/components/layout"; import { PublishActions } from "@/components/publish-actions"; import { @@ -19,7 +19,7 @@ import { getChartConfig, } from "@/configurator"; import { getConfig } from "@/db/config"; -import { Serialized, deserializeProps, serializeProps } from "@/db/serialize"; +import { deserializeProps, Serialized, serializeProps } from "@/db/serialize"; import { useLocale } from "@/locales/use-locale"; import { useDataSourceStore } from "@/stores/data-source"; import { EmbedOptionsProvider } from "@/utils/embed"; @@ -154,7 +154,7 @@ const VisualizationPage = (props: Serialized) => { > {publishSuccess && ( - + )} From 1d5792c456d1ffc45b349fe211e98aefb9baf3eb Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 13 Feb 2024 17:10:06 +0100 Subject: [PATCH 12/59] feat: Use menu instead of tooltip --- app/login/components/profile-tables.tsx | 51 +++++++++++-------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index 4321cb136..abd7dc44f 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -3,7 +3,6 @@ import { Box, Button, CircularProgress, - ClickAwayListener, Dialog, DialogActions, DialogContent, @@ -11,13 +10,14 @@ import { DialogTitle, IconButton, Link, + Menu, + MenuItem, Skeleton, Table, TableBody, TableCell, TableHead, TableRow, - Tooltip, Typography, } from "@mui/material"; import { PUBLISHED_STATE } from "@prisma/client"; @@ -352,29 +352,20 @@ const Actions = (props: ActionsProps) => { const { isOpen, open, close } = useDisclosure(); return ( - - - {actions.map((props, i) => ( - - ))} - - } - sx={{ p: 2 }} - componentsProps={{ tooltip: { sx: { p: 3, pb: 2 } } }} - > - - - - - + <> + + + + + {actions.map((props, i) => ( + + ))} + + ); }; @@ -404,7 +395,8 @@ const ActionLink = (props: ActionLinkProps) => { return ( - { > {label} - + ); }; @@ -448,7 +440,8 @@ const ActionButton = (props: ActionButtonProps) => { return ( <> - { // To prevent the click away listener from closing the dialog. e.stopPropagation(); @@ -469,7 +462,7 @@ const ActionButton = (props: ActionButtonProps) => { > {label} - + {requireConfirmation && ( Date: Thu, 15 Feb 2024 08:04:58 +0100 Subject: [PATCH 13/59] fix fetch data --- app/utils/use-fetch-data.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/utils/use-fetch-data.ts b/app/utils/use-fetch-data.ts index 407def8da..c43b377ed 100644 --- a/app/utils/use-fetch-data.ts +++ b/app/utils/use-fetch-data.ts @@ -27,7 +27,6 @@ class QueryCache { } set(queryKey: QueryKey, value: QueryCacheValue) { - console.log("setting", queryKey, value); this.cache.set(stringifyVariables(queryKey), value); this.fire(queryKey); } @@ -78,7 +77,6 @@ export const useFetchData = ( cache.set(queryKey, { ...cache.get(queryKey), status: "fetching" }); try { const result = await queryFn(); - console.log("fetched", { result }); cache.set(queryKey, { data: result, error: null, status: "success" }); } catch (error) { cache.set(queryKey, { @@ -103,11 +101,11 @@ export const useFetchData = ( return; } - if (!cached.data) { + if (cached.status === "idle") { fetchData(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enable, fetchData]); + }, [enable, fetchData, cached.data]); const invalidate = useCallback(() => { fetchData(); @@ -138,6 +136,7 @@ export const useMutate = ( const result = await queryFn(...args); setData(result); setStatus("success"); + return result; } catch (error) { setError(error as Error); setStatus("error"); From 966335304ddc227cdcc7a5ac52ce196cf839aa18 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Thu, 15 Feb 2024 08:46:50 +0100 Subject: [PATCH 14/59] refactor: Rename method --- app/configurator/configurator-state.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index f6887c2a4..546851112 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -1628,7 +1628,7 @@ const ConfiguratorStateProviderInternal = ( } switch (state.layout.type) { case "singleURLs": { - return saveState( + return publishState( user, key, state, @@ -1646,7 +1646,7 @@ const ConfiguratorStateProviderInternal = ( ); } default: { - return saveState(user, key, state, async (result) => { + return publishState(user, key, state, async (result) => { await handlePublishSuccess(result.key, push); }); } @@ -1845,10 +1845,12 @@ export type ConfiguratorStateWithChartConfigs = | ConfiguratorStatePublishing | ConfiguratorStatePublished; -async function saveState( +async function publishState( user: ReturnType, key: string, state: Extract, + + /** Will be called for all config that have been shared (multiple one in case of layout:singleURLs) */ onSaveConfig: (savedConfig: { key: string }, i: number, total: number) => void ) { switch (state.layout.type) { From 43035290b374ed3a1404baabdf470019586a23f7 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Thu, 15 Feb 2024 08:47:13 +0100 Subject: [PATCH 15/59] refactor: Decompose effect in both parts --- app/pages/v/[chartId].tsx | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/app/pages/v/[chartId].tsx b/app/pages/v/[chartId].tsx index eb0aeb939..e67baea56 100644 --- a/app/pages/v/[chartId].tsx +++ b/app/pages/v/[chartId].tsx @@ -95,21 +95,29 @@ const VisualizationPage = (props: Serialized) => { const chartConfig = state ? getChartConfig(state) : undefined; const { dataSource, setDataSource } = useDataSourceStore(); - React.useEffect(() => { - // Remove publishSuccess from URL so that when reloading of sharing the link - // to someone, there is no publishSuccess mention - if (query.publishSuccess) { - replace({ pathname: window.location.pathname }); - } + useEffect( + function removePulishSuccessFromURL() { + // Remove publishSuccess from URL so that when reloading of sharing the link + // to someone, there is no publishSuccess mention + if (query.publishSuccess) { + replace({ pathname: window.location.pathname }); + } + }, + [query.publishSuccess, replace] + ); - if ( - props.status === "found" && - props.config.data.dataSource.url !== dataSource.url - ) { - setDataSource(props.config.data.dataSource); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataSource.url, setDataSource, props]); + useEffect( + function setCorrectDataSource() { + if ( + props.status === "found" && + props.config.data.dataSource.url !== dataSource.url + ) { + setDataSource(props.config.data.dataSource); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, + [dataSource.url, setDataSource, props] + ); if ( status === "notfound" || From 9d77f582977d532fb886b57e3613a15b1755c519 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Thu, 15 Feb 2024 08:47:37 +0100 Subject: [PATCH 16/59] feat: Show warning if viewing chart still in draft --- app/pages/v/[chartId].tsx | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/app/pages/v/[chartId].tsx b/app/pages/v/[chartId].tsx index e67baea56..6266de26b 100644 --- a/app/pages/v/[chartId].tsx +++ b/app/pages/v/[chartId].tsx @@ -1,19 +1,20 @@ import { Trans } from "@lingui/macro"; -import { Box, Button, Stack, Theme, Typography } from "@mui/material"; +import { Alert, Box, Button, Stack, Theme, Typography } from "@mui/material"; import { makeStyles } from "@mui/styles"; +import { Config, PUBLISHED_STATE } from "@prisma/client"; import { GetServerSideProps } from "next"; import ErrorPage from "next/error"; import Head from "next/head"; import NextLink from "next/link"; import { useRouter } from "next/router"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { ChartPublished } from "@/components/chart-published"; import { PublishSuccess } from "@/components/hint"; import { ContentLayout } from "@/components/layout"; import { PublishActions } from "@/components/publish-actions"; import { - Config, + Config as ChartConfig, ConfiguratorStateProvider, ConfiguratorStatePublished, getChartConfig, @@ -30,9 +31,8 @@ type PageProps = } | { status: "found"; - config: { - key: string; - data: Config; + config: Omit & { + data: ChartConfig; }; }; @@ -74,7 +74,7 @@ const VisualizationPage = (props: Serialized) => { // Keep initial value of publishSuccess const [publishSuccess] = useState(() => !!query.publishSuccess); - const { status } = deserializeProps(props); + const { status, config } = deserializeProps(props); const { key, state } = React.useMemo(() => { if (props.status === "found") { @@ -166,6 +166,16 @@ const VisualizationPage = (props: Serialized) => { )} + {config.published_state === PUBLISHED_STATE.DRAFT && ( + + + + This chart is still in draft. + + + + )} + From ba8016762e023f4db57736bc9bc2c03bfcaf8587 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Thu, 15 Feb 2024 08:47:54 +0100 Subject: [PATCH 17/59] feat: Add save draft button --- app/components/chart-selection-tabs.tsx | 107 ++++++++++++++++++++++-- app/components/use-local-snack.tsx | 34 ++++++++ app/config-types.ts | 26 +++--- app/configurator/configurator-state.tsx | 9 +- app/db/config.ts | 5 +- app/login/components/profile-tables.tsx | 21 ++++- app/pages/api/config-create.ts | 3 +- app/prisma/schema.prisma | 2 +- app/server/nextkit.ts | 6 +- app/utils/chart-config/api.ts | 22 ++--- 10 files changed, 193 insertions(+), 42 deletions(-) create mode 100644 app/components/use-local-snack.tsx diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index 71fa9e708..f5d6ddebb 100644 --- a/app/components/chart-selection-tabs.tsx +++ b/app/components/chart-selection-tabs.tsx @@ -8,8 +8,10 @@ import { Tabs, Theme, Tooltip, + useEventCallback, } from "@mui/material"; import { makeStyles } from "@mui/styles"; +import { PUBLISHED_STATE } from "@prisma/client"; import { useSession } from "next-auth/react"; import { useRouter } from "next/router"; import React from "react"; @@ -37,9 +39,13 @@ import { useUserConfig } from "@/domain/user-configs"; import { useDataCubesComponentsQuery } from "@/graphql/hooks"; import { Icon, IconName } from "@/icons"; import { useLocale } from "@/src"; +import { createConfig, updateConfig } 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 { useMutate } from "@/utils/use-fetch-data"; + +import { useLocalSnack } from "./use-local-snack"; type TabsState = { popoverOpen: boolean; @@ -313,15 +319,97 @@ export const LayoutChartButton = () => { ); }; -export const PublishChartButton = () => { - const { asPath } = useRouter(); +const SaveAsDraftButton = ({ chartId }: { chartId: string | undefined }) => { + const { data: config, invalidate: invalidateConfig } = useUserConfig(chartId); + console.log("config", config?.user_id); + const session = useSession(); + + const [state] = useConfiguratorState(); + + const [snack, enqueueSnackbar] = useLocalSnack(); + const { asPath, replace } = useRouter(); + + const createConfigMut = useMutate(createConfig); + const updatePublishedStateMut = useMutate(updateConfig); + const loggedInId = session.data?.user.id; + + const handleClick = useEventCallback(async () => { + try { + if (config?.user_id && loggedInId) { + const updated = await updatePublishedStateMut.mutate(state, { + userId: loggedInId, + published_state: PUBLISHED_STATE.DRAFT, + key: config.key, + }); + + if (updated) { + if (asPath !== `/create/${updated.key}`) { + replace(`/create/new?edit=${updated.key}`); + } + enqueueSnackbar({ + message: "Draft updated !", + variant: "success", + }); + } else { + throw new Error("Could not update draft"); + } + } else if (state) { + const saved = await createConfigMut.mutate( + state, + PUBLISHED_STATE.DRAFT + ); + if (saved) { + enqueueSnackbar({ + message: "Draft saved !", + variant: "success", + }); + replace(`/create/${saved.key}`); + } else { + throw new Error("Could not save draft"); + } + } + invalidateConfig(); + } catch (e) { + enqueueSnackbar({ + message: `Error while saving draft: ${ + e instanceof Error ? e.message : e + }`, + variant: "error", + }); + } + }); + + return ( + + + + ); +}; + +export const PublishChartButton = ({ + chartId, +}: { + chartId: string | undefined; +}) => { const session = useSession(); - const chartId = getRouterChartId(asPath); const { data: config, status } = useUserConfig(chartId); const editingPublishedChart = - session.data?.user.id && config?.user_id === session.data.user.id; + session.data?.user.id && + config?.user_id === session.data.user.id && + config.published_state === "PUBLISHED"; - return status === "fetching" ? null : ( + return status === "fetching" && !config ? ( + <>{status} + ) : ( {editingPublishedChart ? ( @@ -367,6 +455,9 @@ const TabsInner = (props: TabsInnerProps) => { } = props; const [state, dispatch] = useConfiguratorState(hasChartConfigs); + const { asPath } = useRouter(); + const chartId = getRouterChartId(asPath); + return ( { {...provided.draggableProps} {...provided.dragHandleProps} style={{ ...style, transform, opacity: 1 }} + component="div" key={d.key} sx={{ mr: 2, @@ -475,7 +567,10 @@ const TabsInner = (props: TabsInnerProps) => { (enableLayouting(state) ? ( ) : ( - + + + + ))} ); diff --git a/app/components/use-local-snack.tsx b/app/components/use-local-snack.tsx new file mode 100644 index 000000000..75aac43a9 --- /dev/null +++ b/app/components/use-local-snack.tsx @@ -0,0 +1,34 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * Holds a temporary snack state + */ +export const useLocalSnack = () => { + type Snack = { + message: string; + variant: "success" | "error"; + duration?: number; + }; + const timeoutRef = useRef>(); + const [snack, setSnack] = useState(undefined as Snack | undefined); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return [ + snack, + useCallback((snack: Snack | undefined) => { + setSnack(snack); + if (snack) { + timeoutRef.current = setTimeout(() => { + setSnack(undefined); + }, snack.duration || 5000); + } + }, []), + ] as const; +}; diff --git a/app/config-types.ts b/app/config-types.ts index f30742b1d..895065eea 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -1127,21 +1127,17 @@ const Layout = t.intersection([ export type Layout = t.TypeOf; export type LayoutType = Layout["type"]; -const Config = t.intersection([ - t.type( - { - version: t.string, - dataSource: DataSource, - layout: Layout, - chartConfigs: t.array(ChartConfig), - activeChartKey: t.string, - }, - "Config" - ), - t.partial({ - publishedState: t.string, - }), -]); +const Config = t.type( + { + version: t.string, + dataSource: DataSource, + layout: Layout, + chartConfigs: t.array(ChartConfig), + activeChartKey: t.string, + }, + "Config" +); +[]; export type Config = t.TypeOf; export const isValidConfig = (config: unknown): config is Config => { diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index 546851112..e3b5a257f 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -1,3 +1,4 @@ +import { PUBLISHED_STATE } from "@prisma/client"; import produce, { current } from "immer"; import get from "lodash/get"; import pickBy from "lodash/pickBy"; @@ -1875,7 +1876,10 @@ async function publishState( chartKey ); - const result = await createConfig(preparedConfig); + const result = await createConfig( + preparedConfig, + PUBLISHED_STATE.PUBLISHED + ); onSaveConfig(result, i, reversedChartKeys.length); return result; @@ -1897,8 +1901,9 @@ async function publishState( ? updateConfig(preparedConfig, { key: dbConfig.key, userId: user.id, + published_state: PUBLISHED_STATE.PUBLISHED, }) - : createConfig(preparedConfig)); + : createConfig(preparedConfig, PUBLISHED_STATE.PUBLISHED)); onSaveConfig(result, 0, 1); return result; diff --git a/app/db/config.ts b/app/db/config.ts index 954acd3a7..75b468476 100644 --- a/app/db/config.ts +++ b/app/db/config.ts @@ -2,7 +2,7 @@ * Server side methods to connect to the database */ -import { Config, Prisma, User } from "@prisma/client"; +import { Config, Prisma, PUBLISHED_STATE, User } from "@prisma/client"; import { ChartConfig, ConfiguratorStatePublished } from "@/configurator"; import { migrateConfiguratorState } from "@/utils/chart-config/versioning"; @@ -20,16 +20,19 @@ export const createConfig = async ({ key, data, userId, + publishedState, }: { key: string; data: Prisma.ConfigCreateInput["data"]; userId?: User["id"] | undefined; + publishedState: PUBLISHED_STATE; }): Promise<{ key: string }> => { return await prisma.config.create({ data: { key, data, user_id: userId, + published_state: publishedState, }, }); }; diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index abd7dc44f..af6fe9c05 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -23,6 +23,7 @@ import { import { PUBLISHED_STATE } from "@prisma/client"; import NextLink from "next/link"; import React from "react"; +import { ObjectInspector } from "react-inspector"; import useDisclosure from "@/components/use-disclosure"; import { ParsedConfig } from "@/db/config"; @@ -122,6 +123,11 @@ export const ProfileVisualizationsTable = ( Published + + + Updated + + Actions @@ -182,6 +188,12 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { const removeMut = useMutate(removeConfig); const actions = React.useMemo(() => { const actions: ActionProps[] = [ + { + type: "link", + href: `/v/${config.key}`, + label: t({ id: "login.chart.view", message: "View" }), + iconName: "eye", + }, { type: "link", href: `/create/new?copy=${config.key}`, @@ -332,7 +344,14 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { - {config.created_at.toLocaleDateString("de")} + {config.published_state === PUBLISHED_STATE.DRAFT + ? "Draft" + : config.created_at.toLocaleDateString("de")} + + + + + {config.updated_at.toLocaleDateString("de")} diff --git a/app/pages/api/config-create.ts b/app/pages/api/config-create.ts index 761b4fe39..75da64b09 100644 --- a/app/pages/api/config-create.ts +++ b/app/pages/api/config-create.ts @@ -10,12 +10,13 @@ const route = api({ POST: async ({ req, res }) => { const session = await getServerSession(req, res, nextAuthOptions); const userId = session?.user?.id; - const { data } = req.body; + const { data, publishedState } = req.body; return await createConfig({ key: data.key, data, userId, + publishedState: publishedState, }); }, }); diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index a0a2f2f7b..77f2e7a7c 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -23,7 +23,7 @@ model Config { user User? @relation(fields: [user_id], references: [id]) user_id Int? - published_state PUBLISHED_STATE @default(DRAFT) + published_state PUBLISHED_STATE @default(PUBLISHED) @@map("config") } diff --git a/app/server/nextkit.ts b/app/server/nextkit.ts index d34b0e568..847d11bec 100644 --- a/app/server/nextkit.ts +++ b/app/server/nextkit.ts @@ -1,10 +1,12 @@ import createAPI from "nextkit"; export const api = createAPI({ - async onError() { + async onError(req, res, error) { return { status: 500, - message: "Something went wrong.", + message: `Something went wrong: ${ + error instanceof Error ? error.message : error + }`, }; }, }); diff --git a/app/utils/chart-config/api.ts b/app/utils/chart-config/api.ts index 6395027ac..f25986756 100644 --- a/app/utils/chart-config/api.ts +++ b/app/utils/chart-config/api.ts @@ -5,7 +5,7 @@ import { InferAPIResponse } from "nextkit"; import { ParsedConfig } from "@/db/config"; -import { ConfiguratorStatePublishing } from "../../config-types"; +import { ConfiguratorState } from "../../config-types"; import { apiFetch } from "../api"; import { createChartId } from "../create-chart-id"; @@ -13,7 +13,10 @@ 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) => { +export const createConfig = async ( + state: ConfiguratorState, + publishedState: PUBLISHED_STATE +) => { return apiFetch>( "/api/config-create", { @@ -23,12 +26,9 @@ export const createConfig = async (state: ConfiguratorStatePublishing) => { // 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, - dataSource: state.dataSource, - layout: state.layout, - chartConfigs: state.chartConfigs, - activeChartKey: state.activeChartKey, + ...state, }, + publishedState: publishedState, }, } ); @@ -41,7 +41,7 @@ type UpdateConfigOptions = { }; export const updateConfig = async ( - state: ConfiguratorStatePublishing, + state: ConfiguratorState, options: UpdateConfigOptions ) => { const { key, userId, published_state } = options; @@ -56,11 +56,7 @@ export const updateConfig = async ( userId, data: { key, - version: state.version, - dataSource: state.dataSource, - layout: state.layout, - chartConfigs: state.chartConfigs, - activeChartKey: state.activeChartKey, + ...state, }, published_state, }, From 867311cff0ebbe96c3dd79392119433129cd6e57 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Thu, 15 Feb 2024 09:48:25 +0100 Subject: [PATCH 18/59] fix: Add required chartId prop --- app/configurator/components/configurator.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/configurator/components/configurator.tsx b/app/configurator/components/configurator.tsx index c851bdb79..789ae1e1d 100644 --- a/app/configurator/components/configurator.tsx +++ b/app/configurator/components/configurator.tsx @@ -226,6 +226,7 @@ const LayoutingStep = () => { } const isSingleURLs = state.layout.type === "singleURLs"; + const chartId = getRouterChartId(asPath); return ( { justifyContent: "flex-end", }} > - + {!isSingleURLs && ( From af92a08be7da0170e9c225b235a686acfd4d141f Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Thu, 15 Feb 2024 14:52:08 +0100 Subject: [PATCH 19/59] feat: Add translations for chart features --- app/components/chart-selection-tabs.tsx | 1 - app/locales/de/messages.po | 46 ++++++++++++------------- app/locales/en/messages.po | 46 ++++++++++++------------- app/locales/fr/messages.po | 46 ++++++++++++------------- app/locales/it/messages.po | 46 ++++++++++++------------- app/login/components/profile-tables.tsx | 18 +++++----- 6 files changed, 102 insertions(+), 101 deletions(-) diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index f5d6ddebb..b52718cbc 100644 --- a/app/components/chart-selection-tabs.tsx +++ b/app/components/chart-selection-tabs.tsx @@ -321,7 +321,6 @@ export const LayoutChartButton = () => { const SaveAsDraftButton = ({ chartId }: { chartId: string | undefined }) => { const { data: config, invalidate: invalidateConfig } = useUserConfig(chartId); - console.log("config", config?.user_id); const session = useSession(); const [state] = useConfiguratorState(); diff --git a/app/locales/de/messages.po b/app/locales/de/messages.po index edb5f5825..9b3cf6e50 100644 --- a/app/locales/de/messages.po +++ b/app/locales/de/messages.po @@ -123,6 +123,10 @@ msgstr "Neue Visualisierung erstellen" msgid "button.publish" msgstr "Veröffentlichen" +#: app/components/chart-selection-tabs.tsx +msgid "button.save-draft" +msgstr "Entwurf speichern" + #: app/components/publish-actions.tsx msgid "button.share" msgstr "Teilen" @@ -191,10 +195,6 @@ msgstr "Fett" msgid "columnStyle.textStyle.regular" msgstr "Normal" -#: app/configurator/interactive-filters/editor-time-brush.tsx -#~ msgid "controls..interactiveFilters.time.defaultSettings" -#~ msgstr "Standardeinstellung" - #: app/configurator/components/field-i18n.ts msgid "controls.abbreviations" msgstr "Abkürzungen verwenden" @@ -253,10 +253,6 @@ msgstr "Vergleichsdiagrammtypen kombinieren mehrere Messwerte in einem Diagramm msgid "controls.chart.category.regular" msgstr "Normal" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "controls.chart.combo.y.axis-orientation" -#~ msgstr "Achsenausrichtung" - #: app/configurator/components/chart-options-selector.tsx msgid "controls.chart.combo.y.column-measure" msgstr "Linke Achse (Säule)" @@ -511,14 +507,6 @@ msgstr "-" msgid "controls.imputation.type.zeros" msgstr "Nullen" -#: app/configurator/interactive-filters/interactive-filters-config-options.tsx -#~ msgid "controls.interactiveFilters.time.noTimeDimension" -#~ msgstr "Keine Zeitdimension verfügbar!" - -#: app/configurator/interactive-filters/interactive-filters-config-options.tsx -#~ msgid "controls.interactiveFilters.time.toggleTimeFilter" -#~ msgstr "Zeitfilter anzeigen" - #: app/configurator/components/field-i18n.ts msgid "controls.language.english" msgstr "Englisch" @@ -1175,6 +1163,10 @@ msgstr "Negative Datenwerte können mit diesem Diagrammtyp nicht dargestellt wer msgid "hint.only.negative.data.title" msgstr "Negative Werte" +#: app/pages/v/[chartId].tsx +msgid "hint.publication.draft" +msgstr "Dieses Visualisierung befindet sich noch im Entwurf." + #: app/components/hint.tsx msgid "hint.publication.success" msgstr "Die Visualisierung ist jetzt veröffentlicht. Sie können sie teilen oder einbetten, indem Sie die URL kopieren oder das Menü oben verwenden." @@ -1187,9 +1179,13 @@ msgstr "Filter ausblenden" msgid "interactive.data.filters.show" msgstr "Filter anzeigen" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "left" -#~ msgstr "Links" +#: app/login/components/profile-tables.tsx +msgid "login.chart.actions.publish" +msgstr "Veröffentlichen" + +#: app/login/components/profile-tables.tsx +msgid "login.chart.actions.turn-into-draft" +msgstr "In Entwurf umwandeln" #: app/login/components/profile-tables.tsx msgid "login.chart.copy" @@ -1211,6 +1207,10 @@ msgstr "Bearbeiten" msgid "login.chart.share" msgstr "Teilen" +#: app/login/components/profile-tables.tsx +msgid "login.chart.view" +msgstr "" + #: app/login/components/profile-tables.tsx msgid "login.create-chart" msgstr "erstellen Sie einen" @@ -1250,6 +1250,10 @@ msgstr "Veröffentlicht" msgid "login.profile.my-visualizations.chart-type" msgstr "Typ" +#: app/login/components/profile-tables.tsx +msgid "login.profile.my-visualizations.chart-updated-date" +msgstr "Geändert" + #: app/login/components/profile-tables.tsx msgid "login.profile.my-visualizations.dataset-name" msgstr "Datensatz" @@ -1347,10 +1351,6 @@ msgstr "Hier ist eine Visualisierung, die ich mit visualize.admin.ch erstellt ha msgid "publication.share.mail.subject" msgstr "visualize.admin.ch" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "right" -#~ msgstr "Rechts" - #: app/browser/dataset-browse.tsx #: app/configurator/components/filters.tsx msgid "select.controls.filters.search" diff --git a/app/locales/en/messages.po b/app/locales/en/messages.po index 03cd36e8e..616e88b39 100644 --- a/app/locales/en/messages.po +++ b/app/locales/en/messages.po @@ -123,6 +123,10 @@ msgstr "Create New Visualization" msgid "button.publish" msgstr "Publish" +#: app/components/chart-selection-tabs.tsx +msgid "button.save-draft" +msgstr "Save draft" + #: app/components/publish-actions.tsx msgid "button.share" msgstr "Share" @@ -191,10 +195,6 @@ msgstr "Bold" msgid "columnStyle.textStyle.regular" msgstr "Regular" -#: app/configurator/interactive-filters/editor-time-brush.tsx -#~ msgid "controls..interactiveFilters.time.defaultSettings" -#~ msgstr "Default Settings" - #: app/configurator/components/field-i18n.ts msgid "controls.abbreviations" msgstr "Use abbreviations" @@ -253,10 +253,6 @@ msgstr "Comparison chart types combine several measures in a chart, helping to v msgid "controls.chart.category.regular" msgstr "Regular" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "controls.chart.combo.y.axis-orientation" -#~ msgstr "Axis orientation" - #: app/configurator/components/chart-options-selector.tsx msgid "controls.chart.combo.y.column-measure" msgstr "Left axis (column)" @@ -511,14 +507,6 @@ msgstr "-" msgid "controls.imputation.type.zeros" msgstr "Zeros" -#: app/configurator/interactive-filters/interactive-filters-config-options.tsx -#~ msgid "controls.interactiveFilters.time.noTimeDimension" -#~ msgstr "There is no time dimension!" - -#: app/configurator/interactive-filters/interactive-filters-config-options.tsx -#~ msgid "controls.interactiveFilters.time.toggleTimeFilter" -#~ msgstr "Show time filter" - #: app/configurator/components/field-i18n.ts msgid "controls.language.english" msgstr "English" @@ -1175,6 +1163,10 @@ msgstr "Negative data values cannot be displayed with this chart type." msgid "hint.only.negative.data.title" msgstr "Negative Values" +#: app/pages/v/[chartId].tsx +msgid "hint.publication.draft" +msgstr "This chart is still in draft." + #: app/components/hint.tsx msgid "hint.publication.success" msgstr "Your visualization is now published. Share it by copying the URL or use the Embed menu above." @@ -1187,9 +1179,13 @@ msgstr "Hide Filters" msgid "interactive.data.filters.show" msgstr "Show Filters" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "left" -#~ msgstr "Left" +#: app/login/components/profile-tables.tsx +msgid "login.chart.actions.publish" +msgstr "Publish" + +#: app/login/components/profile-tables.tsx +msgid "login.chart.actions.turn-into-draft" +msgstr "Turn into draft" #: app/login/components/profile-tables.tsx msgid "login.chart.copy" @@ -1211,6 +1207,10 @@ msgstr "Edit" msgid "login.chart.share" msgstr "Share" +#: app/login/components/profile-tables.tsx +msgid "login.chart.view" +msgstr "View" + #: app/login/components/profile-tables.tsx msgid "login.create-chart" msgstr "create one" @@ -1250,6 +1250,10 @@ msgstr "Published" msgid "login.profile.my-visualizations.chart-type" msgstr "Type" +#: app/login/components/profile-tables.tsx +msgid "login.profile.my-visualizations.chart-updated-date" +msgstr "Updated" + #: app/login/components/profile-tables.tsx msgid "login.profile.my-visualizations.dataset-name" msgstr "Dataset" @@ -1347,10 +1351,6 @@ msgstr "Here is a visualization I created using visualize.admin.ch" msgid "publication.share.mail.subject" msgstr "visualize.admin.ch" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "right" -#~ msgstr "Right" - #: app/browser/dataset-browse.tsx #: app/configurator/components/filters.tsx msgid "select.controls.filters.search" diff --git a/app/locales/fr/messages.po b/app/locales/fr/messages.po index b2a6e8785..f7cd60371 100644 --- a/app/locales/fr/messages.po +++ b/app/locales/fr/messages.po @@ -123,6 +123,10 @@ msgstr "Créer une nouvelle visualisation" msgid "button.publish" msgstr "Publier" +#: app/components/chart-selection-tabs.tsx +msgid "button.save-draft" +msgstr "Sauver le brouillon" + #: app/components/publish-actions.tsx msgid "button.share" msgstr "Partager" @@ -191,10 +195,6 @@ msgstr "Gras" msgid "columnStyle.textStyle.regular" msgstr "Normal" -#: app/configurator/interactive-filters/editor-time-brush.tsx -#~ msgid "controls..interactiveFilters.time.defaultSettings" -#~ msgstr "Paramètres d'origine" - #: app/configurator/components/field-i18n.ts msgid "controls.abbreviations" msgstr "Utiliser des abréviations" @@ -253,10 +253,6 @@ msgstr "Les types de graphiques comparatifs combinent plusieurs mesures dans un msgid "controls.chart.category.regular" msgstr "Normal" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "controls.chart.combo.y.axis-orientation" -#~ msgstr "Orientation de l'axe" - #: app/configurator/components/chart-options-selector.tsx msgid "controls.chart.combo.y.column-measure" msgstr "Axe gauche (colonne)" @@ -511,14 +507,6 @@ msgstr "-" msgid "controls.imputation.type.zeros" msgstr "Zéros" -#: app/configurator/interactive-filters/interactive-filters-config-options.tsx -#~ msgid "controls.interactiveFilters.time.noTimeDimension" -#~ msgstr "Il n'y a pas de dimension temporelle!" - -#: app/configurator/interactive-filters/interactive-filters-config-options.tsx -#~ msgid "controls.interactiveFilters.time.toggleTimeFilter" -#~ msgstr "Afficher le filtre temporel" - #: app/configurator/components/field-i18n.ts msgid "controls.language.english" msgstr "Anglais" @@ -1175,6 +1163,10 @@ msgstr "Ce type de graphique ne permet pas de représenter des données négativ msgid "hint.only.negative.data.title" msgstr "Valeurs négatives" +#: app/pages/v/[chartId].tsx +msgid "hint.publication.draft" +msgstr "Ce graphique est un brouillon." + #: app/components/hint.tsx msgid "hint.publication.success" msgstr "Votre visualisation est à présent publiée. Partagez-là en copiant l'URL ou en utilisant les options d'intégration." @@ -1187,9 +1179,13 @@ msgstr "Masquer les filtres" msgid "interactive.data.filters.show" msgstr "Afficher les filtres" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "left" -#~ msgstr "Gauche" +#: app/login/components/profile-tables.tsx +msgid "login.chart.actions.publish" +msgstr "Veröffentlichen" + +#: app/login/components/profile-tables.tsx +msgid "login.chart.actions.turn-into-draft" +msgstr "In Entwurf umwandeln" #: app/login/components/profile-tables.tsx msgid "login.chart.copy" @@ -1211,6 +1207,10 @@ msgstr "Editer" msgid "login.chart.share" msgstr "Partager" +#: app/login/components/profile-tables.tsx +msgid "login.chart.view" +msgstr "Voir" + #: app/login/components/profile-tables.tsx msgid "login.create-chart" msgstr "créez-en un" @@ -1250,6 +1250,10 @@ msgstr "Publié" msgid "login.profile.my-visualizations.chart-type" msgstr "Type" +#: app/login/components/profile-tables.tsx +msgid "login.profile.my-visualizations.chart-updated-date" +msgstr "Modifié" + #: app/login/components/profile-tables.tsx msgid "login.profile.my-visualizations.dataset-name" msgstr "Ensemble de données" @@ -1347,10 +1351,6 @@ msgstr "Voici une visualisation que j'ai créée sur visualize.admin.ch" msgid "publication.share.mail.subject" msgstr "visualize.admin.ch" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "right" -#~ msgstr "Droit" - #: app/browser/dataset-browse.tsx #: app/configurator/components/filters.tsx msgid "select.controls.filters.search" diff --git a/app/locales/it/messages.po b/app/locales/it/messages.po index 00577db09..3035a941d 100644 --- a/app/locales/it/messages.po +++ b/app/locales/it/messages.po @@ -123,6 +123,10 @@ msgstr "Crea una nuova visualizzazione" msgid "button.publish" msgstr "Pubblicare" +#: app/components/chart-selection-tabs.tsx +msgid "button.save-draft" +msgstr "Salva la bozza" + #: app/components/publish-actions.tsx msgid "button.share" msgstr "Condividi" @@ -191,10 +195,6 @@ msgstr "Grassetto" msgid "columnStyle.textStyle.regular" msgstr "Regular" -#: app/configurator/interactive-filters/editor-time-brush.tsx -#~ msgid "controls..interactiveFilters.time.defaultSettings" -#~ msgstr "Impostazioni predefinite" - #: app/configurator/components/field-i18n.ts msgid "controls.abbreviations" msgstr "Usa abbreviazioni" @@ -253,10 +253,6 @@ msgstr "I tipi di grafico di confronto combinano diverse misure in un grafico, a msgid "controls.chart.category.regular" msgstr "Normale" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "controls.chart.combo.y.axis-orientation" -#~ msgstr "Orientamento dell'asse" - #: app/configurator/components/chart-options-selector.tsx msgid "controls.chart.combo.y.column-measure" msgstr "Asse sinistro (colonna)" @@ -511,14 +507,6 @@ msgstr "-" msgid "controls.imputation.type.zeros" msgstr "Zeri" -#: app/configurator/interactive-filters/interactive-filters-config-options.tsx -#~ msgid "controls.interactiveFilters.time.noTimeDimension" -#~ msgstr "Nessuna dimensione temporale disponibile!" - -#: app/configurator/interactive-filters/interactive-filters-config-options.tsx -#~ msgid "controls.interactiveFilters.time.toggleTimeFilter" -#~ msgstr "Mostra i filtri temporali" - #: app/configurator/components/field-i18n.ts msgid "controls.language.english" msgstr "Inglese" @@ -1175,6 +1163,10 @@ msgstr "Dati con valori negativi non possono essere visualizzati con questo tipo msgid "hint.only.negative.data.title" msgstr "Valori negativi" +#: app/pages/v/[chartId].tsx +msgid "hint.publication.draft" +msgstr "Questa visualizzazione è ancora in bozza." + #: app/components/hint.tsx msgid "hint.publication.success" msgstr "La tua visualizzazione è ora pubblicata. Condividila copiando l'URL o utilizzando le opzioni di incorporamento." @@ -1187,9 +1179,13 @@ msgstr "Nascondi i filtri" msgid "interactive.data.filters.show" msgstr "Mostra i filtri" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "left" -#~ msgstr "Sinistra" +#: app/login/components/profile-tables.tsx +msgid "login.chart.actions.publish" +msgstr "Pubblicare" + +#: app/login/components/profile-tables.tsx +msgid "login.chart.actions.turn-into-draft" +msgstr "Trasforma in bozza" #: app/login/components/profile-tables.tsx msgid "login.chart.copy" @@ -1211,6 +1207,10 @@ msgstr "Modifica" msgid "login.chart.share" msgstr "Condividi" +#: app/login/components/profile-tables.tsx +msgid "login.chart.view" +msgstr "" + #: app/login/components/profile-tables.tsx msgid "login.create-chart" msgstr "creane uno" @@ -1250,6 +1250,10 @@ msgstr "Pubblicato" msgid "login.profile.my-visualizations.chart-type" msgstr "Tipo" +#: app/login/components/profile-tables.tsx +msgid "login.profile.my-visualizations.chart-updated-date" +msgstr "" + #: app/login/components/profile-tables.tsx msgid "login.profile.my-visualizations.dataset-name" msgstr "Set di dati" @@ -1347,10 +1351,6 @@ msgstr "Ecco una visualizzazione che ho creato usando visualize.admin.ch" msgid "publication.share.mail.subject" msgstr "visualize.admin.ch" -#: app/configurator/components/chart-options-selector.tsx -#~ msgid "right" -#~ msgstr "Diritto" - #: app/browser/dataset-browse.tsx #: app/configurator/components/filters.tsx msgid "select.controls.filters.search" diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index af6fe9c05..cd1aa2945 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -23,7 +23,6 @@ import { import { PUBLISHED_STATE } from "@prisma/client"; import NextLink from "next/link"; import React from "react"; -import { ObjectInspector } from "react-inspector"; import useDisclosure from "@/components/use-disclosure"; import { ParsedConfig } from "@/db/config"; @@ -214,13 +213,16 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { }, { type: "button", - label: t({ - id: "login.chart.draft", - message: - config.published_state === PUBLISHED_STATE.DRAFT - ? `Publish` - : "Turn into draft", - }), + label: + config.published_state === PUBLISHED_STATE.DRAFT + ? t({ + id: "login.chart.actions.turn-into-draft", + message: "Turn into draft", + }) + : t({ + id: "login.chart.actions.publish", + message: `Publish`, + }), iconName: updatePublishedStateMut.status === "fetching" ? "loading" From 22ad15a380788c8c884d48f29971a44dbea79c9f Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Thu, 15 Feb 2024 15:07:26 +0100 Subject: [PATCH 20/59] refactor: Add docs and explicit the version --- app/utils/use-fetch-data.ts | 38 ++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/app/utils/use-fetch-data.ts b/app/utils/use-fetch-data.ts index c43b377ed..0800d3c07 100644 --- a/app/utils/use-fetch-data.ts +++ b/app/utils/use-fetch-data.ts @@ -17,18 +17,24 @@ type QueryCacheValue = { status: Status; }; +/** + * Stores what has been queried through useFetchData. Is listened to by useCacheKey. + */ class QueryCache { cache: Map>; listeners: [string, () => void][]; + version: number; constructor() { this.cache = new Map(); this.listeners = []; + this.version = 0; } set(queryKey: QueryKey, value: QueryCacheValue) { this.cache.set(stringifyVariables(queryKey), value); this.fire(queryKey); + this.version++; } get(queryKey: QueryKey) { @@ -58,6 +64,24 @@ class QueryCache { const cache = new QueryCache(); +const useCacheKey = (cache: QueryCache, queryKey: QueryKey) => { + const [version, setVersion] = useState(cache.version); + useEffect(() => { + return cache.listen(queryKey, () => { + setVersion(() => cache.version); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return version; +}; + +/** + * Access remote data, very similar to useFetch from react-query. A global cache + * is used to store the data. If data is not fetched, it will be fetched automatically. + * Two useFetchData on the same queryKey will result in only 1 queryFn called. Both useFetchData + * will share the same cache and data. + */ export const useFetchData = ( queryKey: any[], queryFn: () => Promise, @@ -65,7 +89,6 @@ export const useFetchData = ( ) => { const { enable = true, defaultData } = options; - const [, setState] = useState(0); const cached = cache.get(queryKey) as QueryCacheValue; const { data, error, status } = cached ?? {}; @@ -88,12 +111,7 @@ export const useFetchData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [queryKey]); - useEffect(() => { - return cache.listen(queryKey, () => { - setState((n) => n + 1); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + useCacheKey(cache, queryKey); useEffect(() => { if (!enable) { @@ -114,6 +132,9 @@ export const useFetchData = ( return { data: data ?? defaultData, error, status, invalidate }; }; +/** + * Use this to populate (hydrate) the client store with the server side data + */ export const useHydrate = (queryKey: QueryKey, data: T) => { const hasHydrated = useRef(false); if (!hasHydrated.current) { @@ -122,6 +143,9 @@ export const useHydrate = (queryKey: QueryKey, data: T) => { } }; +/** + * Tracks a server mutation with loading/error states + */ export const useMutate = ( queryFn: (...args: TArgs) => Promise ) => { From e6f547947999a5feeb0fee7fa8b9311a02833c70 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Thu, 15 Feb 2024 15:29:37 +0100 Subject: [PATCH 21/59] fix: Inverted message --- app/login/components/profile-tables.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index cd1aa2945..f0fd1b471 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -216,12 +216,12 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { label: config.published_state === PUBLISHED_STATE.DRAFT ? t({ - id: "login.chart.actions.turn-into-draft", - message: "Turn into draft", - }) - : t({ id: "login.chart.actions.publish", message: `Publish`, + }) + : t({ + id: "login.chart.actions.turn-into-draft", + message: "Turn into draft", }), iconName: updatePublishedStateMut.status === "fetching" From 98d73f7005901deea6ce2dab01f97c5da3eb19e8 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Thu, 15 Feb 2024 16:15:56 +0100 Subject: [PATCH 22/59] feat: Add published state as non optional --- app/config-types.ts | 7 +++++-- app/configurator/configurator-state.tsx | 3 +++ app/utils/chart-config/versioning.ts | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/config-types.ts b/app/config-types.ts index 895065eea..c156664d1 100644 --- a/app/config-types.ts +++ b/app/config-types.ts @@ -1134,6 +1134,7 @@ const Config = t.type( layout: Layout, chartConfigs: t.array(ChartConfig), activeChartKey: t.string, + published_state: t.string, }, "Config" ); @@ -1149,21 +1150,23 @@ export const decodeConfig = (config: unknown) => { }; const ConfiguratorStateInitial = t.type({ - version: t.string, state: t.literal("INITIAL"), + version: t.string, dataSource: DataSource, + published_state: t.string, }); export type ConfiguratorStateInitial = t.TypeOf< typeof ConfiguratorStateInitial >; const ConfiguratorStateSelectingDataSet = t.type({ - version: t.string, state: t.literal("SELECTING_DATASET"), + version: t.string, dataSource: DataSource, chartConfigs: t.undefined, layout: t.undefined, activeChartKey: t.undefined, + published_state: t.string, }); export type ConfiguratorStateSelectingDataSet = t.TypeOf< typeof ConfiguratorStateSelectingDataSet diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index e3b5a257f..51160f7aa 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -334,6 +334,7 @@ const INITIAL_STATE: ConfiguratorState = { version: CONFIGURATOR_STATE_VERSION, state: "INITIAL", dataSource: DEFAULT_DATA_SOURCE, + published_state: PUBLISHED_STATE.DRAFT, }; const EMPTY_STATE: ConfiguratorStateSelectingDataSet = { @@ -344,6 +345,7 @@ const EMPTY_STATE: ConfiguratorStateSelectingDataSet = { chartConfigs: undefined, layout: undefined, activeChartKey: undefined, + published_state: PUBLISHED_STATE.DRAFT, }; const getCachedComponents = ( @@ -651,6 +653,7 @@ const transitionStepNext = ( version: CONFIGURATOR_STATE_VERSION, state: "CONFIGURING_CHART", dataSource: draft.dataSource, + published_state: draft.published_state, layout: { type: "tab", meta: { diff --git a/app/utils/chart-config/versioning.ts b/app/utils/chart-config/versioning.ts index ffd239c61..ce4ba06b5 100644 --- a/app/utils/chart-config/versioning.ts +++ b/app/utils/chart-config/versioning.ts @@ -1,3 +1,4 @@ +import { PUBLISHED_STATE } from "@prisma/client"; import produce from "immer"; import { mapValueIrisToColor } from "@/configurator/components/ui-helpers"; @@ -992,6 +993,25 @@ const configuratorStateMigrations: Migration[] = [ }); }, }, + { + description: "ALL + published_state", + from: "3.1.0", + to: "3.2.0", + up: (config) => { + const newConfig = { ...config, version: "3.2.0" }; + + return produce(newConfig, (draft: any) => { + draft.published_state = PUBLISHED_STATE.PUBLISHED; + }); + }, + down: (config) => { + const newConfig = { ...config, version: "3.1.0" }; + + return produce(newConfig, (draft: any) => { + delete draft.published_state; + }); + }, + }, ]; export const migrateConfiguratorState = makeMigrate( From dded7a7a873b5609ae3cae57b271861a81b78c43 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 08:56:38 +0100 Subject: [PATCH 23/59] fix: button in button --- app/components/chart-selection-tabs.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index b52718cbc..c3f2f2009 100644 --- a/app/components/chart-selection-tabs.tsx +++ b/app/components/chart-selection-tabs.tsx @@ -543,6 +543,7 @@ const TabsInner = (props: TabsInnerProps) => { {addable && ( `-${theme.spacing(2)}`, p: 0, From 79786d31e9a804726a58d6a1f9ff654c3b934b66 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 10:00:33 +0100 Subject: [PATCH 24/59] feat: Make create and update config simpler, they take a config and will take care of removing state from it --- app/components/chart-selection-tabs.tsx | 14 +++-- app/configurator/configurator-state.tsx | 18 +++--- app/db/config.ts | 6 +- app/login/components/profile-tables.tsx | 22 ++++--- app/pages/api/config-update.ts | 6 +- app/utils/chart-config/api.ts | 79 +++++++++++++++---------- 6 files changed, 82 insertions(+), 63 deletions(-) diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index c3f2f2009..d85c3c02d 100644 --- a/app/components/chart-selection-tabs.tsx +++ b/app/components/chart-selection-tabs.tsx @@ -335,8 +335,9 @@ const SaveAsDraftButton = ({ chartId }: { chartId: string | undefined }) => { const handleClick = useEventCallback(async () => { try { if (config?.user_id && loggedInId) { - const updated = await updatePublishedStateMut.mutate(state, { - userId: loggedInId, + const updated = await updatePublishedStateMut.mutate({ + data: state, + user_id: loggedInId, published_state: PUBLISHED_STATE.DRAFT, key: config.key, }); @@ -353,10 +354,11 @@ const SaveAsDraftButton = ({ chartId }: { chartId: string | undefined }) => { throw new Error("Could not update draft"); } } else if (state) { - const saved = await createConfigMut.mutate( - state, - PUBLISHED_STATE.DRAFT - ); + const saved = await createConfigMut.mutate({ + data: state, + user_id: loggedInId, + published_state: PUBLISHED_STATE.DRAFT, + }); if (saved) { enqueueSnackbar({ message: "Draft saved !", diff --git a/app/configurator/configurator-state.tsx b/app/configurator/configurator-state.tsx index 51160f7aa..959461c48 100644 --- a/app/configurator/configurator-state.tsx +++ b/app/configurator/configurator-state.tsx @@ -1879,10 +1879,10 @@ async function publishState( chartKey ); - const result = await createConfig( - preparedConfig, - PUBLISHED_STATE.PUBLISHED - ); + const result = await createConfig({ + data: preparedConfig, + published_state: PUBLISHED_STATE.PUBLISHED, + }); onSaveConfig(result, i, reversedChartKeys.length); return result; @@ -1901,12 +1901,16 @@ async function publishState( const preparedConfig = preparePublishingState(state, state.chartConfigs); const result = await (dbConfig && user - ? updateConfig(preparedConfig, { + ? updateConfig({ + data: preparedConfig, key: dbConfig.key, - userId: user.id, + user_id: user.id, published_state: PUBLISHED_STATE.PUBLISHED, }) - : createConfig(preparedConfig, PUBLISHED_STATE.PUBLISHED)); + : createConfig({ + data: preparedConfig, + published_state: PUBLISHED_STATE.PUBLISHED, + })); onSaveConfig(result, 0, 1); return result; diff --git a/app/db/config.ts b/app/db/config.ts index 75b468476..eac5e57fb 100644 --- a/app/db/config.ts +++ b/app/db/config.ts @@ -47,12 +47,12 @@ export const createConfig = async ({ export const updateConfig = async ({ key, data, - userId, + user_id, published_state, }: { key: string; data: Prisma.ConfigUpdateInput["data"]; - userId: User["id"]; + user_id: User["id"]; published_state: Prisma.ConfigUpdateInput["published_state"]; }): Promise<{ key: string }> => { return await prisma.config.update({ @@ -62,7 +62,7 @@ export const updateConfig = async ({ data: { key, data, - user_id: userId, + user_id, updated_at: new Date(), published_state: published_state, }, diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index f0fd1b471..8c4e326d5 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -229,20 +229,18 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { : "linkExternal", onClick: async () => { - await updatePublishedStateMut.mutate( - { + await updatePublishedStateMut.mutate({ + key: config.key, + user_id: userId, + data: { ...config.data, state: "PUBLISHING", }, - { - key: config.key, - userId, - published_state: - config.published_state === PUBLISHED_STATE.DRAFT - ? PUBLISHED_STATE.PUBLISHED - : PUBLISHED_STATE.DRAFT, - } - ); + published_state: + config.published_state === PUBLISHED_STATE.DRAFT + ? PUBLISHED_STATE.PUBLISHED + : PUBLISHED_STATE.DRAFT, + }); invalidateUserConfigs(); }, onSuccess: () => { @@ -264,7 +262,7 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { "Keep in mind that removing this visualization will affect all the places where it might be already embedded!", }), onClick: () => { - removeMut.mutate({ key: config.key, userId }); + removeMut.mutate({ key: config.key }); }, onSuccess: () => { invalidateUserConfigs(); diff --git a/app/pages/api/config-update.ts b/app/pages/api/config-update.ts index 72cd432f1..9caac853a 100644 --- a/app/pages/api/config-update.ts +++ b/app/pages/api/config-update.ts @@ -10,16 +10,16 @@ const route = api({ POST: async ({ req, res }) => { const session = await getServerSession(req, res, nextAuthOptions); const serverUserId = session?.user?.id; - const { key, userId, data, published_state } = req.body; + const { key, user_id, data, published_state } = req.body; - if (serverUserId !== userId) { + if (serverUserId !== user_id) { throw new Error("Unauthorized!"); } return await updateConfig({ key, data, - userId, + user_id, published_state, }); }, diff --git a/app/utils/chart-config/api.ts b/app/utils/chart-config/api.ts index f25986756..f2931e403 100644 --- a/app/utils/chart-config/api.ts +++ b/app/utils/chart-config/api.ts @@ -1,5 +1,6 @@ -import { PUBLISHED_STATE } from "@prisma/client"; +import { Config, PUBLISHED_STATE } from "@prisma/client"; import isUndefined from "lodash/isUndefined"; +import omit from "lodash/omit"; import omitBy from "lodash/omitBy"; import { InferAPIResponse } from "nextkit"; @@ -13,61 +14,76 @@ 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: ConfiguratorState, - publishedState: PUBLISHED_STATE -) => { +type CreateConfigOptions = { + key?: string; + user_id?: number; + published_state?: PUBLISHED_STATE; + data: ConfiguratorState; +}; + +type UpdateConfigOptions = { + key: string; + user_id: number; + published_state?: PUBLISHED_STATE; + data: ConfiguratorState; +}; + +const prepareForServer = (configState: Partial) => { + return omitBy( + { + ...configState, + data: + "data" in configState + ? omit(configState["data"] as {}, ["state"]) + : undefined, + }, + isUndefined + ); +}; + +export const createConfig = async (options: CreateConfigOptions) => { return apiFetch>( "/api/config-create", { method: "POST", - data: { + data: prepareForServer({ 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(), - ...state, + ...options.data, }, - publishedState: publishedState, - }, + user_id: options.user_id, + published_state: options.published_state, + }), } ); }; -type UpdateConfigOptions = { - key: string; - userId: number; - published_state?: PUBLISHED_STATE; -}; +export const updateConfig = async (options: UpdateConfigOptions) => { + const { key, user_id, published_state } = options; -export const updateConfig = async ( - state: ConfiguratorState, - options: UpdateConfigOptions -) => { - const { key, userId, published_state } = options; + console.log("hello", key, user_id, published_state); return apiFetch>( "/api/config-update", { method: "POST", - data: omitBy( - { + data: prepareForServer({ + key, + user_id, + data: { key, - userId, - data: { - key, - ...state, - }, - published_state, + ...options.data, }, - isUndefined - ), + published_state, + }), } ); }; -export const removeConfig = async (options: UpdateConfigOptions) => { - const { key, userId } = options; +export const removeConfig = async (options: { key: string }) => { + const { key } = options; return apiFetch>( "/api/config-remove", @@ -75,7 +91,6 @@ export const removeConfig = async (options: UpdateConfigOptions) => { method: "POST", data: { key, - userId, }, } ); From f32fb119b42809445cd094943ffda6e078af72b5 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 10:26:52 +0100 Subject: [PATCH 25/59] Show SaveAsDraft button inside layout options --- app/components/chart-selection-tabs.tsx | 37 +++++++++++--------- app/configurator/components/configurator.tsx | 9 ++++- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index d85c3c02d..a5eb0de9f 100644 --- a/app/components/chart-selection-tabs.tsx +++ b/app/components/chart-selection-tabs.tsx @@ -16,6 +16,7 @@ import { useSession } from "next-auth/react"; import { useRouter } from "next/router"; import React from "react"; import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; +import { useDebounce } from "use-debounce"; import { extractChartConfigComponentIris } from "@/charts/shared/chart-helpers"; import Flex from "@/components/flex"; @@ -319,13 +320,18 @@ export const LayoutChartButton = () => { ); }; -const SaveAsDraftButton = ({ chartId }: { chartId: string | undefined }) => { +export const SaveAsDraftButton = ({ + chartId, +}: { + chartId: string | undefined; +}) => { const { data: config, invalidate: invalidateConfig } = useUserConfig(chartId); const session = useSession(); const [state] = useConfiguratorState(); - const [snack, enqueueSnackbar] = useLocalSnack(); + const [snack, enqueueSnackbar, dismissSnack] = useLocalSnack(); + const [debouncedSnack] = useDebounce(snack, 500); const { asPath, replace } = useRouter(); const createConfigMut = useMutate(createConfig); @@ -383,11 +389,12 @@ const SaveAsDraftButton = ({ chartId }: { chartId: string | undefined }) => { return ( dismissSnack()} > + ) : null} )} From 07aded36483e6b1c4a31cf0ceec14f5f2a57072d Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 14:56:50 +0100 Subject: [PATCH 37/59] refactor: Extract Confirmation Dialog from actions --- app/login/components/profile-tables.tsx | 125 ++++++++++++++---------- 1 file changed, 75 insertions(+), 50 deletions(-) diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index 0bff1fbe7..f8259daa7 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -481,11 +481,9 @@ const ActionButton = (props: ActionButtonProps) => { confirmationTitle, confirmationText, onClick, - onDialogClose, onSuccess, } = props; const { isOpen, open, close } = useDisclosure(); - const [loading, setLoading] = React.useState(false); return ( <> @@ -513,57 +511,84 @@ const ActionButton = (props: ActionButtonProps) => { {label} {requireConfirmation && ( - e.stopPropagation()} onClose={close} - maxWidth="xs" - > - - - {confirmationTitle ?? - t({ - id: "login.profile.chart.confirmation.default", - message: "Are you sure you want to perform this action?", - })} - - - {confirmationText && ( - - {confirmationText} - - )} - .MuiButton-root": { - justifyContent: "center", - pointerEvents: loading ? "none" : "auto", - }, - }} - > - - - - + title={confirmationTitle} + text={confirmationText} + onClick={onClick} + onSuccess={onSuccess} + /> )} ); }; + +const ConfirmationDialog = ({ + title, + text, + onClick, + onSuccess, + onConfirm, + ...props +}: DialogProps & { + title?: string; + text?: string; + onSuccess?: () => Promise | void; + onConfirm?: () => Promise | void; + onClick: () => Promise | void; +}) => { + const [loading, setLoading] = React.useState(false); + + return ( + e.stopPropagation()} + onClose={close} + maxWidth="xs" + {...props} + > + + + {title ?? + t({ + id: "login.profile.chart.confirmation.default", + message: "Are you sure you want to perform this action?", + })} + + + {text && ( + + {text} + + )} + .MuiButton-root": { + justifyContent: "center", + pointerEvents: loading ? "none" : "auto", + }, + }} + > + + + + + ); +}; From ac7621a16afdbc24cc254e8791a707729acee279 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 15:22:02 +0100 Subject: [PATCH 38/59] feat: Refactor actions to be able to show the primary one as button --- app/login/components/profile-tables.tsx | 194 ++++++++++++------------ 1 file changed, 95 insertions(+), 99 deletions(-) diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index f8259daa7..7373f2006 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -7,6 +7,7 @@ import { DialogActions, DialogContent, DialogContentText, + DialogProps, DialogTitle, IconButton, Link, @@ -393,8 +394,15 @@ const Actions = (props: ActionsProps) => { const buttonRef = React.useRef(null); const { isOpen, open, close } = useDisclosure(); + const [primaryAction, ...rest] = actions; + return ( - <> + + @@ -406,118 +414,106 @@ const Actions = (props: ActionsProps) => { transformOrigin={{ horizontal: "center", vertical: "top" }} sx={{}} > - {actions.map((props, i) => ( + {rest.map((props, i) => ( ))} - - ); -}; - -type ActionProps = ActionLinkProps | ActionButtonProps; - -const Action = (props: ActionProps) => { - switch (props.type) { - case "link": - return ; - case "button": - return ; - default: - const _exhaustiveCheck: never = props; - return _exhaustiveCheck; - } -}; - -type ActionLinkProps = { - type: "link"; - href: string; - label: string; - iconName: IconName; -}; - -const ActionLink = (props: ActionLinkProps) => { - const { href, label, iconName } = props; - - return ( - - - - {label} - - + ); }; -type ActionButtonProps = { - type: "button"; - label: string; - iconName: IconName; - requireConfirmation?: boolean; - confirmationTitle?: string; - confirmationText?: string; - onClick: () => Promise | void; - onDialogClose?: () => void; - onSuccess?: () => void; -}; - -const ActionButton = (props: ActionButtonProps) => { - const { - label, - iconName, - requireConfirmation, - confirmationTitle, - confirmationText, - onClick, - onSuccess, - } = props; - const { isOpen, open, close } = useDisclosure(); - +type ActionProps = + | { + type: "link"; + href: string; + label: string; + iconName: IconName; + } + | { + type: "button"; + label: string; + iconName: IconName; + onClick: () => Promise | void; + requireConfirmation?: false | undefined; + } + | { + type: "button"; + label: string; + iconName: IconName; + onClick: () => Promise | void; + requireConfirmation: true; + confirmationTitle?: string; + confirmationText?: string; + onDialogClose?: () => void; + onSuccess?: () => void; + }; +const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + color: theme.palette.primary.main, +})) as typeof MenuItem; + +const Action = (props: ActionProps & { as: "menuitem" | "button" }) => { + const { label, iconName } = props; + const { isOpen: isConfirmationOpen } = useDisclosure(); + + const Wrapper = ({ icon, label }: { icon: IconName; label: string }) => { + const forwardedProps = + props.type === "button" + ? { + onClick: props.onClick, + } + : { + href: props.href, + }; + if (props.as === "button") { + return ( + + ); + } else { + return ( + + + {label} + + ); + } + }; return ( <> - { - // To prevent the click away listener from closing the dialog. - e.stopPropagation(); - - if (requireConfirmation) { - open(); - } else { - onClick(); - } - }} - sx={{ - display: "flex", - alignItems: "center", - gap: 1, - color: "primary.main", - cursor: "pointer", - }} - > - - {label} - - {requireConfirmation && ( + {props.type === "link" ? ( + + + + ) : props.type === "button" ? ( + + ) : null} + {props.type === "button" && props.requireConfirmation && ( )} From 31897cabcbf7952736c316c275f0329699e31709 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 15:22:06 +0100 Subject: [PATCH 39/59] fix: Typo --- app/pages/v/[chartId].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/v/[chartId].tsx b/app/pages/v/[chartId].tsx index 3d3915942..2b06f61ee 100644 --- a/app/pages/v/[chartId].tsx +++ b/app/pages/v/[chartId].tsx @@ -112,7 +112,7 @@ const VisualizationPage = (props: Serialized) => { const { dataSource, setDataSource } = useDataSourceStore(); useEffect( - function removePulishSuccessFromURL() { + function removePublishSuccessFromURL() { // Remove publishSuccess from URL so that when reloading of sharing the link // to someone, there is no publishSuccess mention if (query.publishSuccess) { From 2097c22b6d926a59431a5911b07aa7930842b516 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 15:33:12 +0100 Subject: [PATCH 40/59] refactor: Split component and make title of visualisation table dynamic --- app/login/components/profile-content-tabs.tsx | 8 + app/login/components/profile-tables.tsx | 159 +++++++++--------- 2 files changed, 85 insertions(+), 82 deletions(-) diff --git a/app/login/components/profile-content-tabs.tsx b/app/login/components/profile-content-tabs.tsx index f532e7721..f81fb36ff 100644 --- a/app/login/components/profile-content-tabs.tsx +++ b/app/login/components/profile-content-tabs.tsx @@ -80,6 +80,10 @@ export const ProfileContentTabs = (props: ProfileContentTabsProps) => { sx={{ display: "flex", flexDirection: "column", gap: 6 }} > { > diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index 7373f2006..f14e6f89b 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -41,40 +41,27 @@ import { useMutate } from "@/utils/use-fetch-data"; const PREVIEW_LIMIT = 3; -type ProfileTableProps = React.PropsWithChildren<{ +const SectionContent = ({ + children, + title, +}: { + children: React.ReactNode; title: string; - preview?: boolean; - onShowAll?: () => void; -}>; - -const ProfileTable = (props: ProfileTableProps) => { - const { title, preview, onShowAll, children } = props; +}) => { const rootClasses = useRootStyles(); - return ( {title} - {children}
- {preview && ( - - )} + + {children}
); }; type ProfileVisualizationsTableProps = { + title: string; userId: number; userConfigs: ParsedConfig[]; preview?: boolean; @@ -84,69 +71,77 @@ type ProfileVisualizationsTableProps = { export const ProfileVisualizationsTable = ( props: ProfileVisualizationsTableProps ) => { - const { userId, userConfigs, preview, onShowAll } = props; + const { title, userId, userConfigs, preview, onShowAll } = props; return ( - PREVIEW_LIMIT} - onShowAll={onShowAll} - > + {userConfigs.length > 0 ? ( <> - .MuiTableCell-root": { - borderBottomColor: "divider", - color: "secondary.main", - }, - }} - > - - - Type - - - - - Name - - - - - Dataset - - - - - Published - - - - - Updated - - - - - Actions - - - - - {userConfigs - .slice(0, preview ? PREVIEW_LIMIT : undefined) - .map((config) => ( - - ))} - + + .MuiTableCell-root": { + borderBottomColor: "divider", + color: "secondary.main", + }, + }} + > + + + Type + + + + + Name + + + + + Dataset + + + + + Published + + + + + Updated + + + + + Actions + + + + + {userConfigs + .slice(0, preview ? PREVIEW_LIMIT : undefined) + .map((config) => ( + + ))} + +
+ {preview && ( + + )} ) : ( @@ -157,7 +152,7 @@ export const ProfileVisualizationsTable = ( . )} -
+ ); }; From a0fd5e0379aaffd331041ea599d3d6a33b0c9d1a Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 15:40:56 +0100 Subject: [PATCH 41/59] feat: Show drafts and published configs separately --- app/login/components/profile-content-tabs.tsx | 54 +++++++++++++++++-- app/login/components/profile-tables.tsx | 22 ++------ 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/app/login/components/profile-content-tabs.tsx b/app/login/components/profile-content-tabs.tsx index f81fb36ff..775444f05 100644 --- a/app/login/components/profile-content-tabs.tsx +++ b/app/login/components/profile-content-tabs.tsx @@ -3,7 +3,8 @@ import { TabContext, TabList, TabPanel } from "@mui/lab"; import { Box, Tab, Theme } from "@mui/material"; import { makeStyles } from "@mui/styles"; import clsx from "clsx"; -import React from "react"; +import groupBy from "lodash/groupBy"; +import React, { useMemo } from "react"; import { useUserConfigs } from "@/domain/user-configs"; import { ProfileVisualizationsTable } from "@/login/components/profile-tables"; @@ -51,6 +52,14 @@ export const ProfileContentTabs = (props: ProfileContentTabsProps) => { const rootClasses = useRootStyles(); const classes = useStyles(); + const { DRAFT: draftConfigs = [], PUBLISHED: publishedConfigs = [] } = + useMemo(() => { + return groupBy( + userConfigs, + (x: { published_state: any }) => x.published_state + ); + }, [userConfigs]); + if (!userConfigs) { return null; } @@ -67,6 +76,10 @@ export const ProfileContentTabs = (props: ProfileContentTabsProps) => { id: "login.profile.my-visualizations", message: "My visualizations", }), + t({ + id: "login.profile.my-drafts", + message: "My drafts", + }), ].map((d) => ( ))} @@ -85,7 +98,7 @@ export const ProfileContentTabs = (props: ProfileContentTabsProps) => { message: "My visualizations", })} userId={userId} - userConfigs={userConfigs} + userConfigs={publishedConfigs} preview onShowAll={() => setValue( @@ -96,6 +109,23 @@ export const ProfileContentTabs = (props: ProfileContentTabsProps) => { ) } /> + + setValue( + t({ + id: "login.profile.my-drafts", + message: "My drafts", + }) + ) + } + />
{ message: "My visualizations", })} userId={userId} - userConfigs={userConfigs} + userConfigs={publishedConfigs} + /> + + + + + diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index f14e6f89b..e68f6bd5a 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -101,17 +101,12 @@ export const ProfileVisualizationsTable = ( Dataset - - - Published - - - Updated + Last edit - + Actions @@ -340,19 +335,12 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { )} - - - {config.published_state === PUBLISHED_STATE.DRAFT - ? "Draft" - : config.created_at.toLocaleDateString("de")} - - - - + + {config.updated_at.toLocaleDateString("de")} - + From 394ea7950da0cbb692d262f466bc51184f20ddac Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 15:53:54 +0100 Subject: [PATCH 42/59] feat: Show actions differently for drafts & published --- app/login/components/profile-tables.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index e68f6bd5a..3401b4d9f 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -24,6 +24,7 @@ import { Typography, } from "@mui/material"; import { PUBLISHED_STATE } from "@prisma/client"; +import { sortBy } from "lodash"; import NextLink from "next/link"; import React from "react"; @@ -185,6 +186,8 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { href: `/v/${config.key}`, label: t({ id: "login.chart.view", message: "View" }), iconName: "eye", + priority: + config.published_state === PUBLISHED_STATE.PUBLISHED ? 0 : undefined, }, { type: "link", @@ -197,6 +200,8 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { href: `/create/new?edit=${config.key}`, label: t({ id: "login.chart.edit", message: "Edit" }), iconName: "edit", + priority: + config.published_state === PUBLISHED_STATE.DRAFT ? 0 : undefined, }, { type: "link", @@ -263,7 +268,7 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { }, ]; - return actions; + return sortBy(actions, (x) => x.priority); }, [ config.data, config.key, @@ -340,7 +345,7 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { {config.updated_at.toLocaleDateString("de")} - + @@ -410,31 +415,30 @@ const Actions = (props: ActionsProps) => { ); }; -type ActionProps = +type ActionProps = { + label: string; + iconName: IconName; + priority?: number; +} & ( | { type: "link"; href: string; - label: string; - iconName: IconName; } | { type: "button"; - label: string; - iconName: IconName; onClick: () => Promise | void; requireConfirmation?: false | undefined; } | { type: "button"; - label: string; - iconName: IconName; onClick: () => Promise | void; requireConfirmation: true; confirmationTitle?: string; confirmationText?: string; onDialogClose?: () => void; onSuccess?: () => void; - }; + } +); const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ display: "flex", alignItems: "center", @@ -459,7 +463,6 @@ const Action = (props: ActionProps & { as: "menuitem" | "button" }) => { return ( diff --git a/app/components/use-local-snack.tsx b/app/components/use-local-snack.tsx index ae1b72bd4..4a0a28fd7 100644 --- a/app/components/use-local-snack.tsx +++ b/app/components/use-local-snack.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; */ export const useLocalSnack = () => { type Snack = { - message: string; + message: string | React.ReactNode; variant: "success" | "error"; duration?: number; }; From 8dfdbf27e2fb76493659075447f827c83b099b20 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 16:18:37 +0100 Subject: [PATCH 44/59] feat: Make button a bit more lively --- app/components/chart-selection-tabs.tsx | 1 - app/themes/federal.tsx | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index e5f579a7b..56ab11d83 100644 --- a/app/components/chart-selection-tabs.tsx +++ b/app/components/chart-selection-tabs.tsx @@ -413,7 +413,6 @@ export const SaveDraftButton = ({ onClose={() => dismissSnack()} > ) : null} From 18a823fa8f410e97a6abdecf5d20d172fd7647aa Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 16:39:17 +0100 Subject: [PATCH 46/59] fix: Menu item takes the whole width --- app/login/components/profile-tables.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index 3401b4d9f..ab0fd823c 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -474,7 +474,7 @@ const Action = (props: ActionProps & { as: "menuitem" | "button" }) => { } else { return ( From 8f5e588a4734a74f4f0893403ff71a0e531d9c87 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 16:42:17 +0100 Subject: [PATCH 47/59] fix: Correct import --- app/login/components/profile-tables.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index ab0fd823c..501b0e69f 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -24,7 +24,7 @@ import { Typography, } from "@mui/material"; import { PUBLISHED_STATE } from "@prisma/client"; -import { sortBy } from "lodash"; +import sortBy from "lodash/sortBy"; import NextLink from "next/link"; import React from "react"; From e11e4b9c3682c1e525add6f8d23bf1d431fcfa72 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 17:02:30 +0100 Subject: [PATCH 48/59] fix: Missing published state --- app/configurator/configurator-state.spec.tsx | 10 +++++++--- app/docs/columns.docs.tsx | 1 + app/docs/fixtures.ts | 2 ++ app/docs/lines.docs.tsx | 1 + app/docs/scatterplot.docs.tsx | 1 + 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/configurator/configurator-state.spec.tsx b/app/configurator/configurator-state.spec.tsx index 9fde5d0b2..15cdfb445 100644 --- a/app/configurator/configurator-state.spec.tsx +++ b/app/configurator/configurator-state.spec.tsx @@ -10,13 +10,13 @@ import { ConfiguratorStateConfiguringChart, DataSource, Filters, - MapConfig, getChartConfig, + MapConfig, } from "@/config-types"; import { - ConfiguratorStateAction, applyNonTableDimensionToFilters, applyTableDimensionToFilters, + ConfiguratorStateAction, deriveFiltersFromFields, getFiltersByMappingStatus, getLocalStorageKey, @@ -153,7 +153,11 @@ describe("initChartFromLocalStorage", () => { it("should initialize from localStorage if valid", async () => { localStorage.setItem( getLocalStorageKey("viz1234"), - JSON.stringify({ state: "CONFIGURING_CHART", ...fakeVizFixture }) + JSON.stringify({ + state: "CONFIGURING_CHART", + published_state: "PUBLISHED", + ...fakeVizFixture, + }) ); const state = await initChartStateFromLocalStorage("viz1234"); expect(state).not.toBeUndefined(); diff --git a/app/docs/columns.docs.tsx b/app/docs/columns.docs.tsx index 40ac9b331..2ac12d79a 100644 --- a/app/docs/columns.docs.tsx +++ b/app/docs/columns.docs.tsx @@ -41,6 +41,7 @@ ${( }, chartConfigs: [chartConfig], activeChartKey: "scatterplot", + published_state: "PUBLISHED", }} > diff --git a/app/docs/fixtures.ts b/app/docs/fixtures.ts index 6883d7240..04643f2c5 100644 --- a/app/docs/fixtures.ts +++ b/app/docs/fixtures.ts @@ -10,6 +10,7 @@ export const states: ConfiguratorState[] = [ state: "SELECTING_DATASET", version: CONFIGURATOR_STATE_VERSION, dataSource: DEFAULT_DATA_SOURCE, + published_state: "PUBLISHED", chartConfigs: undefined, layout: undefined, activeChartKey: undefined, @@ -18,6 +19,7 @@ export const states: ConfiguratorState[] = [ state: "CONFIGURING_CHART", version: CONFIGURATOR_STATE_VERSION, dataSource: DEFAULT_DATA_SOURCE, + published_state: "PUBLISHED", layout: { type: "tab", meta: { diff --git a/app/docs/lines.docs.tsx b/app/docs/lines.docs.tsx index b9a915137..df2490967 100644 --- a/app/docs/lines.docs.tsx +++ b/app/docs/lines.docs.tsx @@ -33,6 +33,7 @@ ${( version: CONFIGURATOR_STATE_VERSION, state: "PUBLISHED", dataSource: { type: "sparql", url: "" }, + published_state: "PUBLISHED", layout: { type: "tab", meta: { diff --git a/app/docs/scatterplot.docs.tsx b/app/docs/scatterplot.docs.tsx index a8d8c02d1..2d724f2ac 100644 --- a/app/docs/scatterplot.docs.tsx +++ b/app/docs/scatterplot.docs.tsx @@ -36,6 +36,7 @@ ${( version: CONFIGURATOR_STATE_VERSION, state: "PUBLISHED", dataSource: { type: "sparql", url: "" }, + published_state: "PUBLISHED", layout: { type: "tab", meta: { From d1ebad4f221d200b58c6d9bd5bda246c632a8ed4 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Fri, 16 Feb 2024 17:13:58 +0100 Subject: [PATCH 49/59] fix: Unused --- app/server/nextkit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/server/nextkit.ts b/app/server/nextkit.ts index 847d11bec..a483c5eac 100644 --- a/app/server/nextkit.ts +++ b/app/server/nextkit.ts @@ -1,7 +1,7 @@ import createAPI from "nextkit"; export const api = createAPI({ - async onError(req, res, error) { + async onError(_req, _res, error) { return { status: 500, message: `Something went wrong: ${ From 6b5846c98745a15343ddb771da824bca30ce5301 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Mon, 19 Feb 2024 14:44:03 +0100 Subject: [PATCH 50/59] refactor: Extract row actions and confirmation dialog from profile-tables --- app/components/arrow-menu.tsx | 22 +++ app/components/confirmation-dialog.tsx | 84 +++++++++ app/components/row-actions.tsx | 142 ++++++++++++++ app/login/components/profile-tables.tsx | 241 +----------------------- 4 files changed, 251 insertions(+), 238 deletions(-) create mode 100644 app/components/arrow-menu.tsx create mode 100644 app/components/confirmation-dialog.tsx create mode 100644 app/components/row-actions.tsx diff --git a/app/components/arrow-menu.tsx b/app/components/arrow-menu.tsx new file mode 100644 index 000000000..87639cbcc --- /dev/null +++ b/app/components/arrow-menu.tsx @@ -0,0 +1,22 @@ +import { Menu, paperClasses, styled } from "@mui/material"; + +export const ArrowMenu = styled(Menu)(({ theme }) => ({ + [`& .${paperClasses.root}`]: { + overflowY: "visible", + overflowX: "visible", + "&:before": { + content: '" "', + display: "block", + background: theme.palette.background.paper, + width: 10, + height: 10, + transform: "rotate(45deg)", + position: "absolute", + margin: "auto", + top: -5, + + left: 0, + right: 0, + }, + }, +})); diff --git a/app/components/confirmation-dialog.tsx b/app/components/confirmation-dialog.tsx new file mode 100644 index 000000000..469f2a9e5 --- /dev/null +++ b/app/components/confirmation-dialog.tsx @@ -0,0 +1,84 @@ +import { t, Trans } from "@lingui/macro"; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogProps, + DialogTitle, + Typography, +} from "@mui/material"; +import React from "react"; + +const ConfirmationDialog = ({ + title, + text, + onClick, + onSuccess, + onConfirm, + ...props +}: DialogProps & { + title?: string; + text?: string; + onSuccess?: () => Promise | void; + onConfirm?: () => Promise | void; + onClick: () => Promise | void; +}) => { + const [loading, setLoading] = React.useState(false); + + return ( + e.stopPropagation()} + onClose={close} + maxWidth="xs" + {...props} + > + + + {title ?? + t({ + id: "login.profile.chart.confirmation.default", + message: "Are you sure you want to perform this action?", + })} + + + {text && ( + + {text} + + )} + .MuiButton-root": { + justifyContent: "center", + pointerEvents: loading ? "none" : "auto", + }, + }} + > + + + + + ); +}; + +export default ConfirmationDialog; diff --git a/app/components/row-actions.tsx b/app/components/row-actions.tsx new file mode 100644 index 000000000..ee7b9373c --- /dev/null +++ b/app/components/row-actions.tsx @@ -0,0 +1,142 @@ +import { Box, Button, IconButton, Link, MenuItem, styled } from "@mui/material"; +import NextLink from "next/link"; +import React from "react"; + +import ConfirmationDialog from "@/components/confirmation-dialog"; +import useDisclosure from "@/components/use-disclosure"; +import { Icon, IconName } from "@/icons"; + +import { ArrowMenu } from "./arrow-menu"; + +type ActionsProps = { + actions: ActionProps[]; +}; + +const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + color: theme.palette.primary.main, +})) as typeof MenuItem; + +export const Action = (props: ActionProps & { as: "menuitem" | "button" }) => { + const { label, iconName } = props; + const { isOpen: isConfirmationOpen } = useDisclosure(); + + const Wrapper = ({ icon, label }: { icon: IconName; label: string }) => { + const forwardedProps = + props.type === "button" + ? { + onClick: props.onClick, + } + : { + href: props.href, + }; + if (props.as === "button") { + return ( + + ); + } else { + return ( + + + {label} + + ); + } + }; + return ( + <> + {props.type === "link" ? ( + + + + ) : props.type === "button" ? ( + + ) : null} + {props.type === "button" && props.requireConfirmation && ( + + )} + + ); +}; + +export const RowActions = (props: ActionsProps) => { + const { actions } = props; + const buttonRef = React.useRef(null); + const { isOpen, open, close } = useDisclosure(); + + const [primaryAction, ...rest] = actions; + + return ( + + + + + + + {rest.map((props, i) => ( + + ))} + + + ); +}; +export type ActionProps = { + label: string; + iconName: IconName; + priority?: number; +} & ( + | { + type: "link"; + href: string; + } + | { + type: "button"; + onClick: () => Promise | void; + requireConfirmation?: false | undefined; + } + | { + type: "button"; + onClick: () => Promise | void; + requireConfirmation: true; + confirmationTitle?: string; + confirmationText?: string; + onDialogClose?: () => void; + onSuccess?: () => void; + } +); diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index 501b0e69f..8d8d76034 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -2,20 +2,8 @@ import { t, Trans } from "@lingui/macro"; import { Box, Button, - CircularProgress, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogProps, - DialogTitle, - IconButton, Link, - Menu, - MenuItem, - paperClasses, Skeleton, - styled, Table, TableBody, TableCell, @@ -28,18 +16,18 @@ import sortBy from "lodash/sortBy"; import NextLink from "next/link"; import React from "react"; -import useDisclosure from "@/components/use-disclosure"; import { ParsedConfig } from "@/db/config"; import { sourceToLabel } from "@/domain/datasource"; import { truthy } from "@/domain/types"; import { useUserConfigs } from "@/domain/user-configs"; import { useDataCubesMetadataQuery } from "@/graphql/hooks"; -import { Icon, IconName } from "@/icons"; import { useRootStyles } from "@/login/utils"; import { useLocale } from "@/src"; import { removeConfig, updateConfig } from "@/utils/chart-config/api"; import { useMutate } from "@/utils/use-fetch-data"; +import { ActionProps, RowActions } from "../../components/row-actions"; + const PREVIEW_LIMIT = 3; const SectionContent = ({ @@ -346,231 +334,8 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { - + ); }; - -type ActionsProps = { - actions: ActionProps[]; -}; - -const ArrowMenu = styled(Menu)(({ theme }) => ({ - [`& .${paperClasses.root}`]: { - overflowY: "visible", - overflowX: "visible", - "&:before": { - content: '" "', - display: "block", - background: theme.palette.background.paper, - width: 10, - height: 10, - transform: "rotate(45deg)", - position: "absolute", - margin: "auto", - top: -5, - - left: 0, - right: 0, - }, - }, -})); - -const Actions = (props: ActionsProps) => { - const { actions } = props; - const buttonRef = React.useRef(null); - const { isOpen, open, close } = useDisclosure(); - - const [primaryAction, ...rest] = actions; - - return ( - - - - - - - {rest.map((props, i) => ( - - ))} - - - ); -}; - -type ActionProps = { - label: string; - iconName: IconName; - priority?: number; -} & ( - | { - type: "link"; - href: string; - } - | { - type: "button"; - onClick: () => Promise | void; - requireConfirmation?: false | undefined; - } - | { - type: "button"; - onClick: () => Promise | void; - requireConfirmation: true; - confirmationTitle?: string; - confirmationText?: string; - onDialogClose?: () => void; - onSuccess?: () => void; - } -); -const StyledMenuItem = styled(MenuItem)(({ theme }) => ({ - display: "flex", - alignItems: "center", - gap: theme.spacing(1), - color: theme.palette.primary.main, -})) as typeof MenuItem; - -const Action = (props: ActionProps & { as: "menuitem" | "button" }) => { - const { label, iconName } = props; - const { isOpen: isConfirmationOpen } = useDisclosure(); - - const Wrapper = ({ icon, label }: { icon: IconName; label: string }) => { - const forwardedProps = - props.type === "button" - ? { - onClick: props.onClick, - } - : { - href: props.href, - }; - if (props.as === "button") { - return ( - - ); - } else { - return ( - - - {label} - - ); - } - }; - return ( - <> - {props.type === "link" ? ( - - - - ) : props.type === "button" ? ( - - ) : null} - {props.type === "button" && props.requireConfirmation && ( - - )} - - ); -}; - -const ConfirmationDialog = ({ - title, - text, - onClick, - onSuccess, - onConfirm, - ...props -}: DialogProps & { - title?: string; - text?: string; - onSuccess?: () => Promise | void; - onConfirm?: () => Promise | void; - onClick: () => Promise | void; -}) => { - const [loading, setLoading] = React.useState(false); - - return ( - e.stopPropagation()} - onClose={close} - maxWidth="xs" - {...props} - > - - - {title ?? - t({ - id: "login.profile.chart.confirmation.default", - message: "Are you sure you want to perform this action?", - })} - - - {text && ( - - {text} - - )} - .MuiButton-root": { - justifyContent: "center", - pointerEvents: loading ? "none" : "auto", - }, - }} - > - - - - - ); -}; From e9710a73e169bbdb0cb3deb4fdaa614240e691e1 Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Mon, 19 Feb 2024 14:49:40 +0100 Subject: [PATCH 51/59] feat: Add xsmall variant to button sizes --- app/components/row-actions.tsx | 4 ++-- app/themes/federal.tsx | 8 +++++++- app/themes/index.ts | 12 ++++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/components/row-actions.tsx b/app/components/row-actions.tsx index ee7b9373c..6ad47051a 100644 --- a/app/components/row-actions.tsx +++ b/app/components/row-actions.tsx @@ -35,7 +35,7 @@ export const Action = (props: ActionProps & { as: "menuitem" | "button" }) => { if (props.as === "button") { return ( + + OK + + + + + ); +}; + const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { const { userId, config } = props; const { dataSource } = config.data; @@ -165,8 +315,15 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { const { invalidate: invalidateUserConfigs } = useUserConfigs(); - const updatePublishedStateMut = useMutate(updateConfig); - const removeMut = useMutate(removeConfig); + const updateConfigMut = useMutate(updateConfig); + const removeConfigMut = useMutate(removeConfig); + + const { + isOpen: isRenameOpen, + open: openRename, + close: closeRename, + } = useDisclosure(); + const actions = React.useMemo(() => { const actions: ActionProps[] = [ { @@ -210,12 +367,10 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { message: "Turn into draft", }), iconName: - updatePublishedStateMut.status === "fetching" - ? "loading" - : "linkExternal", + updateConfigMut.status === "fetching" ? "loading" : "linkExternal", onClick: async () => { - await updatePublishedStateMut.mutate({ + await updateConfigMut.mutate({ key: config.key, user_id: userId, data: { @@ -233,11 +388,19 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { invalidateUserConfigs(); }, }, + { + type: "button", + label: t({ id: "login.chart.rename", message: "Rename" }), + iconName: "text", + onClick: () => { + openRename(); + }, + }, { type: "button", label: t({ id: "login.chart.delete", message: "Delete" }), color: "error", - iconName: removeMut.status === "fetching" ? "loading" : "trash", + iconName: removeConfigMut.status === "fetching" ? "loading" : "trash", requireConfirmation: true, confirmationTitle: t({ id: "login.chart.delete.confirmation", @@ -249,7 +412,7 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { "Keep in mind that removing this visualization will affect all the places where it might be already embedded!", }), onClick: () => { - removeMut.mutate({ key: config.key }); + removeConfigMut.mutate({ key: config.key }); }, onSuccess: () => { invalidateUserConfigs(); @@ -263,8 +426,9 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { config.key, config.published_state, invalidateUserConfigs, - removeMut, - updatePublishedStateMut, + openRename, + removeConfigMut, + updateConfigMut, userId, ]); @@ -336,6 +500,13 @@ const ProfileVisualizationsRow = (props: ProfileVisualizationsRowProps) => { + ); From ce7c21ba55446de298bfccae96ee971f1b72e08e Mon Sep 17 00:00:00 2001 From: Patrick Browne Date: Tue, 20 Feb 2024 08:52:40 +0100 Subject: [PATCH 55/59] feat: Wrap th cells into table row and extract styling to table --- app/login/components/profile-tables.tsx | 83 ++++++++++++++----------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index 3d84e7347..6e8abe2e3 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -11,12 +11,16 @@ import { Divider, Link, Skeleton, + styled, Tab, Table, TableBody, TableCell, + tableCellClasses, TableHead, + tableHeadClasses, TableRow, + tableRowClasses, TextField, Typography, useEventCallback, @@ -69,6 +73,19 @@ type ProfileVisualizationsTableProps = { onShowAll?: () => void; }; +const StyledTable = styled(Table)(({ theme }) => ({ + [`& .${tableRowClasses.root}`]: { + verticalAlign: "middle", + height: 56, + [`& > .${tableCellClasses.root}`]: { + borderBottomColor: theme.palette.divider, + }, + }, + [`& .${tableHeadClasses.root} .${tableCellClasses.root}`]: { + color: theme.palette.grey[600], + }, +})); + export const ProfileVisualizationsTable = ( props: ProfileVisualizationsTableProps ) => { @@ -78,7 +95,7 @@ export const ProfileVisualizationsTable = ( {userConfigs.length > 0 ? ( <> - + .MuiTableCell-root": { @@ -87,31 +104,33 @@ export const ProfileVisualizationsTable = ( }, }} > - - - Type - - - - - Name - - - - - Dataset - - - - - Last edit - - - - - Actions - - + + + + Type + + + + + Name + + + + + Dataset + + + + + Last edit + + + + + Actions + + + {userConfigs @@ -124,7 +143,7 @@ export const ProfileVisualizationsTable = ( /> ))} -
+ {preview && (