diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 8bc0055d03..ad3cb03004 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -34,6 +34,8 @@ module.exports = { // Do not complain about useless contructors in declaration files "no-useless-constructor": "off", "@typescript-eslint/no-useless-constructor": "error", + // Many API fields and generated types use camelcase + "@typescript-eslint/camelcase": "off", }, }, ], diff --git a/client/app/components/ApplicationArea/routeWithUserSession.jsx b/client/app/components/ApplicationArea/routeWithUserSession.tsx similarity index 51% rename from client/app/components/ApplicationArea/routeWithUserSession.jsx rename to client/app/components/ApplicationArea/routeWithUserSession.tsx index 7c3ec86bd0..efc6e5628a 100644 --- a/client/app/components/ApplicationArea/routeWithUserSession.jsx +++ b/client/app/components/ApplicationArea/routeWithUserSession.tsx @@ -1,21 +1,33 @@ import React, { useEffect, useState } from "react"; -import PropTypes from "prop-types"; +// @ts-expect-error (Must be removed after adding @redash/viz typing) import ErrorBoundary, { ErrorBoundaryContext } from "@redash/viz/lib/components/ErrorBoundary"; import { Auth } from "@/services/auth"; import { policy } from "@/services/policy"; +import { CurrentRoute } from "@/services/routes"; import organizationStatus from "@/services/organizationStatus"; +import DynamicComponent from "@/components/DynamicComponent"; import ApplicationLayout from "./ApplicationLayout"; import ErrorMessage from "./ErrorMessage"; +export type UserSessionWrapperRenderChildrenProps

= { + pageTitle?: string; + onError: (error: Error) => void; +} & P; + +export interface UserSessionWrapperProps

{ + render: (props: UserSessionWrapperRenderChildrenProps

) => React.ReactNode; + currentRoute: CurrentRoute

; + bodyClass?: string; +} + // This wrapper modifies `route.render` function and instead of passing `currentRoute` passes an object // that contains: // - `currentRoute.routeParams` // - `pageTitle` field which is equal to `currentRoute.title` // - `onError` field which is a `handleError` method of nearest error boundary -function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) { +export function UserSessionWrapper

({ bodyClass, currentRoute, render }: UserSessionWrapperProps

) { const [isAuthenticated, setIsAuthenticated] = useState(!!Auth.isAuthenticated()); - useEffect(() => { let isCancelled = false; Promise.all([Auth.requireSession(), organizationStatus.refresh(), policy.refresh()]) @@ -50,10 +62,10 @@ function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) { return ( - }> + }> - {({ handleError }) => - renderChildren({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError }) + {({ handleError }: { handleError: UserSessionWrapperRenderChildrenProps

["onError"] }) => + render({ ...currentRoute.routeParams, pageTitle: currentRoute.title, onError: handleError }) } @@ -62,21 +74,35 @@ function UserSessionWrapper({ bodyClass, currentRoute, renderChildren }) { ); } -UserSessionWrapper.propTypes = { - bodyClass: PropTypes.string, - renderChildren: PropTypes.func, +export type RouteWithUserSessionOptions

= { + render: (props: UserSessionWrapperRenderChildrenProps

) => React.ReactNode; + bodyClass?: string; + title: string; + path: string; }; -UserSessionWrapper.defaultProps = { - bodyClass: null, - renderChildren: () => null, -}; +export const UserSessionWrapperDynamicComponentName = "UserSessionWrapper"; -export default function routeWithUserSession({ render, bodyClass, ...rest }) { +export default function routeWithUserSession

({ + render: originalRender, + bodyClass, + ...rest +}: RouteWithUserSessionOptions

) { return { ...rest, - render: currentRoute => ( - - ), + render: (currentRoute: CurrentRoute

) => { + const props = { + render: originalRender, + bodyClass, + currentRoute, + }; + return ( + } + /> + ); + }, }; } diff --git a/client/app/components/DynamicComponent.jsx b/client/app/components/DynamicComponent.jsx index 98e2d78c28..5e4af4ade8 100644 --- a/client/app/components/DynamicComponent.jsx +++ b/client/app/components/DynamicComponent.jsx @@ -1,4 +1,4 @@ -import { isFunction, isString } from "lodash"; +import { isFunction, isString, isUndefined } from "lodash"; import React from "react"; import PropTypes from "prop-types"; @@ -24,6 +24,7 @@ export function unregisterComponent(name) { export default class DynamicComponent extends React.Component { static propTypes = { name: PropTypes.string.isRequired, + fallback: PropTypes.node, children: PropTypes.node, }; @@ -40,10 +41,11 @@ export default class DynamicComponent extends React.Component { } render() { - const { name, children, ...props } = this.props; + const { name, children, fallback, ...props } = this.props; const RealComponent = componentsRegistry.get(name); if (!RealComponent) { - return children; + // return fallback if any, otherwise return children + return isUndefined(fallback) ? children : fallback; } return {children}; } diff --git a/client/app/components/TimeAgo.jsx b/client/app/components/TimeAgo.jsx index 20d0c4643f..da94f1baa2 100644 --- a/client/app/components/TimeAgo.jsx +++ b/client/app/components/TimeAgo.jsx @@ -11,7 +11,7 @@ function toMoment(value) { return value && value.isValid() ? value : null; } -export default function TimeAgo({ date, placeholder, autoUpdate }) { +export default function TimeAgo({ date, placeholder, autoUpdate, variation }) { const startDate = toMoment(date); const [value, setValue] = useState(null); const title = useMemo(() => (startDate ? startDate.format(clientConfig.dateTimeFormat) : null), [startDate]); @@ -28,6 +28,13 @@ export default function TimeAgo({ date, placeholder, autoUpdate }) { } }, [autoUpdate, startDate, placeholder]); + if (variation === "timeAgoInTooltip") { + return ( + + {title} + + ); + } return ( {value} @@ -39,6 +46,7 @@ TimeAgo.propTypes = { date: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.instanceOf(Date), Moment]), placeholder: PropTypes.string, autoUpdate: PropTypes.bool, + variation: PropTypes.oneOf(["timeAgoInTooltip"]), }; TimeAgo.defaultProps = { diff --git a/client/app/lib/utils.js b/client/app/lib/utils.js index 3508eda22d..0aad1a3041 100644 --- a/client/app/lib/utils.js +++ b/client/app/lib/utils.js @@ -11,6 +11,15 @@ export const IntervalEnum = { MILLISECONDS: "millisecond", }; +export const AbbreviatedTimeUnits = { + SECONDS: "s", + MINUTES: "m", + HOURS: "h", + DAYS: "d", + WEEKS: "w", + MILLISECONDS: "ms", +}; + export function formatDateTime(value) { if (!value) { return ""; diff --git a/client/app/pages/queries/QuerySource.jsx b/client/app/pages/queries/QuerySource.jsx index 2f97c50662..d598468170 100644 --- a/client/app/pages/queries/QuerySource.jsx +++ b/client/app/pages/queries/QuerySource.jsx @@ -44,7 +44,6 @@ import useUnsavedChangesAlert from "./hooks/useUnsavedChangesAlert"; import "./QuerySource.less"; function chooseDataSourceId(dataSourceIds, availableDataSources) { - dataSourceIds = map(dataSourceIds, v => parseInt(v, 10)); availableDataSources = map(availableDataSources, ds => ds.id); return find(dataSourceIds, id => includes(availableDataSources, id)) || null; } diff --git a/client/app/services/sanitize.js b/client/app/services/sanitize.js index 4e162db3da..47521ba408 100644 --- a/client/app/services/sanitize.js +++ b/client/app/services/sanitize.js @@ -18,4 +18,6 @@ DOMPurify.addHook("afterSanitizeAttributes", function(node) { } }); +export { DOMPurify }; + export default DOMPurify.sanitize; diff --git a/viz-lib/src/services/sanitize.js b/viz-lib/src/services/sanitize.js index 4e162db3da..47521ba408 100644 --- a/viz-lib/src/services/sanitize.js +++ b/viz-lib/src/services/sanitize.js @@ -18,4 +18,6 @@ DOMPurify.addHook("afterSanitizeAttributes", function(node) { } }); +export { DOMPurify }; + export default DOMPurify.sanitize;