diff --git a/.circleci/docker-compose.cypress.yml b/.circleci/docker-compose.cypress.yml index ffa4ca6960..20cc9980e4 100644 --- a/.circleci/docker-compose.cypress.yml +++ b/.circleci/docker-compose.cypress.yml @@ -2,7 +2,7 @@ version: '3' services: server: build: ../ - command: dev_server + command: server depends_on: - postgres - redis diff --git a/client/app/assets/images/db-logos/cloudwatch.png b/client/app/assets/images/db-logos/cloudwatch.png new file mode 100644 index 0000000000..d0837bfe48 Binary files /dev/null and b/client/app/assets/images/db-logos/cloudwatch.png differ diff --git a/client/app/assets/images/db-logos/cloudwatch_insights.png b/client/app/assets/images/db-logos/cloudwatch_insights.png new file mode 100644 index 0000000000..d0837bfe48 Binary files /dev/null and b/client/app/assets/images/db-logos/cloudwatch_insights.png differ diff --git a/client/app/assets/images/db-logos/exasol.png b/client/app/assets/images/db-logos/exasol.png new file mode 100644 index 0000000000..227e1ba3c2 Binary files /dev/null and b/client/app/assets/images/db-logos/exasol.png differ diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index 2a4ce12699..b9e3cda7ea 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -85,7 +85,7 @@ strong { // Fixed width layout for specific pages @media (min-width: 768px) { - .settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { + .settings-screen, .home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { .container { width: 750px; } @@ -93,7 +93,7 @@ strong { } @media (min-width: 992px) { - .settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { + .settings-screen, .home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { .container { width: 970px; } @@ -101,7 +101,7 @@ strong { } @media (min-width: 1200px) { - .settings-screen, home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { + .settings-screen, .home-page, page-dashboard-list, page-queries-list, page-alerts-list, alert-page, queries-search-results-page, .fixed-container { .container { width: 1170px; } diff --git a/client/app/assets/less/inc/misc.less b/client/app/assets/less/inc/misc.less index cc7359b369..a4bfabf6d6 100755 --- a/client/app/assets/less/inc/misc.less +++ b/client/app/assets/less/inc/misc.less @@ -234,4 +234,9 @@ .hide-in-percy, .pace { visibility: hidden; } + + // hide tooltips in Percy + .ant-tooltip { + display: none !important; + } } \ No newline at end of file diff --git a/client/app/assets/less/inc/visualizations/cohort.less b/client/app/assets/less/inc/visualizations/cohort.less deleted file mode 100644 index e9c6948371..0000000000 --- a/client/app/assets/less/inc/visualizations/cohort.less +++ /dev/null @@ -1,28 +0,0 @@ -cohort-renderer { - display: block; -} - -.cornelius-container { - padding: 0; - margin: 0; - - .cornelius-table { - width: 100%; - margin: 0; - box-shadow: none; - border-radius: 0; - background: transparent; - - tr, th, td { - border-color: @table-border-color; - } - - td { - border-radius: 0 !important; - } - - .cornelius-time, .cornelius-label, .cornelius-people { - background-color: fade(@redash-gray, 3%) !important; - } - } -} diff --git a/client/app/assets/less/inc/visualizations/map.less b/client/app/assets/less/inc/visualizations/map.less index 39d0bc54bb..85d6c49d32 100644 --- a/client/app/assets/less/inc/visualizations/map.less +++ b/client/app/assets/less/inc/visualizations/map.less @@ -6,32 +6,4 @@ height: 100%; z-index: 0; } - - .map-custom-control.leaflet-bar { - background: #fff; - padding: 10px; - margin: 10px; - position: absolute; - z-index: 1; - - &.top-left { - left: 0; - top: 0; - } - - &.top-right { - right: 0; - top: 0; - } - - &.bottom-left { - left: 0; - bottom: 0; - } - - &.bottom-right { - right: 0; - bottom: 0; - } - } } diff --git a/client/app/assets/less/main.less b/client/app/assets/less/main.less index 152e5a5375..38bedad859 100644 --- a/client/app/assets/less/main.less +++ b/client/app/assets/less/main.less @@ -54,7 +54,6 @@ @import 'inc/visualizations/box'; @import 'inc/visualizations/pivot-table'; @import 'inc/visualizations/map'; -@import 'inc/visualizations/cohort'; @import 'inc/visualizations/misc'; /** VENDOR OVERRIDES **/ diff --git a/client/app/components/BeaconConsent.jsx b/client/app/components/BeaconConsent.jsx index 665ffe0c30..5756d36d28 100644 --- a/client/app/components/BeaconConsent.jsx +++ b/client/app/components/BeaconConsent.jsx @@ -1,5 +1,4 @@ import React, { useState } from 'react'; -import { react2angular } from 'react2angular'; import Card from 'antd/lib/card'; import Button from 'antd/lib/button'; import Typography from 'antd/lib/typography'; @@ -10,7 +9,7 @@ import OrgSettings from '@/services/organizationSettings'; const Text = Typography.Text; -export function BeaconConsent() { +function BeaconConsent() { const [hide, setHide] = useState(false); if (!clientConfig.showBeaconConsentMessage || hide) { @@ -76,8 +75,4 @@ export function BeaconConsent() { ); } -export default function init(ngModule) { - ngModule.component('beaconConsent', react2angular(BeaconConsent)); -} - -init.init = true; +export default BeaconConsent; diff --git a/client/app/components/ColorPicker/Input.jsx b/client/app/components/ColorPicker/Input.jsx index 89af2ec5af..040f2de25c 100644 --- a/client/app/components/ColorPicker/Input.jsx +++ b/client/app/components/ColorPicker/Input.jsx @@ -61,6 +61,7 @@ export default function Input({ color, presetColors, presetColumns, onChange, on ))}
#} value={inputValue} onChange={e => handleInputChange(e.target.value)} diff --git a/client/app/components/ColorPicker/Label.jsx b/client/app/components/ColorPicker/Label.jsx new file mode 100644 index 0000000000..6d73af579f --- /dev/null +++ b/client/app/components/ColorPicker/Label.jsx @@ -0,0 +1,30 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; + +import { validateColor, getColorName } from './utils'; +import './label.less'; + +export default function Label({ className, color, presetColors, ...props }) { + const name = useMemo( + () => getColorName(validateColor(color), presetColors), + [color, presetColors], + ); + + return {name}; +} + +Label.propTypes = { + className: PropTypes.string, + color: PropTypes.string, + presetColors: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), // array of colors (no tooltips) + PropTypes.objectOf(PropTypes.string), // color name => color value + ]), +}; + +Label.defaultProps = { + className: null, + color: '#FFFFFF', + presetColors: null, +}; diff --git a/client/app/components/ColorPicker/Swatch.jsx b/client/app/components/ColorPicker/Swatch.jsx index f0b510b612..17747b351f 100644 --- a/client/app/components/ColorPicker/Swatch.jsx +++ b/client/app/components/ColorPicker/Swatch.jsx @@ -1,6 +1,7 @@ import { isString } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; +import cx from 'classnames'; import Tooltip from 'antd/lib/tooltip'; import './swatch.less'; @@ -8,7 +9,7 @@ import './swatch.less'; export default function Swatch({ className, color, title, size, ...props }) { const result = ( @@ -30,7 +31,7 @@ Swatch.propTypes = { }; Swatch.defaultProps = { - className: '', + className: null, title: null, color: 'transparent', size: 12, diff --git a/client/app/components/ColorPicker/index.jsx b/client/app/components/ColorPicker/index.jsx index 946c3a5c33..32bd7703f9 100644 --- a/client/app/components/ColorPicker/index.jsx +++ b/client/app/components/ColorPicker/index.jsx @@ -1,6 +1,7 @@ import { toString } from 'lodash'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import PropTypes from 'prop-types'; +import cx from 'classnames'; import tinycolor from 'tinycolor2'; import Popover from 'antd/lib/popover'; import Card from 'antd/lib/card'; @@ -9,18 +10,16 @@ import Icon from 'antd/lib/icon'; import ColorInput from './Input'; import Swatch from './Swatch'; +import Label from './Label'; +import { validateColor } from './utils'; import './index.less'; -function validateColor(value, fallback = null) { - value = tinycolor(value); - return value.isValid() ? '#' + value.toHex().toUpperCase() : fallback; -} - export default function ColorPicker({ - color, placement, presetColors, presetColumns, triggerSize, interactive, children, onChange, + color, placement, presetColors, presetColumns, interactive, children, onChange, triggerProps, }) { const [visible, setVisible] = useState(false); + const validatedColor = useMemo(() => validateColor(color), [color]); const [currentColor, setCurrentColor] = useState(''); function handleApply() { @@ -57,16 +56,18 @@ export default function ColorPicker({ useEffect(() => { if (visible) { - setCurrentColor(validateColor(color)); + setCurrentColor(validatedColor); } - }, [color, visible]); + }, [validatedColor, visible]); return ( - {children || ()} + {children || ( + + )} ); } @@ -107,8 +115,8 @@ ColorPicker.propTypes = { PropTypes.objectOf(PropTypes.string), // color name => color value ]), presetColumns: PropTypes.number, - triggerSize: PropTypes.number, interactive: PropTypes.bool, + triggerProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types children: PropTypes.node, onChange: PropTypes.func, }; @@ -118,11 +126,12 @@ ColorPicker.defaultProps = { placement: 'top', presetColors: null, presetColumns: 8, - triggerSize: 30, interactive: false, + triggerProps: {}, children: null, onChange: () => {}, }; ColorPicker.Input = ColorInput; ColorPicker.Swatch = Swatch; +ColorPicker.Label = Label; diff --git a/client/app/components/ColorPicker/label.less b/client/app/components/ColorPicker/label.less new file mode 100644 index 0000000000..e8aa009f30 --- /dev/null +++ b/client/app/components/ColorPicker/label.less @@ -0,0 +1,7 @@ +.color-label { + vertical-align: middle; + + .color-swatch + & { + margin-left: 7px; + } +} diff --git a/client/app/components/ColorPicker/utils.js b/client/app/components/ColorPicker/utils.js new file mode 100644 index 0000000000..b397e76c6d --- /dev/null +++ b/client/app/components/ColorPicker/utils.js @@ -0,0 +1,14 @@ +import { isArray, findKey } from 'lodash'; +import tinycolor from 'tinycolor2'; + +export function validateColor(value, fallback = null) { + value = tinycolor(value); + return value.isValid() ? '#' + value.toHex().toUpperCase() : fallback; +} + +export function getColorName(color, presetColors) { + if (isArray(presetColors)) { + return color; + } + return findKey(presetColors, v => validateColor(v) === color) || color; +} diff --git a/client/app/components/ParameterApplyButton.jsx b/client/app/components/ParameterApplyButton.jsx index c41ec3898d..e6efb52595 100644 --- a/client/app/components/ParameterApplyButton.jsx +++ b/client/app/components/ParameterApplyButton.jsx @@ -12,7 +12,7 @@ function ParameterApplyButton({ paramCount, onClick }) { return (
- +
-
+
diff --git a/client/app/components/empty-state/EmptyState.jsx b/client/app/components/empty-state/EmptyState.jsx index 8494595a25..0de80fca64 100644 --- a/client/app/components/empty-state/EmptyState.jsx +++ b/client/app/components/empty-state/EmptyState.jsx @@ -1,7 +1,6 @@ import { keys, some } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular'; import classNames from 'classnames'; import CreateDashboardDialog from '@/components/dashboards/CreateDashboardDialog'; import { currentUser } from '@/services/auth'; @@ -38,7 +37,7 @@ Step.defaultProps = { onClick: null, }; -export function EmptyState({ +function EmptyState({ icon, header, description, @@ -169,8 +168,4 @@ EmptyState.defaultProps = { showInviteStep: false, }; -export default function init(ngModule) { - ngModule.component('emptyState', react2angular(EmptyState)); -} - -init.init = true; +export default EmptyState; diff --git a/client/app/components/sortable/index.jsx b/client/app/components/sortable/index.jsx index 7365e64fae..eecac56ac7 100644 --- a/client/app/components/sortable/index.jsx +++ b/client/app/components/sortable/index.jsx @@ -14,7 +14,7 @@ export const SortableContainerWrapper = sortableContainer(({ children }) => chil export const SortableElement = sortableElement(({ children }) => children); -export function SortableContainer({ disabled, containerProps, children, ...wrapperProps }) { +export function SortableContainer({ disabled, containerComponent, containerProps, children, ...wrapperProps }) { const containerRef = useRef(); const [isDragging, setIsDragging] = useState(false); @@ -59,22 +59,24 @@ export function SortableContainer({ disabled, containerProps, children, ...wrapp containerProps.ref = containerRef; } - // order of props matters - we override some of them + const ContainerComponent = containerComponent; return ( -
{children}
+ {children}
); } SortableContainer.propTypes = { disabled: PropTypes.bool, + containerComponent: PropTypes.elementType, containerProps: PropTypes.object, // eslint-disable-line react/forbid-prop-types children: PropTypes.node, }; SortableContainer.defaultProps = { disabled: false, + containerComponent: 'div', containerProps: {}, children: null, }; diff --git a/client/app/config/index.js b/client/app/config/index.js index 4901a0c192..5c715eb961 100644 --- a/client/app/config/index.js +++ b/client/app/config/index.js @@ -12,16 +12,11 @@ import ngRoute from 'angular-route'; import ngResource from 'angular-resource'; import uiBootstrap from 'angular-ui-bootstrap'; import uiSelect from 'ui-select'; -import ngMessages from 'angular-messages'; -import ngUpload from 'angular-base64-upload'; import vsRepeat from 'angular-vs-repeat'; import 'brace'; -import 'angular-ui-ace'; import 'angular-resizable'; import { each, isFunction, extend } from 'lodash'; -import '@/lib/sortable'; - import DialogWrapper from '@/components/DialogWrapper'; import organizationStatus from '@/services/organizationStatus'; @@ -65,13 +60,9 @@ const requirements = [ ngResource, ngSanitize, uiBootstrap, - ngMessages, uiSelect, - 'ui.ace', - ngUpload, 'angularResizable', vsRepeat, - 'ui.sortable', ]; const ngModule = angular.module('app', requirements); diff --git a/client/app/lib/hooks/useMemoWithDeepCompare.js b/client/app/lib/hooks/useMemoWithDeepCompare.js new file mode 100644 index 0000000000..d299596a33 --- /dev/null +++ b/client/app/lib/hooks/useMemoWithDeepCompare.js @@ -0,0 +1,11 @@ +import { isEqual } from 'lodash'; +import { useMemo, useRef } from 'react'; + +export default function useMemoWithDeepCompare(create, inputs) { + const valueRef = useRef(); + const value = useMemo(create, inputs); + if (!isEqual(value, valueRef.current)) { + valueRef.current = value; + } + return valueRef.current; +} diff --git a/client/app/lib/sortable.js b/client/app/lib/sortable.js deleted file mode 100644 index b1c3a04846..0000000000 --- a/client/app/lib/sortable.js +++ /dev/null @@ -1,510 +0,0 @@ -/* eslint-disable */ -/* -TODO: -1. export module name. -2. publish into its own repo? -3. fix lint errors. -*/ -import angular from 'angular'; -import jQuery from 'jquery'; -import sortable from 'jquery-ui/ui/widgets/sortable'; - -/* - jQuery UI Sortable plugin wrapper - - @param [ui-sortable] {object} Options to pass to $.fn.sortable() merged onto ui.config - */ -angular.module('ui.sortable', []) - .value('uiSortableConfig', { - // the default for jquery-ui sortable is "> *", we need to restrict this to - // ng-repeat items - // if the user uses - items: '> [ng-repeat],> [data-ng-repeat],> [x-ng-repeat]', - cancel: 'input, textarea, button, select, option, .ui-sortable-bypass', - }) - .directive('uiSortable', [ - 'uiSortableConfig', '$timeout', '$log', - function (uiSortableConfig, $timeout, $log) { - return { - require: '?ngModel', - scope: { - ngModel: '=', - uiSortable: '=', - }, - link(scope, element, attrs, ngModel) { - let savedNodes; - element = jQuery(element); - - function combineCallbacks(first, second) { - const firstIsFunc = typeof first === 'function'; - const secondIsFunc = typeof second === 'function'; - if (firstIsFunc && secondIsFunc) { - return function () { - first.apply(this, arguments); - second.apply(this, arguments); - }; - } else if (secondIsFunc) { - return second; - } - return first; - } - - function getSortableWidgetInstance(element) { - // this is a fix to support jquery-ui prior to v1.11.x - // otherwise we should be using `element.sortable('instance')` - const data = element.data('ui-sortable'); - if (data && typeof data === 'object' && data.widgetFullName === 'ui-sortable') { - return data; - } - return null; - } - - function patchSortableOption(key, value) { - if (callbacks[key]) { - if (key === 'stop') { - // call apply after stop - value = combineCallbacks( - value, () => { scope.$apply(); }); - - value = combineCallbacks(value, afterStop); - } - // wrap the callback - value = combineCallbacks(callbacks[key], value); - } else if (wrappers[key]) { - value = wrappers[key](value); - } - - // patch the options that need to have values set - if (!value && (key === 'items' || key === 'ui-model-items')) { - value = uiSortableConfig.items; - } - - return value; - } - - function patchUISortableOptions(newVal, oldVal, sortableWidgetInstance) { - function addDummyOptionKey(value, key) { - if (!(key in opts)) { - // add the key in the opts object so that - // the patch function detects and handles it - opts[key] = null; - } - } - // for this directive to work we have to attach some callbacks - angular.forEach(callbacks, addDummyOptionKey); - - // only initialize it in case we have to - // update some options of the sortable - let optsDiff = null; - - if (oldVal) { - // reset deleted options to default - let defaultOptions; - angular.forEach(oldVal, (oldValue, key) => { - if (!newVal || !(key in newVal)) { - if (key in directiveOpts) { - if (key === 'ui-floating') { - opts[key] = 'auto'; - } else { - opts[key] = patchSortableOption(key, undefined); - } - return; - } - - if (!defaultOptions) { - defaultOptions = sortable().options; - } - let defaultValue = defaultOptions[key]; - defaultValue = patchSortableOption(key, defaultValue); - - if (!optsDiff) { - optsDiff = {}; - } - optsDiff[key] = defaultValue; - opts[key] = defaultValue; - } - }); - } - - // update changed options - angular.forEach(newVal, (value, key) => { - // if it's a custom option of the directive, - // handle it approprietly - if (key in directiveOpts) { - if (key === 'ui-floating' && (value === false || value === true) && sortableWidgetInstance) { - sortableWidgetInstance.floating = value; - } - - opts[key] = patchSortableOption(key, value); - return; - } - - value = patchSortableOption(key, value); - - if (!optsDiff) { - optsDiff = {}; - } - optsDiff[key] = value; - opts[key] = value; - }); - - return optsDiff; - } - - function getPlaceholderElement(element) { - const placeholder = element.sortable('option', 'placeholder'); - - // placeholder.element will be a function if the placeholder, has - // been created (placeholder will be an object). If it hasn't - // been created, either placeholder will be false if no - // placeholder class was given or placeholder.element will be - // undefined if a class was given (placeholder will be a string) - if (placeholder && placeholder.element && typeof placeholder.element === 'function') { - let result = placeholder.element(); - // workaround for jquery ui 1.9.x, - // not returning jquery collection - result = jQuery(result); - return result; - } - return null; - } - - function getPlaceholderExcludesludes(element, placeholder) { - // exact match with the placeholder's class attribute to handle - // the case that multiple connected sortables exist and - // the placeholder option equals the class of sortable items - const notCssSelector = opts['ui-model-items'].replace(/[^,]*>/g, ''); - const excludes = element.find(`[class="${placeholder.attr('class')}"]:not(${notCssSelector})`); - return excludes; - } - - function hasSortingHelper(element, ui) { - const helperOption = element.sortable('option', 'helper'); - return helperOption === 'clone' || (typeof helperOption === 'function' && ui.item.sortable.isCustomHelperUsed()); - } - - function getSortingHelper(element, ui, savedNodes) { - let result = null; - if (hasSortingHelper(element, ui) && - element.sortable('option', 'appendTo') === 'parent') { - // The .ui-sortable-helper element (that's the default class name) - // is placed last. - result = savedNodes.last(); - } - return result; - } - - // thanks jquery-ui - function isFloating(item) { - return (/left|right/).test(item.css('float')) || (/inline|table-cell/).test(item.css('display')); - } - - function getElementScope(elementScopes, element) { - let result = null; - for (let i = 0; i < elementScopes.length; i++) { - const x = elementScopes[i]; - if (x.element[0] === element[0]) { - result = x.scope; - break; - } - } - return result; - } - - function afterStop(e, ui) { - ui.item.sortable._destroy(); - } - - // return the index of ui.item among the items - // we can't just do ui.item.index() because there it might have siblings - // which are not items - function getItemIndex(item) { - return item.parent() - .find(opts['ui-model-items']) - .index(item); - } - - let opts = {}; - - // directive specific options - let directiveOpts = { - 'ui-floating': undefined, - 'ui-model-items': uiSortableConfig.items, - }; - - let callbacks = { - receive: null, - remove: null, - start: null, - stop: null, - update: null, - }; - - let wrappers = { - helper: null, - }; - - angular.extend(opts, directiveOpts, uiSortableConfig, scope.uiSortable); - - function wireUp() { - // When we add or remove elements, we need the sortable to 'refresh' - // so it can find the new/removed elements. - scope.$watchCollection('ngModel', () => { - // Timeout to let ng-repeat modify the DOM - $timeout(() => { - // ensure that the jquery-ui-sortable widget instance - // is still bound to the directive's element - if (!!getSortableWidgetInstance(element)) { - element.sortable('refresh'); - } - }, 0, false); - }); - - callbacks.start = function (e, ui) { - if (opts['ui-floating'] === 'auto') { - // since the drag has started, the element will be - // absolutely positioned, so we check its siblings - const siblings = ui.item.siblings(); - const sortableWidgetInstance = getSortableWidgetInstance(jQuery(e.target)); - sortableWidgetInstance.floating = isFloating(siblings); - } - - // Save the starting position of dragged item - const index = getItemIndex(ui.item); - ui.item.sortable = { - model: ngModel.$modelValue[index], - index, - source: ui.item.parent(), - sourceModel: ngModel.$modelValue, - cancel() { - ui.item.sortable._isCanceled = true; - }, - isCanceled() { - return ui.item.sortable._isCanceled; - }, - isCustomHelperUsed() { - return !!ui.item.sortable._isCustomHelperUsed; - }, - _isCanceled: false, - _isCustomHelperUsed: ui.item.sortable._isCustomHelperUsed, - _destroy() { - angular.forEach(ui.item.sortable, (value, key) => { - ui.item.sortable[key] = undefined; - }); - }, - }; - }; - - callbacks.activate = function (e, ui) { - // We need to make a copy of the current element's contents so - // we can restore it after sortable has messed it up. - // This is inside activate (instead of start) in order to save - // both lists when dragging between connected lists. - savedNodes = element.contents(); - - // If this list has a placeholder (the connected lists won't), - // don't inlcude it in saved nodes. - const placeholder = getPlaceholderElement(element); - if (placeholder && placeholder.length) { - const excludes = getPlaceholderExcludesludes(element, placeholder); - savedNodes = savedNodes.not(excludes); - } - - // save the directive's scope so that it is accessible from ui.item.sortable - const connectedSortables = ui.item.sortable._connectedSortables || []; - - connectedSortables.push({ - element, - scope, - }); - - ui.item.sortable._connectedSortables = connectedSortables; - }; - - callbacks.update = function (e, ui) { - // Save current drop position but only if this is not a second - // update that happens when moving between lists because then - // the value will be overwritten with the old value - if (!ui.item.sortable.received) { - ui.item.sortable.dropindex = getItemIndex(ui.item); - const droptarget = ui.item.parent(); - ui.item.sortable.droptarget = droptarget; - - const droptargetScope = getElementScope(ui.item.sortable._connectedSortables, droptarget); - ui.item.sortable.droptargetModel = droptargetScope.ngModel; - - // Cancel the sort (let ng-repeat do the sort for us) - // Don't cancel if this is the received list because it has - // already been canceled in the other list, and trying to cancel - // here will mess up the DOM. - element.sortable('cancel'); - } - - // Put the nodes back exactly the way they started (this is very - // important because ng-repeat uses comment elements to delineate - // the start and stop of repeat sections and sortable doesn't - // respect their order (even if we cancel, the order of the - // comments are still messed up). - const sortingHelper = !ui.item.sortable.received && getSortingHelper(element, ui, savedNodes); - if (sortingHelper && sortingHelper.length) { - // Restore all the savedNodes except from the sorting helper element. - // That way it will be garbage collected. - savedNodes = savedNodes.not(sortingHelper); - } - savedNodes.appendTo(element); - - // If this is the target connected list then - // it's safe to clear the restored nodes since: - // update is currently running and - // stop is not called for the target list. - if (ui.item.sortable.received) { - savedNodes = null; - } - - // If received is true (an item was dropped in from another list) - // then we add the new item to this list otherwise wait until the - // stop event where we will know if it was a sort or item was - // moved here from another list - if (ui.item.sortable.received && !ui.item.sortable.isCanceled()) { - scope.$apply(() => { - ngModel.$modelValue.splice(ui.item.sortable.dropindex, 0, - ui.item.sortable.moved); - }); - } - }; - - callbacks.stop = function (e, ui) { - // If the received flag hasn't be set on the item, this is a - // normal sort, if dropindex is set, the item was moved, so move - // the items in the list. - if (!ui.item.sortable.received && - ('dropindex' in ui.item.sortable) && - !ui.item.sortable.isCanceled()) { - scope.$apply(() => { - ngModel.$modelValue.splice( - ui.item.sortable.dropindex, 0, - ngModel.$modelValue.splice(ui.item.sortable.index, 1)[0]); - }); - } else { - // if the item was not moved, then restore the elements - // so that the ngRepeat's comment are correct. - if ((!('dropindex' in ui.item.sortable) || ui.item.sortable.isCanceled()) && - !angular.equals(element.contents(), savedNodes)) { - const sortingHelper = getSortingHelper(element, ui, savedNodes); - if (sortingHelper && sortingHelper.length) { - // Restore all the savedNodes except from the sorting helper element. - // That way it will be garbage collected. - savedNodes = savedNodes.not(sortingHelper); - } - savedNodes.appendTo(element); - } - } - - // It's now safe to clear the savedNodes - // since stop is the last callback. - savedNodes = null; - }; - - callbacks.receive = function (e, ui) { - // An item was dropped here from another list, set a flag on the - // item. - ui.item.sortable.received = true; - }; - - callbacks.remove = function (e, ui) { - // Workaround for a problem observed in nested connected lists. - // There should be an 'update' event before 'remove' when moving - // elements. If the event did not fire, cancel sorting. - if (!('dropindex' in ui.item.sortable)) { - element.sortable('cancel'); - ui.item.sortable.cancel(); - } - - // Remove the item from this list's model and copy data into item, - // so the next list can retrive it - if (!ui.item.sortable.isCanceled()) { - scope.$apply(() => { - ui.item.sortable.moved = ngModel.$modelValue.splice( - ui.item.sortable.index, 1)[0]; - }); - } - }; - - wrappers.helper = function (inner) { - if (inner && typeof inner === 'function') { - return function (e, item) { - const oldItemSortable = item.sortable; - const index = getItemIndex(item); - item.sortable = { - model: ngModel.$modelValue[index], - index, - source: item.parent(), - sourceModel: ngModel.$modelValue, - _restore() { - angular.forEach(item.sortable, (value, key) => { - item.sortable[key] = undefined; - }); - - item.sortable = oldItemSortable; - }, - }; - - const innerResult = inner.apply(this, arguments); - item.sortable._restore(); - item.sortable._isCustomHelperUsed = item !== innerResult; - return innerResult; - }; - } - return inner; - }; - - scope.$watchCollection('uiSortable', (newVal, oldVal) => { - // ensure that the jquery-ui-sortable widget instance - // is still bound to the directive's element - const sortableWidgetInstance = getSortableWidgetInstance(element); - if (!!sortableWidgetInstance) { - const optsDiff = patchUISortableOptions(newVal, oldVal, sortableWidgetInstance); - - if (optsDiff) { - element.sortable('option', optsDiff); - } - } - }, true); - - patchUISortableOptions(opts); - } - - function init() { - if (ngModel) { - wireUp(); - } else { - $log.info('ui.sortable: ngModel not provided!', element); - } - - // Create sortable - element.sortable(opts); - } - - function initIfEnabled() { - if (scope.uiSortable && scope.uiSortable.disabled) { - return false; - } - - init(); - - // Stop Watcher - initIfEnabled.cancelWatcher(); - initIfEnabled.cancelWatcher = angular.noop; - - return true; - } - - initIfEnabled.cancelWatcher = angular.noop; - - if (!initIfEnabled()) { - initIfEnabled.cancelWatcher = scope.$watch('uiSortable.disabled', initIfEnabled); - } - }, - }; - }, - ]); diff --git a/client/app/lib/value-format.js b/client/app/lib/value-format.js index 263e148cda..e7f1aadf1f 100644 --- a/client/app/lib/value-format.js +++ b/client/app/lib/value-format.js @@ -1,6 +1,6 @@ import moment from 'moment/moment'; import numeral from 'numeral'; -import { isString, isArray, isUndefined, isNil, toString } from 'lodash'; +import { isString, isArray, isUndefined, isFinite, isNil, toString } from 'lodash'; numeral.options.scalePercentBy100 = false; @@ -21,13 +21,22 @@ export function createTextFormatter(highlightLinks) { return value => toString(value); } +function toMoment(value) { + if (moment.isMoment(value)) { + return value; + } + if (isFinite(value)) { + return moment(value); + } + // same as default `moment(value)`, but avoid fallback to `new Date()` + return moment(toString(value), [moment.ISO_8601, moment.RFC_2822]); +} + export function createDateTimeFormatter(format) { if (isString(format) && (format !== '')) { return (value) => { - if (value && moment.isMoment(value)) { - return value.format(format); - } - return toString(value); + const wrapped = toMoment(value); + return wrapped.isValid() ? wrapped.format(format) : toString(value); }; } return value => toString(value); diff --git a/client/app/pages/admin/Jobs.jsx b/client/app/pages/admin/Jobs.jsx index 533c06f82a..f04efc39bc 100644 --- a/client/app/pages/admin/Jobs.jsx +++ b/client/app/pages/admin/Jobs.jsx @@ -9,14 +9,14 @@ import Layout from '@/components/admin/Layout'; import { CounterCard } from '@/components/admin/CeleryStatus'; import { WorkersTable, QueuesTable, OtherJobsTable } from '@/components/admin/RQStatus'; -import { $http } from '@/services/ng'; +import { $http, $location, $rootScope } from '@/services/ng'; import recordEvent from '@/services/recordEvent'; import { routesToAngularRoutes } from '@/lib/utils'; import moment from 'moment'; class Jobs extends React.Component { state = { - activeTab: location.hash.replace('#', '') || null, + activeTab: $location.hash(), isLoading: true, error: null, @@ -80,7 +80,8 @@ class Jobs extends React.Component { const { isLoading, error, queueCounters, startedJobs, overallCounters, workers, activeTab } = this.state; const changeTab = (newTab) => { - location.replace(`${location.pathname}#${newTab}`); + $location.hash(newTab); + $rootScope.$applyAsync(); this.setState({ activeTab: newTab }); }; diff --git a/client/app/pages/alert/components/Query.less b/client/app/pages/alert/components/Query.less index 75c940ac09..009af32088 100644 --- a/client/app/pages/alert/components/Query.less +++ b/client/app/pages/alert/components/Query.less @@ -14,4 +14,13 @@ .alert-query-schedule { font-style: italic; text-transform: lowercase; +} + +@media only percy { + // hide query selector arrow in Percy to avoid a flaky snapshot + .alert-query-selector { + .ant-select-arrow-icon { + display: none !important; + } + } } \ No newline at end of file diff --git a/client/app/pages/alerts/AlertsList.jsx b/client/app/pages/alerts/AlertsList.jsx index 34fef9ebc7..e7ecabfc7d 100644 --- a/client/app/pages/alerts/AlertsList.jsx +++ b/client/app/pages/alerts/AlertsList.jsx @@ -4,7 +4,7 @@ import { react2angular } from 'react2angular'; import { toUpper } from 'lodash'; import { PageHeader } from '@/components/PageHeader'; import { Paginator } from '@/components/Paginator'; -import { EmptyState } from '@/components/empty-state/EmptyState'; +import EmptyState from '@/components/empty-state/EmptyState'; import { wrap as liveItemsList, ControllerType } from '@/components/items-list/ItemsList'; import { ResourceItemsSource } from '@/components/items-list/classes/ItemsSource'; diff --git a/client/app/pages/dashboards/DashboardListEmptyState.jsx b/client/app/pages/dashboards/DashboardListEmptyState.jsx index 810335ffe5..d0dcf99c12 100644 --- a/client/app/pages/dashboards/DashboardListEmptyState.jsx +++ b/client/app/pages/dashboards/DashboardListEmptyState.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { BigMessage } from '@/components/BigMessage'; import { NoTaggedObjectsFound } from '@/components/NoTaggedObjectsFound'; -import { EmptyState } from '@/components/empty-state/EmptyState'; +import EmptyState from '@/components/empty-state/EmptyState'; export default function DashboardListEmptyState({ page, searchTerm, selectedTags }) { if (searchTerm !== '') { diff --git a/client/app/pages/home/Home.jsx b/client/app/pages/home/Home.jsx new file mode 100644 index 0000000000..907ba24d03 --- /dev/null +++ b/client/app/pages/home/Home.jsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { includes, isEmpty } from 'lodash'; +import { react2angular } from 'react2angular'; +import Alert from 'antd/lib/alert'; +import Icon from 'antd/lib/icon'; +import EmptyState from '@/components/empty-state/EmptyState'; +import DynamicComponent from '@/components/DynamicComponent'; +import BeaconConsent from '@/components/BeaconConsent'; +import recordEvent from '@/services/recordEvent'; +import { messages } from '@/services/auth'; +import { $http } from '@/services/ng'; +import notification from '@/services/notification'; +import { Dashboard } from '@/services/dashboard'; +import { Query } from '@/services/query'; + +function DeprecatedEmbedFeatureAlert() { + return ( + + You have enabled ALLOW_PARAMETERS_IN_EMBEDS. This setting is + now deprecated and should be turned off. Parameters in embeds are supported + by default.{' '} + + Read more + . + + )} + /> + ); +} + +function EmailNotVerifiedAlert() { + const verifyEmail = () => { + $http.post('verification_email').then(({ data }) => { + notification.success(data.message); + }); + }; + + return ( + + We have sent an email with a confirmation link to your email address. Please + follow the link to verify your email address.{' '} + Resend email. + + )} + /> + ); +} + +function FavoriteList({ title, resource, itemUrl, emptyState }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + resource.favorites().$promise + .then(({ results }) => setItems(results)) + .finally(() => setLoading(false)); + }, []); + + return ( + <> +
+

{title}

+ {loading && } +
+ {!isEmpty(items) && ( +