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;