diff --git a/README.md b/README.md index 473569991..b093ef3f3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Website | Documentation | Twitter | - Discord | + Slack | Blog

diff --git a/package-lock.json b/package-lock.json index 3f6ac114d..c0cc0dcc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6655,9 +6655,9 @@ } }, "node_modules/anser": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", - "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==" + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.1.1.tgz", + "integrity": "sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ==" }, "node_modules/ansi-align": { "version": "3.0.1", @@ -6730,19 +6730,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansi-to-react": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/ansi-to-react/-/ansi-to-react-6.1.6.tgz", - "integrity": "sha512-+HWn72GKydtupxX9TORBedqOMsJRiKTqaLUKW8txSBZw9iBpzPKLI8KOu4WzwD4R7hSv1zEspobY6LwlWvwZ6Q==", - "dependencies": { - "anser": "^1.4.1", - "escape-carriage": "^1.3.0" - }, - "peerDependencies": { - "react": "^16.3.2 || ^17.0.0", - "react-dom": "^16.3.2 || ^17.0.0" - } - }, "node_modules/antd": { "version": "4.24.14", "resolved": "https://registry.npmjs.org/antd/-/antd-4.24.14.tgz", @@ -23959,12 +23946,13 @@ "@sentry/integrations": "^7.64.0", "@sentry/react": "^7.64.0", "@testkube/plugins": "*", - "ansi-to-react": "^6.1.6", + "anser": "^2.1.1", "antd": "^4.24.12", "axios": "0.27.2", "classnames": "2.3.1", "cron-parser": "^4.8.1", "date-fns": "^2.28.0", + "escape-carriage": "^1.3.1", "file-saver": "^2.0.5", "framer-motion": "^4.1.17", "lodash.debounce": "^4.0.8", diff --git a/packages/e2e-tests/fixtures/triggers.ts b/packages/e2e-tests/fixtures/triggers.ts index c032531ef..8eba5de85 100644 --- a/packages/e2e-tests/fixtures/triggers.ts +++ b/packages/e2e-tests/fixtures/triggers.ts @@ -11,7 +11,7 @@ export default { concurrencyPolicy: '', testSelector: { name: 'postman-executor-smoke', - ...(config.cloudContext ? {} : {namespace: config.namespace}), + namespace: config.namespace, }, resourceSelector: { name: 'non-existant-resource', diff --git a/packages/e2e-tests/helpers/common.ts b/packages/e2e-tests/helpers/common.ts index 61d10392c..4559f439d 100644 --- a/packages/e2e-tests/helpers/common.ts +++ b/packages/e2e-tests/helpers/common.ts @@ -58,5 +58,5 @@ export function validateWebhook(webhookData: Partial, createdWebhoo } export function validateTrigger(triggerData: Partial, createdTriggerData: TriggerData): void { - expect(triggerData).toEqual(createdTriggerData); + expect(createdTriggerData).toEqual(triggerData); } diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 594789256..9eaf8fef4 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -132,6 +132,12 @@ export default createPlugin('some-plugin-name') // Using .provider() is more convenient though. const isLoading = tk.sync(() => useSomeStoreData('loading')); tk.slots.somePluginStub.someOtherSlot.add(<>Loading..., {enabled: isLoading}); + + // When you need to ensure that the slot usage will be updated immediately after .sync() change, + // you may use .syncSubscribe() function instead. + // It's a bit slower, as it will emit the change to all scopes. + const isLoadingAsap = tk.syncSubscribe(() => useSomeStoreData('loading')); + tk.slots.somePluginStub.someOtherSlot.add(<>Loading..., {enabled: isLoadingAsap}); }); ``` diff --git a/packages/plugins/src/internal/PluginScope.spec.ts b/packages/plugins/src/internal/PluginScope.spec.ts index fd9617a4b..0ff6f673e 100644 --- a/packages/plugins/src/internal/PluginScope.spec.ts +++ b/packages/plugins/src/internal/PluginScope.spec.ts @@ -265,6 +265,103 @@ describe('plugins', () => { ); }); + it('should return default value before synchronization (syncSubscribe)', () => { + const scope = create({}); + const fn1 = jest.fn(() => Math.random()); + const fn2 = jest.fn(() => Math.random()); + const fnSync1 = scope.syncSubscribe(fn1); + const fnSync2 = scope.syncSubscribe(fn2, 1.5); + expect(fn1).not.toHaveBeenCalled(); + expect(fn2).not.toHaveBeenCalled(); + expect(fnSync1()).toBe(undefined); + expect(fnSync2()).toBe(1.5); + }); + + it('should return cached value after synchronization (syncSubscribe)', () => { + const scope = create({}); + const fn1 = jest.fn(() => Math.random()); + const fn2 = jest.fn(() => Math.random()); + const fnSync1 = scope.syncSubscribe(fn1); + const fnSync2 = scope.syncSubscribe(fn2, 1.5); + scope[PluginScopeCallSync](); + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn2).toHaveBeenCalledTimes(1); + expect(fnSync1()).toBe(fn1.mock.results[0].value); + expect(fnSync2()).toBe(fn2.mock.results[0].value); + }); + + it('should replace value after multiple synchronizations (syncSubscribe)', () => { + const scope = create({}); + const fn = jest.fn(() => Math.random()); + const fnSync = scope.syncSubscribe(fn); + scope[PluginScopeCallSync](); + scope[PluginScopeCallSync](); + expect(fn).toHaveBeenCalledTimes(2); + expect(fnSync()).toBe(fn.mock.results[1].value); + }); + + it('should emit change in root scope on initial run when its different than default (syncSubscribe)', async () => { + const root = create({}); + const middle = create({}, root); + const scope = create({}, middle); + const listener = jest.fn(); + root[PluginScopeSubscribeChange](listener); + scope.syncSubscribe(() => 10); + scope[PluginScopeCallSync](); + await frame(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should not emit change in root scope on initial run when its same as default (syncSubscribe)', async () => { + const root = create({}); + const middle = create({}, root); + const scope = create({}, middle); + const listener = jest.fn(); + root[PluginScopeSubscribeChange](listener); + scope.syncSubscribe(() => 10, 10); + scope[PluginScopeCallSync](); + await frame(); + expect(listener).toHaveBeenCalledTimes(0); + }); + + it('should not emit change in root scope when there is no change (syncSubscribe)', async () => { + const root = create({}); + const middle = create({}, root); + const scope = create({}, middle); + const listener = jest.fn(); + root[PluginScopeSubscribeChange](listener); + const value = 10; + const fn = jest.fn(() => value); + scope.syncSubscribe(fn, value); + scope[PluginScopeCallSync](); + scope[PluginScopeCallSync](); + await frame(); + expect(listener).toHaveBeenCalledTimes(0); + }); + + it('should emit change in root scope when there is a change (syncSubscribe)', async () => { + const root = create({}); + const middle = create({}, root); + const scope = create({}, middle); + const listener = jest.fn(); + root[PluginScopeSubscribeChange](listener); + let value = 10; + scope.syncSubscribe(() => value, value); + scope[PluginScopeCallSync](); + value = 123; + scope[PluginScopeCallSync](); + await frame(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('should not allow synchronization after plugin initialization (syncSubscribe)', () => { + const scope = create({}); + scope[PluginScopeDisableNewSync](); + expect(() => scope.syncSubscribe(() => {})).toThrow( + new Error('The syncSubscribe() factory may be executed only during initialization.') + ); + }); + it('should allow destroying items produced by specific scope or its children', () => { const root = create({slots: ['slot1']}); const separate = create({inheritedSlots: ['slot1']}, root); diff --git a/packages/plugins/src/internal/PluginScope.ts b/packages/plugins/src/internal/PluginScope.ts index ea5379278..ddff6f9c5 100644 --- a/packages/plugins/src/internal/PluginScope.ts +++ b/packages/plugins/src/internal/PluginScope.ts @@ -125,7 +125,7 @@ export class PluginScope { public [PluginScopeCallSync](): void { Array.from(this[PluginScopeSyncData].keys()).forEach(fn => { - this[PluginScopeSyncData].set(fn, fn()); + this[PluginScopeSyncData].set(fn, fn(this[PluginScopeSyncData].get(fn))); }); } @@ -189,6 +189,36 @@ export class PluginScope { return () => this[PluginScopeSyncData].get(wrappedFn) ?? defaultValue; } + /** + * Transfer data from React to the plugin context. + * + * The difference from sync() is, that it will emit change in the scope when the value is different. + * It's a bit slower, as it's trigger a change on the root scope afterward. + * + * TODO: Consider using Observables instead, that could be passed to Slot.enabled? + */ + public syncSubscribe(fn: () => U, defaultValue: U): () => U; + public syncSubscribe(fn: () => U, defaultValue?: undefined): () => U | undefined; + public syncSubscribe(fn: () => U, defaultValue?: U): () => U | undefined { + if (this[PluginScopeDisableNewSyncStatus]) { + throw new Error('The syncSubscribe() factory may be executed only during initialization.'); + } + + const wrappedFn = (prevValue: U | undefined = defaultValue) => { + const nextValue = fn(); + let root: PluginScope = this; + while (root[PluginScopeParentScope]) { + root = root[PluginScopeParentScope]; + } + if (prevValue !== nextValue) { + root[PluginScopeEmitChange](); + } + return nextValue; + }; + this[PluginScopeSyncData].set(wrappedFn, undefined); + return () => this[PluginScopeSyncData].get(wrappedFn) ?? defaultValue; + } + public children>(plugin: U): PluginScope>> { const scope = new PluginScope(this, { data: [], diff --git a/packages/web/package.json b/packages/web/package.json index 51304e168..941f6f8d3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -54,12 +54,13 @@ "@sentry/integrations": "^7.64.0", "@sentry/react": "^7.64.0", "@testkube/plugins": "*", - "ansi-to-react": "^6.1.6", + "anser": "^2.1.1", "antd": "^4.24.12", "axios": "0.27.2", "classnames": "2.3.1", "cron-parser": "^4.8.1", "date-fns": "^2.28.0", + "escape-carriage": "^1.3.1", "file-saver": "^2.0.5", "framer-motion": "^4.1.17", "lodash.debounce": "^4.0.8", diff --git a/packages/web/public/index.html b/packages/web/public/index.html index 2e61be822..d1b865cd9 100644 --- a/packages/web/public/index.html +++ b/packages/web/public/index.html @@ -14,7 +14,7 @@ diff --git a/packages/web/src/AppRoot.tsx b/packages/web/src/AppRoot.tsx index 6484a0535..64fd06716 100644 --- a/packages/web/src/AppRoot.tsx +++ b/packages/web/src/AppRoot.tsx @@ -48,7 +48,7 @@ const AppRoot: React.FC = () => { () => [ ...basePlugins, ClusterStatusPlugin, - ConfigPlugin.configure({discordUrl: externalLinks.discord}), + ConfigPlugin.configure({slackUrl: externalLinks.slack}), RouterPlugin.configure({baseUrl: env.basename || ''}), PermissionsPlugin.configure({resolver: new BasePermissionsResolver()}), RtkResetOnApiChangePlugin, diff --git a/packages/web/src/antd-theme/my-antd-theme.less b/packages/web/src/antd-theme/my-antd-theme.less index 829b74fba..3d48b3c86 100644 --- a/packages/web/src/antd-theme/my-antd-theme.less +++ b/packages/web/src/antd-theme/my-antd-theme.less @@ -10,7 +10,7 @@ @layout-body-background: #111827; @layout-header-background: #111827; @font-family: 'Roboto', sans-serif; -@code-family: 'Roboto Mono', sans-serif; +@code-family: 'IBM Plex Mono', monospace; // button @btn-height-base: 40px; @btn-default-bg: rgba(255, 255, 255, 0.05); diff --git a/packages/web/src/assets/images/status-pages-mock-1.svg b/packages/web/src/assets/images/status-pages-mock-1.svg index dc5eef7f4..180e7fa4f 100644 --- a/packages/web/src/assets/images/status-pages-mock-1.svg +++ b/packages/web/src/assets/images/status-pages-mock-1.svg @@ -1,95 +1,111 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/web/src/components/atoms/ExecutorIcon/ExecutorIcon.styled.tsx b/packages/web/src/components/atoms/ExecutorIcon/ExecutorIcon.styled.tsx index 3fa68e13a..faf8fad6d 100644 --- a/packages/web/src/components/atoms/ExecutorIcon/ExecutorIcon.styled.tsx +++ b/packages/web/src/components/atoms/ExecutorIcon/ExecutorIcon.styled.tsx @@ -1,8 +1,8 @@ import styled from 'styled-components'; -export const StyledExecutorIcon = styled.div<{$noWidth: boolean}>` - width: ${({$noWidth}) => ($noWidth ? 'unset' : '28px')}; - height: ${({$noWidth}) => ($noWidth ? 'unset' : '28px')}; +export const StyledExecutorIcon = styled.div<{$size: 'large' | 'small'}>` + width: ${({$size}) => ($size === 'large' ? '28px' : '16px')}; + height: ${({$size}) => ($size === 'large' ? '28px' : '16px')}; svg { width: 100%; diff --git a/packages/web/src/components/atoms/ExecutorIcon/ExecutorIcon.tsx b/packages/web/src/components/atoms/ExecutorIcon/ExecutorIcon.tsx index 3f6ed97af..b717657a1 100644 --- a/packages/web/src/components/atoms/ExecutorIcon/ExecutorIcon.tsx +++ b/packages/web/src/components/atoms/ExecutorIcon/ExecutorIcon.tsx @@ -18,8 +18,8 @@ import {ReactComponent as TracetestIcon} from '@assets/tracetestIcon.svg'; import {StyledExecutorIcon} from './ExecutorIcon.styled'; type ExecutorIconProps = { + size?: 'large' | 'small'; type?: string; - noWidth?: boolean; }; export const executorIcons: Record = { @@ -39,7 +39,7 @@ export const executorIcons: Record = { }; const ExecutorIcon: React.FC = props => { - const {type, noWidth = false} = props; + const {size = 'large', type} = props; const icon = type ? executorIcons[type] : ; @@ -47,7 +47,7 @@ const ExecutorIcon: React.FC = props => { return ; } return ( - + {icon || } ); diff --git a/packages/web/src/components/atoms/Icon/Icon.tsx b/packages/web/src/components/atoms/Icon/Icon.tsx index 4453b23ac..bfed9f834 100644 --- a/packages/web/src/components/atoms/Icon/Icon.tsx +++ b/packages/web/src/components/atoms/Icon/Icon.tsx @@ -10,7 +10,7 @@ import {IconProps} from './types'; const { CogIcon, DocumentationIcon, - DiscordIcon, + SlackIcon, GitHubIcon, PassedStatusIcon, FailedStatusIcon, @@ -25,7 +25,7 @@ const { const iconsMap: Record = { cog: CogIcon, documentation: DocumentationIcon, - discord: DiscordIcon, + slack: SlackIcon, github: GitHubIcon, passed: PassedStatusIcon, failed: FailedStatusIcon, diff --git a/packages/web/src/components/atoms/Icon/icons.tsx b/packages/web/src/components/atoms/Icon/icons.tsx index 7553fab03..a0cb203eb 100644 --- a/packages/web/src/components/atoms/Icon/icons.tsx +++ b/packages/web/src/components/atoms/Icon/icons.tsx @@ -41,10 +41,17 @@ const GitHubIcon: React.FC = () => { ); }; -const DiscordIcon: React.FC = () => { +const SlackIcon: React.FC = () => { return ( - - + + + + + + + + + ); }; @@ -114,7 +121,7 @@ export default { CogIcon, DocumentationIcon, GitHubIcon, - DiscordIcon, + SlackIcon, FailedStatusIcon, PassedStatusIcon, RunningStatusIcon, diff --git a/packages/web/src/components/atoms/Icon/types.ts b/packages/web/src/components/atoms/Icon/types.ts index 9a706a6b1..721cf304d 100644 --- a/packages/web/src/components/atoms/Icon/types.ts +++ b/packages/web/src/components/atoms/Icon/types.ts @@ -4,7 +4,7 @@ export type IconProps = { name: | 'cog' | 'documentation' - | 'discord' + | 'slack' | 'github' | 'passed' | 'failed' diff --git a/packages/web/src/components/atoms/InstallationInfoItem/InstallationInfoItem.styled.tsx b/packages/web/src/components/atoms/InstallationInfoItem/InstallationInfoItem.styled.tsx new file mode 100644 index 000000000..130375a9c --- /dev/null +++ b/packages/web/src/components/atoms/InstallationInfoItem/InstallationInfoItem.styled.tsx @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + gap: 10px; +`; diff --git a/packages/web/src/components/pages/GlobalSettings/General/YourInstallation/YourInstallationItem.tsx b/packages/web/src/components/atoms/InstallationInfoItem/InstallationInfoItem.tsx similarity index 53% rename from packages/web/src/components/pages/GlobalSettings/General/YourInstallation/YourInstallationItem.tsx rename to packages/web/src/components/atoms/InstallationInfoItem/InstallationInfoItem.tsx index a546a8906..252228818 100644 --- a/packages/web/src/components/pages/GlobalSettings/General/YourInstallation/YourInstallationItem.tsx +++ b/packages/web/src/components/atoms/InstallationInfoItem/InstallationInfoItem.tsx @@ -2,23 +2,25 @@ import {Text} from '@custom-antd'; import Colors from '@styles/Colors'; -import {YourInstallationItemContainer} from './YourInstallation.styled'; +import * as S from './InstallationInfoItem.styled'; -type YourInstallationItemProps = { +type InstallationInfoItemProps = { label: string; value?: string; }; -const YourInstallationItem: React.FC = props => { +const InstallationInfoItem: React.FC = props => { const {label, value} = props; + return ( - + {label} + {value} - + ); }; -export default YourInstallationItem; +export default InstallationInfoItem; diff --git a/packages/web/src/components/atoms/InstallationInfoItem/index.ts b/packages/web/src/components/atoms/InstallationInfoItem/index.ts new file mode 100644 index 000000000..77203b057 --- /dev/null +++ b/packages/web/src/components/atoms/InstallationInfoItem/index.ts @@ -0,0 +1 @@ +export {default} from './InstallationInfoItem'; diff --git a/packages/web/src/components/atoms/LabelListItem/LabelListItem.styled.ts b/packages/web/src/components/atoms/LabelListItem/LabelListItem.styled.ts index a84056e34..9d5a64d4b 100644 --- a/packages/web/src/components/atoms/LabelListItem/LabelListItem.styled.ts +++ b/packages/web/src/components/atoms/LabelListItem/LabelListItem.styled.ts @@ -2,19 +2,17 @@ import styled from 'styled-components'; import Colors from '@styles/Colors'; -export const StyledLabelListItem = styled.li<{isSkippedMode: boolean}>` +export const StyledLabelListItem = styled.li<{$type: 'primary' | 'secondary'}>` display: flex; align-items: center; padding: 0 5px; - border: 1px solid ${Colors.slate700}; + border: ${({$type}) => ($type === 'primary' ? `1px solid ${Colors.slate700}` : 'none')}; border-radius: 4px; - background: transparent; - + background-color: ${({$type}) => ($type === 'primary' ? 'transparent' : Colors.slate800)}; font-size: 12px; font-weight: 400; - text-wrap: nowrap; max-width: 50vw; `; diff --git a/packages/web/src/components/atoms/LabelListItem/LabelListItem.tsx b/packages/web/src/components/atoms/LabelListItem/LabelListItem.tsx index b5a7c155b..2145a806e 100644 --- a/packages/web/src/components/atoms/LabelListItem/LabelListItem.tsx +++ b/packages/web/src/components/atoms/LabelListItem/LabelListItem.tsx @@ -9,16 +9,17 @@ type LabelListItemProps = { labelValue?: EntityValue; isSkippedMode?: boolean; skippedLabelsNumber?: number; + type?: 'primary' | 'secondary'; }; const LabelListItem: React.FC = props => { - const {labelKey = '', labelValue = '', isSkippedMode = false, skippedLabelsNumber = 0} = props; + const {labelKey = '', labelValue = '', isSkippedMode = false, skippedLabelsNumber = 0, type = 'primary'} = props; const value = isSkippedMode ? `+${skippedLabelsNumber} more` : labelValue ? `${labelKey}: ${labelValue}` : labelKey; return ( - - + + ); }; diff --git a/packages/web/src/components/atoms/MonacoEditor/MonacoEditor.tsx b/packages/web/src/components/atoms/MonacoEditor/MonacoEditor.tsx index fd25b813a..4be09ef5a 100644 --- a/packages/web/src/components/atoms/MonacoEditor/MonacoEditor.tsx +++ b/packages/web/src/components/atoms/MonacoEditor/MonacoEditor.tsx @@ -21,7 +21,7 @@ export const lineHeight = 22; const options = { contextmenu: true, - fontFamily: 'Roboto Mono, Monaco, monospace', + fontFamily: '"IBM Plex Mono", Monaco, monospace', fontSize: 13, lineHeight, minimap: { diff --git a/packages/web/src/components/atoms/SplitLabelText/SplitLabelText.tsx b/packages/web/src/components/atoms/SplitLabelText/SplitLabelText.tsx index f9c003629..eef1e5999 100644 --- a/packages/web/src/components/atoms/SplitLabelText/SplitLabelText.tsx +++ b/packages/web/src/components/atoms/SplitLabelText/SplitLabelText.tsx @@ -12,10 +12,11 @@ type SplitLabelProps = { value: string; textClassName?: string; disabled?: boolean; + type?: 'primary' | 'secondary'; }; const SplitLabelText: React.FC = props => { - const {value, textClassName = 'regular', disabled = false} = props; + const {value, textClassName = 'regular', disabled = false, type = 'primary'} = props; if (!labelRegex.test(value)) { return ( @@ -32,7 +33,11 @@ const SplitLabelText: React.FC = props => { {key}:{' '} - + {rest.join(':')} diff --git a/packages/web/src/components/atoms/StatusIcon/StatusIcon.styled.tsx b/packages/web/src/components/atoms/StatusIcon/StatusIcon.styled.tsx index 6b5f52844..6930a5ce0 100644 --- a/packages/web/src/components/atoms/StatusIcon/StatusIcon.styled.tsx +++ b/packages/web/src/components/atoms/StatusIcon/StatusIcon.styled.tsx @@ -1,18 +1,18 @@ import styled from 'styled-components'; -export const StyledStatusIcon = styled.div` +export const StyledStatusIcon = styled.div<{$size: 'small' | 'large'}>` display: flex; justify-content: center; align-items: center; - height: 28px; - width: 28px; + height: ${({$size}) => ($size === 'large' ? '28px' : '20px')}; + width: ${({$size}) => ($size === 'large' ? '28px' : '20px')}; border: 1px solid; border-radius: 4px; span { - width: 20px; - height: 20px; + width: ${({$size}) => ($size === 'large' ? '20px' : '12px')}; + height: ${({$size}) => ($size === 'large' ? '20px' : '12px')}; } `; diff --git a/packages/web/src/components/atoms/StatusIcon/StatusIcon.tsx b/packages/web/src/components/atoms/StatusIcon/StatusIcon.tsx index 73b3ffb62..38e59870e 100644 --- a/packages/web/src/components/atoms/StatusIcon/StatusIcon.tsx +++ b/packages/web/src/components/atoms/StatusIcon/StatusIcon.tsx @@ -93,13 +93,14 @@ const iconStyles: Record = { type StatusIconProps = { status: IconProps['name']; + size?: 'small' | 'large'; }; const StatusIcon: React.FC = props => { - const {status} = props; + const {status, size = 'large'} = props; return ( - + ); diff --git a/packages/web/src/components/atoms/index.ts b/packages/web/src/components/atoms/index.ts index 385255c83..9ebd4e221 100644 --- a/packages/web/src/components/atoms/index.ts +++ b/packages/web/src/components/atoms/index.ts @@ -18,3 +18,4 @@ export {default as CommandInput} from './CommandInput'; export {default as MonacoEditor} from './MonacoEditor'; export {default as IconLabel} from './IconLabel'; export {default as Tag} from './Tag'; +export {default as InstallationInfoItem} from './InstallationInfoItem'; diff --git a/packages/web/src/components/custom-antd/Button/Button.styled.tsx b/packages/web/src/components/custom-antd/Button/Button.styled.tsx index f9cac36b3..627ffaf4c 100644 --- a/packages/web/src/components/custom-antd/Button/Button.styled.tsx +++ b/packages/web/src/components/custom-antd/Button/Button.styled.tsx @@ -7,6 +7,7 @@ import Colors from '@styles/Colors'; export interface ICustomButtonProps extends AntdButtonProps { $customType?: 'primary' | 'secondary' | 'tertiary' | 'transparent' | 'warning' | 'github' | 'gitlab'; + $fullWidth?: boolean; hidden?: boolean; $withPadding?: boolean; tooltip?: string; @@ -102,11 +103,15 @@ const buttonTypesStyles: Record = { export const AntdCustomStyledButton = styled(AntdButton)` ${props => buttonTypesStyles[props.$customType || 'primary']}; - ${props => - !props.$withPadding - ? ` - padding: 0; - height: unset; - ` - : ''}; + + ${({$withPadding}) => { + if (!$withPadding) { + return ` + padding: 0; + height: unset; + `; + } + }} + + width: ${({$fullWidth}) => ($fullWidth ? '100%' : 'auto')}; `; diff --git a/packages/web/src/components/molecules/ArtifactsList/ArtifactsList.styled.tsx b/packages/web/src/components/molecules/ArtifactsList/ArtifactsList.styled.tsx index 1533b8eac..08e44f53c 100644 --- a/packages/web/src/components/molecules/ArtifactsList/ArtifactsList.styled.tsx +++ b/packages/web/src/components/molecules/ArtifactsList/ArtifactsList.styled.tsx @@ -13,3 +13,11 @@ export const ArtifactsListContainer = styled.ul` list-style-type: none; `; + +export const ProcessingContainer = styled.div` + margin: 16px 0 16px 12px; + display: flex; + gap: 16px; + align-items: center; + font-size: 12px; +`; diff --git a/packages/web/src/components/molecules/ArtifactsList/ArtifactsList.tsx b/packages/web/src/components/molecules/ArtifactsList/ArtifactsList.tsx index 0d35f8820..5a2d55d31 100644 --- a/packages/web/src/components/molecules/ArtifactsList/ArtifactsList.tsx +++ b/packages/web/src/components/molecules/ArtifactsList/ArtifactsList.tsx @@ -1,5 +1,7 @@ import {useMemo, useState} from 'react'; +import {LoadingOutlined} from '@ant-design/icons'; + import {Button, Skeleton, Text} from '@custom-antd'; import {Artifact} from '@models/artifact'; @@ -10,7 +12,7 @@ import Colors from '@styles/Colors'; import {DefaultRequestError, displayDefaultErrorNotification} from '@utils/notification'; -import {ArtifactsListContainer} from './ArtifactsList.styled'; +import * as S from './ArtifactsList.styled'; import ArtifactsListItem from './ArtifactsListItem'; import {StyledDownloadAllContainer} from './ArtifactsListItem.styled'; @@ -46,22 +48,37 @@ const ArtifactsList: React.FC = props => { ); } - return artifacts.map((artifact, index) => { - const {name} = artifact; - - const listItemKey = `${name} - ${index}`; - - return ( - - ); - }); - }, [artifacts, testExecutionId, isLoading]); + const processingArtifacts = artifacts.filter(artifact => artifact.status === 'processing'); + + return ( + <> + {processingArtifacts.length > 0 && ( + + + We are currently processing your artifacts... + + )} + + {artifacts + .filter(artifact => artifact.status === 'ready') + .map((artifact, index) => { + const {name} = artifact; + + const listItemKey = `${name} - ${index}`; + + return ( + + ); + })} + + ); + }, [isLoading, artifacts, testExecutionId, testName, testSuiteName]); const handleDownloadAll = async () => { try { @@ -75,14 +92,14 @@ const ArtifactsList: React.FC = props => { }; return ( - + {artifacts.length > 1 ? ( ) : null} {renderedArtifactsList} - + ); }; diff --git a/packages/web/src/components/molecules/ArtifactsList/ArtifactsListItem.spec.tsx b/packages/web/src/components/molecules/ArtifactsList/ArtifactsListItem.spec.tsx index 9b0688f97..a6e3f46c4 100644 --- a/packages/web/src/components/molecules/ArtifactsList/ArtifactsListItem.spec.tsx +++ b/packages/web/src/components/molecules/ArtifactsList/ArtifactsListItem.spec.tsx @@ -1,14 +1,15 @@ -import React from 'react'; - import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {Artifact} from '@models/artifact'; + import ArtifactsListItem from './ArtifactsListItem'; describe('ArtifactsListItem', () => { - const artifact = { + const artifact: Artifact = { description: 'artifact-description', name: 'artifact-name', size: 123, + status: 'ready', }; const executionId = '123'; const testName = 'test-name'; diff --git a/packages/web/src/components/molecules/ConfigurationCard/ConfigurationCard.tsx b/packages/web/src/components/molecules/ConfigurationCard/ConfigurationCard.tsx index fd163c39c..8ec3e56c2 100644 --- a/packages/web/src/components/molecules/ConfigurationCard/ConfigurationCard.tsx +++ b/packages/web/src/components/molecules/ConfigurationCard/ConfigurationCard.tsx @@ -33,6 +33,7 @@ type ConfigurationCardProps = { footer?: ReactNode; headerAction?: ReactNode; confirmLabel?: string; + confirmDisabled?: boolean; wasTouched?: boolean; children?: ReactNode; readOnly?: boolean; @@ -56,6 +57,7 @@ const ConfigurationCard: React.FC = props => { isWarning = false, readOnly = false, loading = false, + confirmDisabled = false, } = props; const topRef = useRef(null); const inTopInViewport = useInViewport(topRef); @@ -114,7 +116,7 @@ const ConfigurationCard: React.FC = props => { without nesting + // - there are no overlapping highlights + Children.map(children, element => { + if (typeof element === 'string' || typeof element === 'number') { + rawText += element; + } else if (element && typeof element === 'object' && 'props' in element) { + const start = rawText.length; + const elementText = element.props.children; + for (let i = start; i < start + elementText.length; i += 1) { + propsMap[i] = element.props; + } + rawText += elementText; + } + }); + + return {text: rawText, props: propsMap}; + }, [children]); + + const highlightsMap = useMemo(() => { + if (!highlights?.length) { + return {}; + } + // Build map of highlights + const map: Record = {}; + for (let i = 0; i < highlights.length; i += 1) { + const start = highlights[i].start; + for (let j = start; j < highlights[i].end; j += 1) { + map[j] = true; + } + } + return map; + }, [highlights]); + + return useMemo(() => { + if (!highlights?.length) { + return {children}; + } + + // Wrap the text to elements again + const nextElements: ReactNode[] = []; + let lastIndex = 0; + let lastProps: any; + let lastHighlight = false; + for (let i = 0; i < text.length; i += 1) { + if (Boolean(highlightsMap[i]) !== lastHighlight || lastProps !== props[i]) { + if (i !== lastIndex) { + nextElements.push( + createElement( + lastHighlight ? Keyword : 'span', + {...(lastProps || {}), key: nextElements.length}, + text.substring(lastIndex, i) + ) + ); + } + lastIndex = i; + lastHighlight = Boolean(highlightsMap[i]); + lastProps = props[i]; + } + } + + // FIXME Conditionally? + nextElements.push( + createElement( + lastHighlight ? Keyword : 'span', + {...(lastProps || {}), key: nextElements.length}, + text.substring(lastIndex) + ) + ); + + return <>{nextElements}; + }, [highlightsMap, props, text]); +}; diff --git a/packages/web/src/components/molecules/Console/LogProcessor.ts b/packages/web/src/components/molecules/Console/LogProcessor.ts new file mode 100644 index 000000000..daed39649 --- /dev/null +++ b/packages/web/src/components/molecules/Console/LogProcessor.ts @@ -0,0 +1,81 @@ +import {LogProcessorLine} from './LogProcessorLine'; +import {ANSI_MARKER} from './utils'; + +export class LogProcessor { + private activeLine: LogProcessorLine | undefined = undefined; + public lines: LogProcessorLine[] = []; + public maxDigits = 0; + + public append(log: string, start = 0, end = log.length): void { + let startLine = start; + let noAsciiDecode = true; + for (let i = start; i < end; i += 1) { + if (log[i] === '\n') { + if (this.activeLine) { + this.activeLine.append(log, startLine, i, noAsciiDecode); + this.activeLine = undefined; + } else { + this.lines.push(new LogProcessorLine(log, startLine, i, noAsciiDecode)); + } + startLine = i + 1; + noAsciiDecode = true; + } else if (log[i] === ANSI_MARKER) { + noAsciiDecode = false; + } + } + + if (startLine < end) { + this.activeLine = new LogProcessorLine(log, startLine, end, noAsciiDecode); + this.lines.push(this.activeLine); + } + + // eslint-disable-next-line no-bitwise + this.maxDigits = (Math.log(this.lines.length + 1) * Math.LOG10E + 1) | 0; + } + + public getMaxLineLength(): number { + return this.lines.reduce((max, line) => (max > line.chars ? max : line.chars), 0); + } + + public getProcessedLines(start = 0, end = this.lines.length): LogProcessorLine[] { + this.process(start, end); + return this.lines.slice(start, end); + } + + public process(start = 0, end = this.lines.length): void { + if (start < 0) { + start = 0; + } + if (end > this.lines.length) { + end = this.lines.length; + } + + // Process each line + for (let i = start; i < end; i += 1) { + const line = this.lines[i]; + line.process(this.lines[i - 1]?.lastNodeClassName); + } + + // Apply the prevClassName to further lines + let prevClassName = this.lines[end - 1]?.lastNodeClassName; + if (prevClassName !== undefined) { + for (let i = end; i < this.lines.length; i += 1) { + const line = this.lines[i]; + if (!line.processed || line.nodes[0]?.props.className !== undefined) { + break; + } + line.process(prevClassName); + prevClassName = line.lastNodeClassName; + } + } + } + + public static from(content: string, preprocess = false): LogProcessor { + const processor = new LogProcessor(); + processor.append(content); + if (preprocess) { + processor.process(); + } + return processor; + } +} diff --git a/packages/web/src/components/molecules/Console/LogProcessorLine.ts b/packages/web/src/components/molecules/Console/LogProcessorLine.ts new file mode 100644 index 000000000..d54d85cf9 --- /dev/null +++ b/packages/web/src/components/molecules/Console/LogProcessorLine.ts @@ -0,0 +1,113 @@ +import {ReactElement, createElement} from 'react'; + +import Anser, {AnserJsonEntry} from 'anser'; + +import {ANSI_MARKER, countCharacters} from './utils'; + +const anser = new Anser(); +const anserOptions = {json: true, remove_empty: true, use_classes: true}; +function buildClassName(bundle: AnserJsonEntry): string | undefined { + let className: string = ''; + if (bundle.bg) { + className += `${bundle.bg}-bg `; + } + if (bundle.fg) { + className += `${bundle.fg}-fg `; + } + if (bundle.decoration) { + className += `ansi-${bundle.decoration} `; + } + return className ? className.substring(0, className.length - 1) : undefined; +} + +export class LogProcessorLine { + private source: string; + private start: number; + private end: number; + private noAsciiDecode: boolean; + public processed = false; + public chars: number; + public nodes: ReactElement[] = []; + public lastNodeClassName: string | undefined = undefined; + + public constructor(source: string, start: number, end: number, noAsciiDecode = false) { + this.source = source; + this.start = start; + this.end = end; + this.noAsciiDecode = noAsciiDecode; + this.chars = noAsciiDecode ? end - start : countCharacters(source, start, end); + } + + public append(content: string, start = 0, end = content.length, noAsciiDecode = false): void { + this.source = this.source.substring(this.start, this.end) + content.substring(start, end); + this.start = 0; + this.end = this.source.length; + // TODO: Process next chunks only + this.noAsciiDecode = this.noAsciiDecode && noAsciiDecode; + if (this.processed) { + this.processed = false; + this.nodes = []; + this.process(this.nodes[0]?.props.className); + } else { + this.chars += noAsciiDecode ? end - start : countCharacters(content, start, end); + } + } + + // TODO: Optimize for cases where consecutive characters have same color codes + private addNode(content: string, className?: string): void { + this.nodes.push(createElement('span', {key: this.nodes.length, className}, content)); + this.lastNodeClassName = className; + } + + private processNextAsciiChunk(start: number, end: number): void { + const match = anser.processChunkJson(this.source.substring(start, end), anserOptions, true); + this.addNode(match.content, buildClassName(match)); + } + + private processNextRawChunk(start: number, end: number): void { + this.addNode(this.source.substring(start, end), this.lastNodeClassName); + } + + private processNextChunk(start: number, end: number, ascii = false): void { + if (start >= end) { + return; + } + if (ascii) { + this.processNextAsciiChunk(start, end); + } else { + this.processNextRawChunk(start, end); + } + } + + public process(prevClassName?: string): void { + // Avoid processing twice + if (this.processed) { + return; + } + this.processed = true; + + // Preserve the known class name + this.lastNodeClassName = prevClassName; + + // Fast-track when we know there is no ASCII + if (this.noAsciiDecode) { + this.processNextRawChunk(this.start, this.end); + return; + } + + // Parse the content for the ASCII codes + let lastIndex = this.start; + let isEscaping = false; + for (let i = this.start; i < this.end; i += 1) { + if (this.source[i] === ANSI_MARKER && this.source[i + 1] === '[') { + this.processNextChunk(lastIndex, i, isEscaping); + isEscaping = true; + i += 1; + lastIndex = i + 1; + } + } + + // Handle the last chunk + this.processNextChunk(lastIndex, this.end, isEscaping); + } +} diff --git a/packages/web/src/components/molecules/Console/useLogLinesPosition.ts b/packages/web/src/components/molecules/Console/useLogLinesPosition.ts new file mode 100644 index 000000000..d4de11b07 --- /dev/null +++ b/packages/web/src/components/molecules/Console/useLogLinesPosition.ts @@ -0,0 +1,99 @@ +import {useMemo, useRef} from 'react'; + +import {useLastCallback} from '@hooks/useLastCallback'; + +import {LogProcessor} from './LogProcessor'; + +interface VisualLine { + index: number; + start: number; + end: number; +} + +interface LogLinesPosition { + getTop: (lineNumber: number) => number; + getSize: (lineNumber: number) => number; + getVisualLine: (visualLineNumber: number) => VisualLine; + total: number; +} + +export const useLogLinesPosition = (processor: LogProcessor, maxCharacters = Infinity): LogLinesPosition => { + // Access basic data + const lines = processor.lines; + const isWrapped = maxCharacters !== Infinity && maxCharacters !== 0; + + // Store positions + const cachedPositionsRef = useRef([]); + const cachedSizesRef = useRef([]); + + // Recalculate positions + const cachedMaxCharacters = useRef(maxCharacters); + if (isWrapped) { + cachedMaxCharacters.current = maxCharacters; + } + useMemo(() => { + if (isWrapped) { + if (cachedPositionsRef.current.length !== lines.length) { + cachedPositionsRef.current = new Array(lines.length); + cachedSizesRef.current = new Array(lines.length); + } + const cachedPositions = cachedPositionsRef.current; + const cachedSizes = cachedSizesRef.current; + let top = 0; + for (let i = 0; i < lines.length; i += 1) { + const chars = lines[i].chars; + const height = chars === 0 ? 1 : Math.ceil(chars / maxCharacters); + cachedSizes[i] = height; + cachedPositions[i] = top; + top += height; + } + } + }, [cachedMaxCharacters.current, processor]); + + const getTop = (line: number): number => { + if (line <= 0) { + return 0; + } + if (line > lines.length) { + line = lines.length; + } + return isWrapped ? cachedPositionsRef.current[line - 1] : line; + }; + const getSize = (line: number) => { + if (line <= 0 || line > lines.length) { + return 0; + } + return isWrapped ? cachedSizesRef.current[line - 1] : 1; + }; + const getVisualLine = (visualLine: number) => { + if (visualLine < 1) { + visualLine = 1; + } + if (!isWrapped) { + return {index: visualLine - 1, start: visualLine - 1, end: visualLine}; + } + let index = visualLine - 1; + for (; index >= 0; index -= 1) { + const top = cachedPositionsRef.current[index]; + if (top <= visualLine) { + return { + index, + start: cachedPositionsRef.current[index], + end: cachedPositionsRef.current[index] + cachedSizesRef.current[index], + }; + } + } + return { + index: 0, + start: cachedPositionsRef.current[0], + end: cachedPositionsRef.current[0] + cachedSizesRef.current[0], + }; + }; + + return { + getTop: useLastCallback(getTop), + getSize: useLastCallback(getSize), + getVisualLine: useLastCallback(getVisualLine), + total: getTop(lines.length) + getSize(lines.length), + }; +}; diff --git a/packages/web/src/components/molecules/Console/utils.ts b/packages/web/src/components/molecules/Console/utils.ts new file mode 100644 index 000000000..90c31de43 --- /dev/null +++ b/packages/web/src/components/molecules/Console/utils.ts @@ -0,0 +1,19 @@ +export const ANSI_MARKER = String.fromCharCode(0x1b); + +const ansiRegex = /([!\x3c-\x3f]*)([\d;]*)([\x20-\x2c]*[\x40-\x7e])/g; +export const countCharacters = (content: string, start: number, end: number): number => { + let codes = 0; + for (let i = start; i < end; i += 1) { + if (content[i] === ANSI_MARKER) { + i += 2; + ansiRegex.lastIndex = i; + const match = ansiRegex.exec(content); + if (match !== null && match.index === i) { + const len = match[0].length; + codes += len + 2; + i += len - 1; + } + } + } + return end - start - codes; +}; diff --git a/packages/web/src/components/molecules/DotsDropdown/DotsDropdown.tsx b/packages/web/src/components/molecules/DotsDropdown/DotsDropdown.tsx index b22a81d79..1a08c3072 100644 --- a/packages/web/src/components/molecules/DotsDropdown/DotsDropdown.tsx +++ b/packages/web/src/components/molecules/DotsDropdown/DotsDropdown.tsx @@ -24,6 +24,8 @@ interface DotsDropdownProps { trigger?: ('click' | 'hover' | 'contextMenu')[]; overlayClassName?: string; disabled?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; } const DotsDropdown: React.FC = ({ @@ -34,6 +36,8 @@ const DotsDropdown: React.FC = ({ trigger = ['click'], overlayClassName = 'light-dropdown', disabled, + open, + onOpenChange, }) => { return ( = ({ }} placement={placement} disabled={disabled} + open={open} + onOpenChange={onOpenChange} > event.stopPropagation()}> diff --git a/packages/web/src/components/molecules/EmptyListContent/EmptyListContent.tsx b/packages/web/src/components/molecules/EmptyListContent/EmptyListContent.tsx index 068ff34a3..f7cb86597 100644 --- a/packages/web/src/components/molecules/EmptyListContent/EmptyListContent.tsx +++ b/packages/web/src/components/molecules/EmptyListContent/EmptyListContent.tsx @@ -46,7 +46,7 @@ const EmptyListContent: React.FC> = pro isReadOnly, } = props; - const discordUrl = useConfigPlugin.select(x => x.discordUrl); + const slackUrl = useConfigPlugin.select(x => x.slackUrl); const isActionAvailable = usePermission(actionTypeToPermission[actionType]); return ( @@ -66,9 +66,9 @@ const EmptyListContent: React.FC> = pro {children} - + Need help getting started? Want to talk to Testkube engineers?{' '} - Find us on Discord + Find us on Slack diff --git a/packages/web/src/components/molecules/EntityGrid/EntityGrid.tsx b/packages/web/src/components/molecules/EntityGrid/EntityGrid.tsx index 7b823b2b0..b26e2a0c0 100644 --- a/packages/web/src/components/molecules/EntityGrid/EntityGrid.tsx +++ b/packages/web/src/components/molecules/EntityGrid/EntityGrid.tsx @@ -53,7 +53,7 @@ function EntityGridBase(props: EntityGridProps): React const itemProps = {...componentProps, item} as T; return ; }), - [data] + [Component, componentProps, data, itemKey] ); if (loadingInitially) { diff --git a/packages/web/src/components/molecules/EntityGrid/EntityGridItemPure.tsx b/packages/web/src/components/molecules/EntityGrid/EntityGridItemPure.tsx index 7bfb217ca..339ace68c 100644 --- a/packages/web/src/components/molecules/EntityGrid/EntityGridItemPure.tsx +++ b/packages/web/src/components/molecules/EntityGrid/EntityGridItemPure.tsx @@ -1,4 +1,4 @@ -import React, {FC, forwardRef, memo, useCallback} from 'react'; +import {FC, forwardRef, memo, useCallback} from 'react'; import {ExecutorIcon, StatusIcon, Tag} from '@atoms'; @@ -6,11 +6,12 @@ import {Text} from '@custom-antd'; import useExecutorIcon from '@hooks/useExecutorIcon'; +import {ActionsDropdownProps} from '@models/actionsDropdown'; import type {Execution} from '@models/execution'; import type {ExecutionMetrics} from '@models/metrics'; import type {TestSuiteExecution} from '@models/testSuiteExecution'; -import {DotsDropdown, LabelsList, MetricsBarChart} from '@molecules'; +import {LabelsList, MetricsBarChart} from '@molecules'; import Colors from '@styles/Colors'; @@ -31,6 +32,7 @@ import EntityGridItemExecutionTime from './EntityGridItemExecutionTime'; export interface Item { type?: string; name: string; + namespace?: string; labels?: Record; } @@ -46,11 +48,11 @@ interface EntityGridItemPureProps { latestExecution?: TestSuiteExecution | Execution; metrics?: Metrics; onClick: (item: Item) => void; - onAbort: (item: Item) => void; dataTest: string; outOfSync?: boolean; isAgentAvailable?: boolean; entityLabel: string; + DropdownComponent: React.FC; } const EntityGridItemTestIcon: FC<{item: Item}> = memo(({item}) => { @@ -58,30 +60,29 @@ const EntityGridItemTestIcon: FC<{item: Item}> = memo(({item}) => { return item.type ? : null; }); -const isRunningStatus = (status: string) => ['running', 'queued'].includes(status); - const EntityGridItemPure = forwardRef((props, ref) => { - const {item, latestExecution, onClick, onAbort, dataTest, metrics, outOfSync, isAgentAvailable, entityLabel} = props; + const { + item, + latestExecution, + onClick, + dataTest, + metrics, + outOfSync, + isAgentAvailable, + entityLabel, + DropdownComponent, + } = props; const status = (latestExecution as Execution)?.executionResult?.status || (latestExecution as TestSuiteExecution)?.status || 'pending'; const executions = metrics?.executions; - const isRunning = isRunningStatus(status) || executions?.some(execution => isRunningStatus(execution.status)); const click = useCallback(() => { onClick(item); }, [onClick, item]); - const abort = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - onAbort(item); - }, - [onAbort, item] - ); - return ( @@ -108,13 +109,7 @@ const EntityGridItemPure = forwardRef(( - {isRunning ? ( - Abort all executions}]} - /> - ) : null} + diff --git a/packages/web/src/components/molecules/FilterMenu/MenuFilter.styled.tsx b/packages/web/src/components/molecules/FilterMenu/MenuFilter.styled.tsx index 2537c7162..dd4d340e9 100644 --- a/packages/web/src/components/molecules/FilterMenu/MenuFilter.styled.tsx +++ b/packages/web/src/components/molecules/FilterMenu/MenuFilter.styled.tsx @@ -61,7 +61,7 @@ export const AppliedFiltersNotification = styled.div` background-color: ${Colors.purple}; `; -export const StyledFilterLabel = styled.div<{isFiltersDisabled: boolean}>` +export const StyledFilterLabel = styled.div<{$disabled: boolean}>` position: relative; display: flex; justify-content: space-between; @@ -71,14 +71,14 @@ export const StyledFilterLabel = styled.div<{isFiltersDisabled: boolean}>` border-radius: 4px; padding: 11px; - background-color: ${props => (props.isFiltersDisabled ? Colors.slate800disabled : Colors.slate800)}; + background-color: ${props => (props.$disabled ? Colors.slate800disabled : Colors.slate800)}; color: ${Colors.slate500}; - cursor: ${props => (props.isFiltersDisabled ? 'not-allowed' : 'pointer')}; + cursor: ${props => (props.$disabled ? 'not-allowed' : 'pointer')}; transition: 0.3s ease; ${props => - props.isFiltersDisabled + props.$disabled ? '' : ` &:hover { diff --git a/packages/web/src/components/molecules/HelpCard/HelpCard.styled.tsx b/packages/web/src/components/molecules/HelpCard/HelpCard.styled.tsx index 7bbf0b7b0..9540a8354 100644 --- a/packages/web/src/components/molecules/HelpCard/HelpCard.styled.tsx +++ b/packages/web/src/components/molecules/HelpCard/HelpCard.styled.tsx @@ -15,7 +15,7 @@ export const StyledHelpCardContainer = styled.div<{isLink?: boolean}>` width: 100%; background-color: ${Colors.slate800}; - cursor: ${props => (props.isLink ? 'pointer' : 'default')}; + cursor: ${props => (props.isLink ? 'pointer' : 'inherit')}; font-size: 12px; color: ${Colors.slate200}; diff --git a/packages/web/src/components/molecules/HelpCard/HelpCard.tsx b/packages/web/src/components/molecules/HelpCard/HelpCard.tsx index 9239b98b0..6bc2b19d7 100644 --- a/packages/web/src/components/molecules/HelpCard/HelpCard.tsx +++ b/packages/web/src/components/molecules/HelpCard/HelpCard.tsx @@ -11,17 +11,23 @@ type HelpCardTypes = { isHelp?: boolean; link?: string; customLinkIcon?: JSX.Element; + prefix?: JSX.Element; + suffix?: JSX.Element; }; const HelpCard: React.FC> = props => { - const {isLink, isHelp, children, link, customLinkIcon: CustomLinkIcon} = props; + const {prefix, suffix, isLink, isHelp, children, link, customLinkIcon: CustomLinkIcon} = props; const redirectToLink = () => { + if (!isLink) { + return; + } window.open(link, '_blank'); }; return ( - {isLink && !CustomLinkIcon ? ( + {prefix && prefix} + {isLink && !CustomLinkIcon && !prefix ? ( @@ -33,6 +39,7 @@ const HelpCard: React.FC> = props => { ) : null} {children} {isLink ? {CustomLinkIcon || } : null} + {suffix && suffix} ); }; diff --git a/packages/web/src/components/molecules/LabelsList/LabelsList.tsx b/packages/web/src/components/molecules/LabelsList/LabelsList.tsx index d36c33640..a4e2df3a2 100644 --- a/packages/web/src/components/molecules/LabelsList/LabelsList.tsx +++ b/packages/web/src/components/molecules/LabelsList/LabelsList.tsx @@ -13,10 +13,11 @@ type LabelsListProps = { howManyLabelsToShow?: number; shouldSkipLabels?: boolean; className?: string; + type?: 'primary' | 'secondary'; }; const LabelsList: React.FC = props => { - const {labels, howManyLabelsToShow = 3, shouldSkipLabels = false, className = ''} = props; + const {labels, howManyLabelsToShow = 3, shouldSkipLabels = false, className = '', type = 'primary'} = props; const labelKeys: EntityKey[] = Object.keys(labels); @@ -28,7 +29,7 @@ const LabelsList: React.FC = props => { } } - return ; + return ; }) .filter(labelComponent => labelComponent); @@ -37,7 +38,7 @@ const LabelsList: React.FC = props => { const renderedSkippedLabels = skippedLabelsArray ? skippedLabelsArray.map(([labelKey]) => { - return ; + return ; }) : null; @@ -56,6 +57,7 @@ const LabelsList: React.FC = props => { key="skipped-labels-number" isSkippedMode={shouldSkipLabels} skippedLabelsNumber={skippedLabelsNumber} + type={type} /> diff --git a/packages/web/src/components/molecules/LogOutput/FullscreenLogOutput.tsx b/packages/web/src/components/molecules/LogOutput/FullscreenLogOutput.tsx index 1cce6a4cf..e18cf925c 100644 --- a/packages/web/src/components/molecules/LogOutput/FullscreenLogOutput.tsx +++ b/packages/web/src/components/molecules/LogOutput/FullscreenLogOutput.tsx @@ -1,74 +1,23 @@ -import styled from 'styled-components'; +import styled, {css} from 'styled-components'; -import {Coordinates} from '@models/config'; - -import Colors from '@styles/Colors'; import {maxDevice} from '@styles/MediaQueries'; import LogOutputPure from './LogOutputPure'; -const FullscreenLogOutput = styled(LogOutputPure)<{$rect?: Coordinates}>` +export const fullscreenLogOutputStyles = css` position: fixed; z-index: 1001; - - color: ${Colors.slate400}; - - &.full-screen-log-output-enter { - top: ${({$rect}) => $rect?.top}px; - left: ${({$rect}) => $rect?.left}px; - - width: ${({$rect}) => $rect?.width}px; - height: ${({$rect}) => $rect?.height}px; - } - - &.full-screen-log-output-enter-active, - &.full-screen-log-output-enter-done { - top: 0; - left: 100px; - - width: calc(100% - 100px); - height: 100%; - - transition: top 0.4s ease-in-out, left 0.3s ease-in-out 0.4s, height 0.4s ease-in-out, width 0.3s ease-in-out 0.4s; - } - - &.full-screen-log-output-exit { - top: 0; - left: 100px; - - width: calc(100% - 100px); - height: 100%; - } - - &.full-screen-log-output-exit-active { - top: ${({$rect}) => $rect?.top}px; - left: ${({$rect}) => $rect?.left}px; - - width: ${({$rect}) => $rect?.width}px; - height: ${({$rect}) => $rect?.height}px; - - transition: top 0.2s ease-in-out 0.2s, left 0.2s ease-in-out, height 0.2s ease-in-out 0.2s, width 0.2s ease-in-out; - } - - &.full-screen-log-output-exit-done { - top: ${({$rect}) => $rect?.top}px; - left: ${({$rect}) => $rect?.left}px; - - width: ${({$rect}) => $rect?.width}px; - height: ${({$rect}) => $rect?.height}px; - } + top: 0; + left: 100px; + width: calc(100% - 100px); + height: 100%; @media ${maxDevice.tablet} { - &.full-screen-log-output-enter-active { - left: 60px; - width: calc(100% - 30px); - } - - &.full-screen-log-output-enter-done { - left: 60px; - width: calc(100% - 60px); - } + left: 60px; + width: calc(100% - 60px); } `; -export default FullscreenLogOutput; +export default styled(LogOutputPure)` + ${fullscreenLogOutputStyles} +`; diff --git a/packages/web/src/components/molecules/LogOutput/LogOutput.styled.tsx b/packages/web/src/components/molecules/LogOutput/LogOutput.styled.tsx index f781ec3ad..81f70ba92 100644 --- a/packages/web/src/components/molecules/LogOutput/LogOutput.styled.tsx +++ b/packages/web/src/components/molecules/LogOutput/LogOutput.styled.tsx @@ -1,20 +1,8 @@ -import {ExpandAltOutlined, FullscreenExitOutlined} from '@ant-design/icons'; +import {ExpandAltOutlined, FullscreenExitOutlined, SearchOutlined} from '@ant-design/icons'; import styled from 'styled-components'; -import {AnsiClassesMapping} from '@atoms'; - import Colors from '@styles/Colors'; -import {invisibleScroll} from '@styles/globalStyles'; - -export const BaseLogOutputStyles = ` - display: flex; - flex-direction: column; - - overflow: auto; - - ${invisibleScroll} -`; export const LogOutputWrapper = styled.div` height: 100%; @@ -27,27 +15,16 @@ export const StyledLogOutputContainer = styled.div` max-height: 100%; flex: 1; border-radius: 4px; - overflow: auto; - ${invisibleScroll} -`; - -export const StyledLogTextContainer = styled.div` - height: 100%; - flex: 1; background-color: ${Colors.slate900}; - - ${BaseLogOutputStyles} + overflow: hidden; `; export const StyledPreLogText = styled.pre` - display: flex; - flex-direction: column; - overflow: initial; + display: block; + height: calc(100% - 20px); - padding: 10px; + margin: 10px; font-size: 12px; - - ${AnsiClassesMapping} `; export const StyledLogOutputActionsContainer = styled.ul` @@ -75,7 +52,7 @@ export const StyledLogOutputHeaderContainer = styled.div<{$isFullscreen?: boolea ` : ` position: relative; - float: right; + z-index: 2; `} display: flex; @@ -119,3 +96,64 @@ export const StyledExpandAltOutlined = styled(ExpandAltOutlined)` border-color: ${Colors.indigo400}; } `; + +export const StyledSearchOutlined = styled(SearchOutlined)` + border-radius: 2px; + + padding: 4px; + margin: 6px; + + font-size: 22px; + right: 70px; + + border: 1px solid transparent; + transition: 0.3s; + + &:hover { + border-color: ${Colors.indigo400}; + } +`; + +export const StyledSearchInput = styled.input.attrs({type: 'text'})` + padding: 0 10px; + width: 150px; + height: 25px; + margin-left: 10px; + line-height: 23px; + border: 1px solid ${Colors.slate700}; + background: ${Colors.slate900}; + border-radius: 3px; + + &:focus { + outline: none; + border-color: ${Colors.indigo400}; + } +`; + +export const StyledSearchContainer = styled.div` + position: absolute; + top: 22px; + right: 70px; + transform: translateY(-50%); + display: flex; + align-items: center; +`; + +export const StyledSearchResults = styled.div` + font-size: 0.7em; + margin-right: 10px; +`; + +export const SearchArrowButton = styled.button.attrs({type: 'button'})` + background: transparent; + border: 0; + outline: 0; + width: 2em; + height: 2em; + line-height: 2em; + cursor: pointer; + + &:hover { + background: ${Colors.slate800}; + } +`; diff --git a/packages/web/src/components/molecules/LogOutput/LogOutput.tsx b/packages/web/src/components/molecules/LogOutput/LogOutput.tsx index 9864b8834..356782367 100644 --- a/packages/web/src/components/molecules/LogOutput/LogOutput.tsx +++ b/packages/web/src/components/molecules/LogOutput/LogOutput.tsx @@ -1,142 +1,62 @@ -import React, {Fragment, createElement, memo, useCallback, useEffect, useRef, useState} from 'react'; +import React, {Fragment, createElement, memo, useEffect, useRef} from 'react'; import {createPortal} from 'react-dom'; -import {CSSTransition} from 'react-transition-group'; -import {useAsync, useInterval} from 'react-use'; -import useWebSocket from 'react-use-websocket'; -import {isEqual} from 'lodash'; - -import {Coordinates} from '@models/config'; +import {useSearch} from '@molecules/LogOutput/useSearch'; import {useTestsSlot} from '@plugins/tests-and-test-suites/hooks'; -import {useWsEndpoint} from '@services/apiEndpoint'; - -import {useLogOutputPick} from '@store/logOutput'; - -import {getRtkIdToken} from '@utils/rtk'; +import {useLogOutputField, useLogOutputPick, useLogOutputSync} from '@store/logOutput'; import FullscreenLogOutput from './FullscreenLogOutput'; import {LogOutputWrapper} from './LogOutput.styled'; -import LogOutputPure from './LogOutputPure'; -import {useCountLines, useLastLines} from './utils'; - -export type LogOutputProps = { - logOutput?: string; - executionId?: string; - isRunning?: boolean; - initialLines?: number; -}; +import LogOutputPure, {LogOutputPureRef} from './LogOutputPure'; +import {LogOutputProps, useLogOutput} from './useLogOutput'; const LogOutput: React.FC = props => { - const {logOutput = 'No logs', executionId, isRunning = false, initialLines = 300} = props; - - const containerRef = useRef(null); - - const wsRoot = useWsEndpoint(); + const {isRunning} = props; + const logRef = useRef(null); + const options = useLogOutput(props); const {isFullscreen} = useLogOutputPick('isFullscreen'); + const fullscreenContainer = document.querySelector('#log-output-container')!; - const [rect, setRect] = useState(); - const [logs, setLogs] = useState(''); - const [shouldConnect, setShouldConnect] = useState(false); - - const [expanded, setExpanded] = useState(false); - const lines = useCountLines(logs); - const visibleLogs = useLastLines(logs, expanded || isRunning ? Infinity : initialLines); - - const onExpand = useCallback(() => setExpanded(true), []); + // Search logic + const [, setSearching] = useLogOutputField('searching'); + const [searchQuery] = useLogOutputField('searchQuery'); - // TODO: Consider getting token different way than using the one from RTK - const {value: token, loading: tokenLoading} = useAsync(getRtkIdToken); - useWebSocket( - `${wsRoot}/executions/${executionId}/logs/stream`, - { - onMessage: e => { - const logData = e.data; - - setLogs(prev => { - if (prev) { - try { - const dataToJSON = JSON.parse(logData); - const potentialOutput = dataToJSON?.result?.output || dataToJSON?.output; - - if (potentialOutput) { - return potentialOutput; - } + useEffect(() => { + if (!searchQuery) { + setSearching(false); + } + }, [searchQuery, setSearching]); - return `${prev}\n${dataToJSON.content}`; - } catch (err) { - // It may be just an output directly, so we have to ignore it - } - return `${prev}\n${logData}`; - } - - return `${logData}`; - }); - }, - shouldReconnect: () => true, - retryOnError: true, - queryParams: token ? {token} : {}, - }, - shouldConnect && !tokenLoading - ); + const search = useSearch({searchQuery, output: options.logs}); + useLogOutputSync({ + searching: search.loading, + searchResults: search.list, + searchLinesMap: search.map, + }); + const [searchIndex, setSearchIndex] = useLogOutputField('searchIndex'); useEffect(() => { - setLogs(isRunning ? '' : logOutput); - setShouldConnect(isRunning); - }, [isRunning, executionId]); - - useInterval(() => { - const clientRect = containerRef?.current?.getBoundingClientRect(); - if (clientRect && !isEqual(clientRect, rect)) { - setRect({ - top: clientRect.top, - left: clientRect.left, - width: clientRect.width, - height: clientRect.height, - }); + if (search.list.length === 0) { + // Do nothing + } else if (searchIndex >= search.list.length) { + setSearchIndex(0); + } else { + const highlight = search.list[searchIndex]; + logRef.current?.console?.scrollToLine(highlight.line); } - }, 200); - - const fullscreenLogRef = useRef(null); - const fullscreenLog = ( - - - - ); + }, [searchIndex, searchQuery, search.loading, logRef.current?.console]); return ( <> {/* eslint-disable-next-line react/no-array-index-key */} {useTestsSlot('logOutputTop').map((element, i) => createElement(Fragment, {key: i}, element))} - + - {createPortal(fullscreenLog, document.querySelector('#log-output-container')!)} + {isFullscreen ? createPortal(, fullscreenContainer) : null} ); }; diff --git a/packages/web/src/components/molecules/LogOutput/LogOutputActions.tsx b/packages/web/src/components/molecules/LogOutput/LogOutputActions.tsx index 6776b3b80..3da46fc8c 100644 --- a/packages/web/src/components/molecules/LogOutput/LogOutputActions.tsx +++ b/packages/web/src/components/molecules/LogOutput/LogOutputActions.tsx @@ -9,6 +9,7 @@ import useSecureContext from '@hooks/useSecureContext'; import FullscreenAction from './FullscreenAction'; import {StyledLogOutputActionsContainer} from './LogOutput.styled'; +import SearchAction from './SearchAction'; type LogOutputActionsProps = { logOutput: string; @@ -22,6 +23,7 @@ const LogOutputActions: React.FC = props => { return ( + {isSecureContext ? ( ) : ( diff --git a/packages/web/src/components/molecules/LogOutput/LogOutputPure.styled.tsx b/packages/web/src/components/molecules/LogOutput/LogOutputPure.styled.tsx new file mode 100644 index 000000000..ee964a6d3 --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/LogOutputPure.styled.tsx @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +export const InitialisingLogContainer = styled.div` + margin: 16px 0 0 16px; + display: flex; + gap: 16px; + align-items: center; + font-size: 12px; +`; diff --git a/packages/web/src/components/molecules/LogOutput/LogOutputPure.tsx b/packages/web/src/components/molecules/LogOutput/LogOutputPure.tsx index c0f31575f..9cc3eddf5 100644 --- a/packages/web/src/components/molecules/LogOutput/LogOutputPure.tsx +++ b/packages/web/src/components/molecules/LogOutput/LogOutputPure.tsx @@ -1,61 +1,59 @@ -import React, {MouseEvent, forwardRef, memo, useCallback, useEffect, useRef} from 'react'; +import {forwardRef, memo, useImperativeHandle, useRef} from 'react'; -import Ansi from 'ansi-to-react'; +import {LoadingOutlined} from '@ant-design/icons'; -import {useScrolledToBottom} from '@hooks/useScrolledToBottom'; +import {Console, ConsoleRef} from '@molecules/Console/Console'; -import {StyledLogOutputContainer, StyledLogTextContainer, StyledPreLogText} from './LogOutput.styled'; +import {StyledLogOutputContainer, StyledPreLogText} from './LogOutput.styled'; import LogOutputHeader from './LogOutputHeader'; +import * as S from './LogOutputPure.styled'; export interface LogOutputPureProps { - className?: string; logs: string; - visibleLogs: string; - expanded: boolean; - lines: number; - initialLines: number; - onExpand: () => void; + className?: string; + hideActions?: boolean; + isRunning?: boolean; + wrap?: boolean; + LineComponent?: Parameters[0]['LineComponent']; } -const LogOutputPure = memo( - forwardRef( - ({className, logs, visibleLogs, expanded, lines, initialLines, onExpand}, ref) => { - const scrollableRef = useRef(null); - const isScrolledToBottom = useScrolledToBottom(scrollableRef?.current); +export interface LogOutputPureRef { + container: HTMLDivElement | null; + console: ConsoleRef | null; +} - const expand = useCallback( - (event: MouseEvent) => { - event.preventDefault(); - onExpand?.(); - }, - [onExpand] +const LogOutputPure = memo( + forwardRef( + ({className, hideActions, isRunning, logs, wrap, LineComponent}, ref) => { + const consoleRef = useRef(null); + const containerRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + get container() { + return containerRef.current; + }, + get console() { + return consoleRef.current; + }, + }), + [] ); - useEffect(() => { - if (scrollableRef?.current && isScrolledToBottom) { - scrollableRef.current.scrollTop = scrollableRef.current.scrollHeight; - } - }, [visibleLogs]); - return ( - - - - - {visibleLogs ? ( - - {!expanded && lines >= initialLines ? ( - <> - - Click to show all {lines} lines... - -
- - ) : null} - {visibleLogs} -
- ) : null} -
+ + {hideActions ? null : } + {logs ? ( + + + + ) : isRunning ? ( + + + Initialising your test execution + + ) : null} ); } diff --git a/packages/web/src/components/molecules/LogOutput/SearchAction.tsx b/packages/web/src/components/molecules/LogOutput/SearchAction.tsx new file mode 100644 index 000000000..3cbdf011a --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/SearchAction.tsx @@ -0,0 +1,100 @@ +import {ChangeEvent, FC, KeyboardEvent, useLayoutEffect, useRef, useState} from 'react'; +import {useUnmount, useUpdate} from 'react-use'; + +import {DownOutlined, LoadingOutlined, UpOutlined} from '@ant-design/icons'; +import {Tooltip} from 'antd'; + +import {useEventCallback} from '@hooks/useEventCallback'; +import {useLastCallback} from '@hooks/useLastCallback'; + +import {useLogOutputField} from '@store/logOutput'; + +import { + SearchArrowButton, + StyledSearchContainer, + StyledSearchInput, + StyledSearchOutlined, + StyledSearchResults, +} from './LogOutput.styled'; + +const SearchAction: FC = () => { + const [query, setQuery] = useLogOutputField('searchQuery'); + const [index, setIndex] = useLogOutputField('searchIndex'); + const [searching] = useLogOutputField('searching'); + const [results] = useLogOutputField('searchResults'); + const [open, setOpen] = useState(false); + const toggle = () => setOpen(!open); + const inputRef = useRef(null); + + const prev = () => setIndex(Math.max(0, index - 1) % results.length); + const next = () => setIndex((index + 1) % results.length); + + const change = useLastCallback((event: ChangeEvent) => { + setQuery(event.target.value); + setIndex(0); + }); + + // Hook to Ctrl/Cmd+F + useEventCallback('keydown', (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'f') { + event.preventDefault(); + inputRef.current?.focus(); + setOpen(true); + } + }); + useEventCallback( + 'keydown', + event => { + const key = (event as unknown as KeyboardEvent).key; + if (key === 'Escape') { + event.preventDefault(); + event.stopImmediatePropagation(); + setOpen(false); + } else if (key === 'Enter') { + event.preventDefault(); + event.stopImmediatePropagation(); + next(); + } + }, + inputRef.current + ); + + useUnmount(() => { + setQuery(''); + }); + + // Re-render after opening the input to obtain reference + useLayoutEffect(useUpdate(), [open]); + + const message = + query === '' ? null : results.length > 0 ? ( + <> + + {index + 1} / {results.length} + + + + + + + + {searching ? : null} + + ) : searching ? ( + + ) : ( + no results + ); + + return ( + + {message} + {open ? : null} + + + + + ); +}; + +export default SearchAction; diff --git a/packages/web/src/components/molecules/LogOutput/createSearchScanner.ts b/packages/web/src/components/molecules/LogOutput/createSearchScanner.ts new file mode 100644 index 000000000..05444f04d --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/createSearchScanner.ts @@ -0,0 +1,62 @@ +import {SearchResult} from '@store/logOutput'; + +import {createSearchScannerFn} from './createSearchScannerFn'; + +export interface SearchScannerOptions { + searchQuery: string; + onData: (batch: SearchResult[]) => void; + onEnd: () => void; +} + +export interface SearchScanner { + append(content: string): void; + stop(): void; +} + +const createSearchScannerWorker = (options: SearchScannerOptions): SearchScanner => { + const blob = new Blob([ + `var scan = (${createSearchScannerFn.toString()})( + ${JSON.stringify(options.searchQuery)}, + postMessage + ); + onmessage = function (e) { scan(e.data) }`, + ]); + const worker = new Worker(URL.createObjectURL(blob)); + worker.onmessage = e => { + if (e.data.finished) { + options.onEnd(); + } else { + options.onData(e.data as SearchResult[]); + } + }; + worker.onerror = err => { + // eslint-disable-next-line no-console + console.error('Error from SearchScanner worker', err); + }; + return { + append: (content: string) => worker.postMessage(content), + stop: () => worker.terminate(), + }; +}; + +const createSearchScannerBlocking = (options: SearchScannerOptions): SearchScanner => { + const scanner = createSearchScannerFn(options.searchQuery, data => { + if ((data as any).end) { + options.onEnd(); + } else { + options.onData(data as SearchResult[]); + } + }); + const timeouts: any[] = []; + return { + append: (content: string) => timeouts.push(setTimeout(() => scanner(content), 1)), + stop: () => timeouts.forEach(x => clearTimeout(x)), + }; +}; + +export const createSearchScanner = (options: SearchScannerOptions): SearchScanner => { + if (typeof Worker === 'function' && typeof Blob === 'function') { + return createSearchScannerWorker(options); + } + return createSearchScannerBlocking(options); +}; diff --git a/packages/web/src/components/molecules/LogOutput/createSearchScannerFn.ts b/packages/web/src/components/molecules/LogOutput/createSearchScannerFn.ts new file mode 100644 index 000000000..14f9dc85c --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/createSearchScannerFn.ts @@ -0,0 +1,100 @@ +import {SearchResult} from '@store/logOutput'; + +/** + * This function should not use any external references. + * + * It has to be a single function, + * as it's serialized into Worker function. + * + * For the best performance: + * - it's building a function that will be optimized by JS engine + * + * TODO: Ignore ASCII codes + * TODO: Use previous results when it is substring + */ +export const createSearchScannerFn = ( + searchQuery: string, + push: (data: SearchResult[] | {finished: true}) => void +): ((content: string) => void) => { + let currentLineNumber = 1; + let lineStartIndex = 0; + + const condition = searchQuery + .split('') + .map((char, i) => { + const index = i === 0 ? 'i' : `i+${i}`; + const lowerCondition = `c.charCodeAt(${index})===${char.toLowerCase().charCodeAt(0)}`; + const upperCondition = `c.charCodeAt(${index})===${char.toUpperCase().charCodeAt(0)}`; + return lowerCondition === upperCondition ? lowerCondition : `(${lowerCondition}||${upperCondition})`; + }) + .join('&&'); + + // @see {@link https://github.com/chalk/ansi-regex/blob/main/index.js} + const pattern = [ + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', + ].join('|'); + const ansiRegex = new RegExp(pattern, 'g'); + + // eslint-disable-next-line no-new-func + const scan = new Function( + 'l', + 'lineStart', + 'c', + 'postMessage', + ` + var batch = 300; + var queue = new Array(batch); + var count = 0; + var nextFlush = 50000; + var flush = function () { + nextFlush = (Math.floor(i / 50000) + 1) * 50000; + if (count === batch) { + postMessage(queue); + } else if (count > 0) { + postMessage(queue.slice(0, count)); + count = 0; + } + } + var push = function (msg) { + queue[count] = msg; + count++; + if (count === batch) { + flush(); + } + } + var ansi = ${ansiRegex.toString()}; + var omitted = 0; + for (var i = 0; i < c.length; i++) { + if (c.charCodeAt(i) === 0x1b) { + ansi.lastIndex = i; + var result = ansi.exec(c); + if (result && result.index === i) { + i += result[0].length - 1; + omitted += result[0].length; + } + continue; + } + if (${condition}) { + push({line: l, start: i - omitted - lineStart, end: i - omitted - lineStart + ${searchQuery.length}}); + } + if (c[i] === "\\n") { + l++; + lineStart = i + 1; + omitted = 0; + } + if (i > nextFlush) { + flush(); + } + } + flush(); + postMessage({finished: true}); + return [l, lineStart]; + ` + ); + return (content: string) => { + const result = scan(currentLineNumber, lineStartIndex, content, push); + currentLineNumber = result[0]; + lineStartIndex = result[1]; + }; +}; diff --git a/packages/web/src/components/molecules/LogOutput/useLogOutput.tsx b/packages/web/src/components/molecules/LogOutput/useLogOutput.tsx new file mode 100644 index 000000000..04801ddc3 --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/useLogOutput.tsx @@ -0,0 +1,32 @@ +import LogOutputPure, {LogOutputPureProps} from './LogOutputPure'; +import {useLogsStream} from './useLogsStream'; + +export type LogOutputProps = { + logOutput?: string; + executionId?: string; + isRunning?: boolean; + hideActions?: boolean; + wrap?: boolean; + LineComponent?: Parameters[0]['LineComponent']; +}; + +export const useLogOutput = (props: LogOutputProps): LogOutputPureProps => { + const { + logOutput = 'No logs', + executionId, + wrap = false, + hideActions = false, + isRunning = false, + LineComponent, + } = props; + + const streamLogs = useLogsStream(executionId, isRunning); + const logs = isRunning && executionId ? streamLogs : logOutput; + + return { + hideActions, + logs, + wrap, + LineComponent, + }; +}; diff --git a/packages/web/src/components/molecules/LogOutput/useLogsStream.ts b/packages/web/src/components/molecules/LogOutput/useLogsStream.ts new file mode 100644 index 000000000..3cfc51ef2 --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/useLogsStream.ts @@ -0,0 +1,53 @@ +import {useEffect, useState} from 'react'; +import {useAsync} from 'react-use'; +import useWebSocket from 'react-use-websocket'; + +import {useWsEndpoint} from '@services/apiEndpoint'; + +import {getRtkIdToken} from '@utils/rtk'; + +export const useLogsStream = (executionId?: string, enabled?: boolean) => { + const wsRoot = useWsEndpoint(); + const [logs, setLogs] = useState(''); + + // TODO: Consider getting token different way than using the one from RTK + const {value: token, loading: tokenLoading} = useAsync(getRtkIdToken); + useWebSocket( + `${wsRoot}/executions/${executionId}/logs/stream`, + { + onMessage: e => { + const logData = e.data; + + setLogs(prev => { + if (prev) { + try { + const dataToJSON = JSON.parse(logData); + const potentialOutput = dataToJSON?.result?.output || dataToJSON?.output; + + if (potentialOutput) { + return potentialOutput; + } + + return `${prev}\n${dataToJSON.content}`; + } catch (err) { + // It may be just an output directly, so we have to ignore it + } + return `${prev}\n${logData}`; + } + + return `${logData}`; + }); + }, + shouldReconnect: () => true, + retryOnError: true, + queryParams: token ? {token} : {}, + }, + Boolean(executionId) && enabled && !tokenLoading + ); + + useEffect(() => { + setLogs(''); + }, [executionId]); + + return logs; +}; diff --git a/packages/web/src/components/molecules/LogOutput/useSearch.ts b/packages/web/src/components/molecules/LogOutput/useSearch.ts new file mode 100644 index 000000000..8db1766a8 --- /dev/null +++ b/packages/web/src/components/molecules/LogOutput/useSearch.ts @@ -0,0 +1,67 @@ +import {useEffect, useMemo, useRef, useState} from 'react'; + +import {useRafUpdate} from '@hooks/useRafUpdate'; + +import {SearchResult} from '@store/logOutput'; + +import {createSearchScanner} from './createSearchScanner'; + +export interface SearchOptions { + searchQuery: string; + output?: string; +} + +export const useSearch = ({searchQuery, output}: SearchOptions) => { + const rerender = useRafUpdate(); + const [loading, setLoading] = useState(false); + const resultsRef = useRef([]); + const mapRef = useRef>({}); + const linesRef = useRef([]); + + useMemo(() => { + resultsRef.current = []; + mapRef.current = {}; + linesRef.current = []; + }, [output, searchQuery]); + + // TODO: Append output for live data + useEffect(() => { + resultsRef.current = []; + mapRef.current = {}; + linesRef.current = []; + if (!searchQuery) { + setLoading(false); + return; + } + setLoading(true); + + const scanner = createSearchScanner({ + searchQuery, + onData: batch => { + resultsRef.current.push(...batch); + rerender(); + + batch.forEach(result => { + if (mapRef.current[result.line]) { + mapRef.current[result.line] = [...mapRef.current[result.line], result]; + } else { + linesRef.current.push(result.line); + mapRef.current[result.line] = [result]; + } + }); + }, + onEnd: () => setLoading(false), + }); + + scanner.append(output || ''); + + return () => scanner.stop(); + }, [output, searchQuery]); + + return { + list: resultsRef.current, + map: mapRef.current, + lines: linesRef.current, + loading, + }; +}; diff --git a/packages/web/src/components/molecules/LogOutput/utils.spec.ts b/packages/web/src/components/molecules/LogOutput/utils.spec.ts index b62929a01..962e88e35 100644 --- a/packages/web/src/components/molecules/LogOutput/utils.spec.ts +++ b/packages/web/src/components/molecules/LogOutput/utils.spec.ts @@ -1,6 +1,6 @@ import {renderHook} from '@testing-library/react'; -import {countLines, getLastLines, useCountLines, useLastLines} from './utils'; +import {countLines, useCountLines} from './utils'; describe('molecules', () => { describe('LogOutput', () => { @@ -26,19 +26,6 @@ describe('molecules', () => { }); }); - describe('getLastLines', () => { - it('should return whole text when does not have enough lines', () => { - expect(getLastLines('abc', 1)).toBe('abc'); - expect(getLastLines('abc', 10)).toBe('abc'); - expect(getLastLines('abc\ndef', 10)).toBe('abc\ndef'); - }); - - it('should cut text to last lines', () => { - expect(getLastLines('abc\n'.repeat(100), 10)).toBe('abc\n'.repeat(9)); - expect(getLastLines('\nabc'.repeat(100), 10)).toBe('\nabc'.repeat(10).substring(1)); - }); - }); - describe('useCountLines', () => { it('should return number of lines', () => { const {result} = renderHook(() => useCountLines('\nabc'.repeat(100))); @@ -53,33 +40,5 @@ describe('molecules', () => { expect(result.current).toBe(1001); }); }); - - describe('useLastLines', () => { - it('should return all text when there is less lines', () => { - const {result} = renderHook(() => useLastLines('\nabc'.repeat(100), 1000)); - expect(result.current).toBe('\nabc'.repeat(100)); - }); - - it('should cut text to last lines', () => { - const {result} = renderHook(() => useLastLines('abc\n'.repeat(100), 10)); - expect(result.current).toBe('abc\n'.repeat(9)); - }); - - it('should react to text change', () => { - const {result, rerender} = renderHook(({text}) => useLastLines(text, 200), { - initialProps: {text: '\nabc'.repeat(100)}, - }); - rerender({text: 'abc\n'.repeat(1000)}); - expect(result.current).toBe('abc\n'.repeat(199)); - }); - - it('should react to lines change', () => { - const {result, rerender} = renderHook(({max}) => useLastLines('abc\n'.repeat(1000), max), { - initialProps: {max: 100}, - }); - rerender({max: 200}); - expect(result.current).toBe('abc\n'.repeat(199)); - }); - }); }); }); diff --git a/packages/web/src/components/molecules/LogOutput/utils.ts b/packages/web/src/components/molecules/LogOutput/utils.ts index d7a187db6..81ded0590 100644 --- a/packages/web/src/components/molecules/LogOutput/utils.ts +++ b/packages/web/src/components/molecules/LogOutput/utils.ts @@ -10,21 +10,4 @@ export const countLines = (text: string): number => { return count; }; -export const getLastLines = (text: string, lines: number): string => { - let count = 0; - for (let i = text.length - 1; i >= 0; i -= 1) { - if (text[i] === '\n') { - count += 1; - if (lines === count) { - return text.substring(i + 1); - } - } - } - return text; -}; - export const useCountLines = (text: string) => useMemo(() => countLines(text), [text]); - -export const useLastLines = (text: string, maxLines: number): string => { - return useMemo(() => (countLines(text) <= maxLines ? text : getLastLines(text, maxLines)), [text, maxLines]); -}; diff --git a/packages/web/src/components/molecules/MetricsBarChart/components/BarWithTooltipPure.tsx b/packages/web/src/components/molecules/MetricsBarChart/components/BarWithTooltipPure.tsx index e2b3f5e86..551d7f8f7 100644 --- a/packages/web/src/components/molecules/MetricsBarChart/components/BarWithTooltipPure.tsx +++ b/packages/web/src/components/molecules/MetricsBarChart/components/BarWithTooltipPure.tsx @@ -27,7 +27,7 @@ type BarConfigPure = BarConfig & { }; const BarWithTooltipPure: React.FC = memo(props => { - const {width, height, color, status, duration, name, startTime, chartHeight, hoverColor, date, onSelect} = props; + const {width, height, color, status, duration, name, startTime, hoverColor, date, onSelect} = props; const onBarClicked = useCallback(() => onSelect(name), [onSelect, name]); @@ -52,7 +52,7 @@ const BarWithTooltipPure: React.FC = memo(props => { ); return ( - + {date ? {date} : undefined} diff --git a/packages/web/src/components/molecules/TestActionsDropdown/TestActionsDropdown.tsx b/packages/web/src/components/molecules/TestActionsDropdown/TestActionsDropdown.tsx new file mode 100644 index 000000000..199376396 --- /dev/null +++ b/packages/web/src/components/molecules/TestActionsDropdown/TestActionsDropdown.tsx @@ -0,0 +1,110 @@ +import {useCallback, useMemo, useRef, useState} from 'react'; + +import {ItemType} from 'antd/lib/menu/hooks/useItems'; + +import {useDashboardNavigate} from '@hooks/useDashboardNavigate'; +import useInViewport from '@hooks/useInViewport'; +import useRunEntity from '@hooks/useRunEntity'; +import {SystemAccess, useSystemAccess} from '@hooks/useSystemAccess'; + +import {ActionsDropdownProps} from '@models/actionsDropdown'; +import {ExecutionMetrics} from '@models/metrics'; + +import DotsDropdown from '@molecules/DotsDropdown'; +import {notificationCall} from '@molecules/Notification'; + +import {useAbortAllTestExecutionsMutation, useGetTestExecutionMetricsQuery} from '@services/tests'; + +import {PollingIntervals} from '@utils/numbers'; + +const TestActionsDropdown: React.FC = props => { + const {name, namespace, outOfSync, type} = props; + + const [abortAllTestExecutions] = useAbortAllTestExecutionsMutation(); + + const isSystemAvailable = useSystemAccess(SystemAccess.system); + + const [open, setOpen] = useState(false); + + const editNavigate = useDashboardNavigate(() => `/tests/${name}/settings/test`); + + const [, run] = useRunEntity('tests', { + name, + namespace, + type, + }); + + const ref = useRef(null); + const isInViewport = useInViewport(ref); + + const {data: metrics, refetch} = useGetTestExecutionMetricsQuery( + {id: name, last: 7, limit: 13}, + {skip: !isInViewport || !isSystemAvailable, pollingInterval: PollingIntervals.halfMin} + ); + + const executions: ExecutionMetrics[] = useMemo(() => metrics?.executions ?? [], [metrics]); + + const onAbort = useCallback(() => { + setOpen(false); + + abortAllTestExecutions({id: name}) + .unwrap() + .then(refetch) + .catch(() => { + notificationCall('failed', 'Something went wrong during test execution abortion'); + }); + }, [abortAllTestExecutions, name, refetch]); + + const dropdownItems: ItemType[] = useMemo( + () => [ + { + key: `run-test`, + label: 'Run test', + onClick: async ({domEvent}) => { + domEvent.stopPropagation(); + setOpen(false); + await run(); + refetch(); + }, + }, + { + key: `edit-test`, + label: 'Edit test', + onClick: ({domEvent}) => { + domEvent.stopPropagation(); + setOpen(false); + editNavigate(); + }, + }, + + ...(executions?.some((e: ExecutionMetrics) => e.status === 'running') + ? ([ + {key: 'divider', type: 'divider'}, + { + key: 'abort-executions', + label: 'Abort all executions', + onClick: async ({domEvent}) => { + domEvent.stopPropagation(); + onAbort(); + }, + }, + ] as ItemType[]) + : []), + ], + [editNavigate, executions, onAbort, refetch, run] + ); + + return ( +
+ +
+ ); +}; + +export default TestActionsDropdown; diff --git a/packages/web/src/components/molecules/TestActionsDropdown/index.ts b/packages/web/src/components/molecules/TestActionsDropdown/index.ts new file mode 100644 index 000000000..08304414c --- /dev/null +++ b/packages/web/src/components/molecules/TestActionsDropdown/index.ts @@ -0,0 +1 @@ +export {default} from './TestActionsDropdown'; diff --git a/packages/web/src/components/molecules/TestSuiteActionsDropdown/TestSuiteActionsDropdown.tsx b/packages/web/src/components/molecules/TestSuiteActionsDropdown/TestSuiteActionsDropdown.tsx new file mode 100644 index 000000000..7ef497335 --- /dev/null +++ b/packages/web/src/components/molecules/TestSuiteActionsDropdown/TestSuiteActionsDropdown.tsx @@ -0,0 +1,110 @@ +import {useCallback, useMemo, useRef, useState} from 'react'; + +import {ItemType} from 'antd/lib/menu/hooks/useItems'; + +import {useDashboardNavigate} from '@hooks/useDashboardNavigate'; +import useInViewport from '@hooks/useInViewport'; +import useRunEntity from '@hooks/useRunEntity'; +import {SystemAccess, useSystemAccess} from '@hooks/useSystemAccess'; + +import {ActionsDropdownProps} from '@models/actionsDropdown'; +import {ExecutionMetrics} from '@models/metrics'; + +import DotsDropdown from '@molecules/DotsDropdown'; +import {notificationCall} from '@molecules/Notification'; + +import {useAbortAllTestSuiteExecutionsMutation, useGetTestSuiteExecutionMetricsQuery} from '@services/testSuites'; + +import {PollingIntervals} from '@utils/numbers'; + +const TestSuiteActionsDropdown: React.FC = props => { + const {name, namespace, outOfSync, type} = props; + + const [abortAllTestSuiteExecutions] = useAbortAllTestSuiteExecutionsMutation(); + + const isSystemAvailable = useSystemAccess(SystemAccess.system); + + const [open, setOpen] = useState(false); + + const editNavigate = useDashboardNavigate(() => `/test-suites/${name}/settings/tests`); + + const [, run] = useRunEntity('test-suites', { + name, + namespace, + type, + }); + + const ref = useRef(null); + const isInViewport = useInViewport(ref); + + const {data: metrics, refetch} = useGetTestSuiteExecutionMetricsQuery( + {id: name, last: 7, limit: 13}, + {skip: !isInViewport || !isSystemAvailable, pollingInterval: PollingIntervals.halfMin} + ); + + const executions: ExecutionMetrics[] = useMemo(() => metrics?.executions ?? [], [metrics]); + + const onAbort = useCallback(() => { + setOpen(false); + + abortAllTestSuiteExecutions({id: name}) + .unwrap() + .then(refetch) + .catch(() => { + notificationCall('failed', 'Something went wrong during test suite execution abortion'); + }); + }, [abortAllTestSuiteExecutions, name, refetch]); + + const dropdownItems: ItemType[] = useMemo( + () => [ + { + key: `run-test-suite`, + label: 'Run test suite', + onClick: async ({domEvent}) => { + domEvent.stopPropagation(); + setOpen(false); + await run(); + refetch(); + }, + }, + { + key: 'edit-test-suite', + label: 'Edit test suite', + onClick: ({domEvent}) => { + domEvent.stopPropagation(); + setOpen(false); + editNavigate(); + }, + }, + + ...(executions?.some((e: ExecutionMetrics) => e.status === 'running') + ? ([ + {key: 'divider', type: 'divider'}, + { + key: 'abort-executions', + label: 'Abort all executions', + onClick: async ({domEvent}) => { + domEvent.stopPropagation(); + onAbort(); + }, + }, + ] as ItemType[]) + : []), + ], + [editNavigate, executions, onAbort, refetch, run] + ); + + return ( +
+ +
+ ); +}; + +export default TestSuiteActionsDropdown; diff --git a/packages/web/src/components/molecules/TestSuiteActionsDropdown/index.ts b/packages/web/src/components/molecules/TestSuiteActionsDropdown/index.ts new file mode 100644 index 000000000..704e0243a --- /dev/null +++ b/packages/web/src/components/molecules/TestSuiteActionsDropdown/index.ts @@ -0,0 +1 @@ +export {default} from './TestSuiteActionsDropdown'; diff --git a/packages/web/src/components/molecules/index.ts b/packages/web/src/components/molecules/index.ts index ae3ce74c8..a6d62bedb 100644 --- a/packages/web/src/components/molecules/index.ts +++ b/packages/web/src/components/molecules/index.ts @@ -39,3 +39,5 @@ export {default as InlineNotification} from './InlineNotification'; export {default as SummaryGrid} from './SummaryGrid'; export {default as LabelSelectorHelpIcon} from './LabelSelectorHelpIcon'; export {default as DeleteModal} from './DeleteModal'; +export {default as TestActionsDropdown} from './TestActionsDropdown'; +export {default as TestSuiteActionsDropdown} from './TestSuiteActionsDropdown'; diff --git a/packages/web/src/components/organisms/CardForm/CardForm.tsx b/packages/web/src/components/organisms/CardForm/CardForm.tsx index ed8fd5312..a522dcf79 100644 --- a/packages/web/src/components/organisms/CardForm/CardForm.tsx +++ b/packages/web/src/components/organisms/CardForm/CardForm.tsx @@ -29,6 +29,7 @@ interface CardFormProps { labelAlign?: FormLabelAlign; layout?: FormLayout; initialValues?: any; + confirmDisabled?: boolean; confirmLabel?: string; monitLeave?: boolean; monitLeaveMessage?: string; @@ -53,6 +54,7 @@ const CardForm: FC> = ({ readOnly, wasTouched, isWarning, + confirmDisabled, confirmLabel, spacing, monitLeave = true, @@ -132,6 +134,7 @@ const CardForm: FC> = ({ footer={footer} headerAction={headerAction} confirmLabel={confirmLabel} + confirmDisabled={confirmDisabled} wasTouched={wasTouched} readOnly={readOnly} loading={loading} diff --git a/packages/web/src/components/organisms/EntityDetails/EntityDetailsHeader.tsx b/packages/web/src/components/organisms/EntityDetails/EntityDetailsHeader.tsx index 5e8d7c21c..7e63193bf 100644 --- a/packages/web/src/components/organisms/EntityDetails/EntityDetailsHeader.tsx +++ b/packages/web/src/components/organisms/EntityDetails/EntityDetailsHeader.tsx @@ -1,4 +1,4 @@ -import {FC, useMemo} from 'react'; +import {FC, useEffect, useMemo} from 'react'; import {Select, Space} from 'antd'; @@ -30,7 +30,7 @@ const filterOptions: OptionType[] = [ {value: 7, label: 'Timeframe: last 7 days', key: 'last7Days'}, {value: 30, label: 'Timeframe: last 30 days', key: 'last30Days'}, {value: 90, label: 'Timeframe: last 90 days', key: 'last90Days'}, - {value: 365, label: 'Timeframe: this year', key: 'thisYear'}, + {value: 365, label: 'Timeframe: last 12 months', key: 'last12Months'}, {value: 0, label: 'See all executions', key: 'allDays'}, ]; @@ -83,6 +83,31 @@ const EntityDetailsHeader: FC = ({ [executions?.totals?.running] ); + useEffect(() => { + if (!details) return; + + const latestExecutionStartTime = details.status?.latestExecution.startTime; + + if (!latestExecutionStartTime) { + setDaysFilterValue(0); + return; + } + + const latestExecutionStartTimeDate = new Date(latestExecutionStartTime); + const differenceInDays = Math.round((Date.now() - latestExecutionStartTimeDate.getTime()) / (1000 * 3600 * 24)); + + for (let i = 0; i < filterOptions.length; i += 1) { + if (Number(filterOptions[i].value) >= differenceInDays) { + setDaysFilterValue(Number(filterOptions[i].value)); + return; + } + + if (filterOptions[i].value === 0) { + setDaysFilterValue(0); + } + } + }, [details, setDaysFilterValue]); + return ( > = ({ const [metrics, setMetrics] = useEntityDetailsField('metrics'); const [, setCurrentPage] = useEntityDetailsField('currentPage'); const [executions, setExecutions] = useEntityDetailsField('executions'); - const {details: storeDetails} = useEntityDetailsPick('details'); + const [, setExecutionsLoading] = useEntityDetailsField('executionsLoading'); const [, setIsFirstTimeLoading] = useEntityDetailsField('isFirstTimeLoading'); const [daysFilterValue, setDaysFilterValue] = useEntityDetailsField('daysFilterValue'); + const {executionsFilters} = useEntityDetailsPick('executionsFilters'); const isClusterAvailable = useSystemAccess(SystemAccess.agent); const isSystemAvailable = useSystemAccess(SystemAccess.system); const wsRoot = useWsEndpoint(); - const {data: rawExecutions, refetch} = useGetExecutions( - {id, last: daysFilterValue}, + const { + data: rawExecutions, + isFetching, + refetch, + } = useGetExecutions( + {id, last: daysFilterValue, ...executionsFilters}, { pollingInterval: PollingIntervals.long, - skip: !isSystemAvailable, + skip: !isSystemAvailable || daysFilterValue === undefined, } ); + const {data: rawMetrics, refetch: refetchMetrics} = useGetMetrics( {id, last: daysFilterValue}, {skip: !isSystemAvailable} @@ -78,8 +84,9 @@ const EntityDetailsLayer: FC> = ({ pollingInterval: PollingIntervals.long, skip: !isSystemAvailable, }); + const isV2 = isTestSuiteV2(rawDetails); - const details = useMemo(() => (isV2 ? convertTestSuiteV2ExecutionToV3(rawDetails) : rawDetails), [rawDetails]); + const details = useMemo(() => (isV2 ? convertTestSuiteV2ExecutionToV3(rawDetails) : rawDetails), [isV2, rawDetails]); const onWebSocketData = (wsData: WSDataWithTestExecution | WSDataWithTestSuiteExecution) => { try { @@ -196,6 +203,10 @@ const EntityDetailsLayer: FC> = ({ !tokenLoading && isClusterAvailable ); + useEffect(() => { + setExecutionsLoading(isFetching); + }, [isFetching, setExecutionsLoading]); + useEffect(() => { if (execId && executions?.results.length > 0) { const executionDetails = executions?.results?.find((execution: any) => execution.id === execId); diff --git a/packages/web/src/components/organisms/EntityDetails/Settings/SettingsScheduling/Schedule.tsx b/packages/web/src/components/organisms/EntityDetails/Settings/SettingsScheduling/Schedule.tsx index 147c214db..79c9735ee 100644 --- a/packages/web/src/components/organisms/EntityDetails/Settings/SettingsScheduling/Schedule.tsx +++ b/packages/web/src/components/organisms/EntityDetails/Settings/SettingsScheduling/Schedule.tsx @@ -148,7 +148,7 @@ const Schedule: React.FC = ({label, useUpdateEntity}) => { ) : null} - + {cronString} diff --git a/packages/web/src/components/organisms/EntityList/EntityListContent/EntityListContent.styled.tsx b/packages/web/src/components/organisms/EntityList/EntityListContent/EntityListContent.styled.tsx deleted file mode 100644 index 64ac54ea8..000000000 --- a/packages/web/src/components/organisms/EntityList/EntityListContent/EntityListContent.styled.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import styled from 'styled-components'; - -import Colors from '@styles/Colors'; - -export const HeaderContainer = styled.div` - padding-bottom: 20px; -`; - -export const StyledFiltersSection = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - flex-wrap: wrap; -`; - -export const StyledButtonContainer = styled.div` - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 8px; -`; - -export const Header = styled.div` - display: flex; - flex-direction: column; - - padding: 30px 0 0; - - background-color: ${Colors.slate900}; - - z-index: 1000; -`; diff --git a/packages/web/src/components/organisms/EntityList/EntityListContent/index.ts b/packages/web/src/components/organisms/EntityList/EntityListContent/index.ts deleted file mode 100644 index a8e20ccd8..000000000 --- a/packages/web/src/components/organisms/EntityList/EntityListContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {default} from './EntityListContent'; diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/EntityListFilters.styled.tsx b/packages/web/src/components/organisms/EntityList/EntityListFilters/EntityListFilters.styled.tsx deleted file mode 100644 index 054457772..000000000 --- a/packages/web/src/components/organisms/EntityList/EntityListFilters/EntityListFilters.styled.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import styled from 'styled-components'; - -import {maxDevice} from '@styles/MediaQueries'; - -export const FiltersContainer = styled.div` - display: inline-flex; - flex-wrap: wrap; - gap: 16px; - align-items: center; - - & > * { - width: 296px; - } - - @media ${maxDevice.tablet} { - width: 100%; - - & > * { - width: 100%; - } - } -`; diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/EntityListFilters.tsx b/packages/web/src/components/organisms/EntityList/EntityListFilters/EntityListFilters.tsx deleted file mode 100644 index 2ad5be3aa..000000000 --- a/packages/web/src/components/organisms/EntityList/EntityListFilters/EntityListFilters.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import {FC, memo} from 'react'; - -import {FilterProps} from '@models/filters'; - -import {FiltersContainer} from './EntityListFilters.styled'; -import LabelsFilter from './LabelsFilter'; -import StatusFilter from './StatusFilter'; -import TextSearchFilter from './TextSearchFilter'; - -const EntityListFilters: FC = props => { - const {isFiltersDisabled, ...rest} = props; - - return ( - - - - - - ); -}; - -export default memo(EntityListFilters); diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/LabelsFilter/LabelsFilter.tsx b/packages/web/src/components/organisms/EntityList/EntityListFilters/LabelsFilter/LabelsFilter.tsx deleted file mode 100644 index 45bfd840d..000000000 --- a/packages/web/src/components/organisms/EntityList/EntityListFilters/LabelsFilter/LabelsFilter.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import {useEffect, useMemo, useState} from 'react'; - -import {FilterFilled} from '@ant-design/icons'; - -import {Button, Title} from '@custom-antd'; - -import usePressEnter from '@hooks/usePressEnter'; -import {SystemAccess, useSystemAccess} from '@hooks/useSystemAccess'; - -import {Entity} from '@models/entityMap'; -import {FilterProps} from '@models/filters'; - -import {FilterMenuFooter, StyledFilterDropdown, StyledFilterLabel, StyledFilterMenu} from '@molecules/FilterMenu'; - -import {initialPageSize} from '@redux/initialState'; - -import {useGetLabelsQuery} from '@services/labels'; - -import {PollingIntervals} from '@src/utils/numbers'; - -import Colors from '@styles/Colors'; - -import {decodeSelectorArray, encodeSelectorArray} from '@utils/selectors'; - -import {AutoComplete, EmptyButton, StyledKeyValueRow, StyledLabelsMenuContainer} from './LabelsFilter.styled'; - -const defaultKeyValuePair: Entity = { - key: '', - value: '', -}; - -const LabelsFilter: React.FC = props => { - const {setFilters, filters, isFiltersDisabled, width} = props; - const isClusterAvailable = useSystemAccess(SystemAccess.agent); - - const {data} = useGetLabelsQuery(null, { - pollingInterval: PollingIntervals.default, - skip: !isClusterAvailable, - }); - const [isVisible, setVisibilityState] = useState(false); - const [labelsMapping, setLabelsMapping] = useState([]); - - const onEvent = usePressEnter(); - - const onOpenChange = (flag: boolean) => { - setVisibilityState(flag); - }; - - const onKeyChange = (key: string, index: number) => { - setLabelsMapping([ - ...labelsMapping.slice(0, index), - {key, value: labelsMapping[index].value}, - ...labelsMapping.slice(index + 1), - ]); - }; - - const onValueChange = (value: string, index: number) => { - setLabelsMapping([ - ...labelsMapping.slice(0, index), - {key: labelsMapping[index].key, value}, - ...labelsMapping.slice(index + 1), - ]); - }; - - const onDeleteRow = (index: number) => { - setLabelsMapping([...labelsMapping.slice(0, index), ...labelsMapping.slice(index + 1)]); - }; - - const onAddRow = () => { - setLabelsMapping([...labelsMapping, defaultKeyValuePair]); - }; - - useEffect(() => { - const mapping = decodeSelectorArray(filters.selector); - setLabelsMapping(mapping.length === 0 ? [defaultKeyValuePair] : mapping); - }, [filters.selector]); - - const keysLabels = useMemo(() => Object.keys(data ?? {}).map(key => ({key, label: key, value: key})), [data]); - - const valuesLabels = useMemo( - () => (data ? keysLabels.map(item => data[item.key].map(v => ({key: item.key, value: v}))).flat() : []), - [keysLabels] - ); - - const renderKeyValueInputs = useMemo( - () => - labelsMapping.map((item, index) => { - const key = `key-value-pair${index}`; - - const keyOptions = keysLabels.filter(f => f.key.startsWith(item.key)); - - const valuesOptions = valuesLabels - .filter(f => f.key === item.key && f.value.startsWith(item.value)) - .map(v => ({key: v.value, label: v.value, value: v.value})); - - return ( - - onKeyChange(event, index)} - value={item.key} - data-testid={`key-input-${index}`} - placeholder="Key" - /> - onValueChange(event, index)} - value={item.value} - data-testid={`value-input-${index}`} - placeholder="Value" - /> - {index > 0 ? ( - - ) : ( - - )} - - ); - }), - [labelsMapping, keysLabels, valuesLabels] - ); - - const applyFilters = () => { - const selector = encodeSelectorArray(labelsMapping); - setFilters({...filters, selector, pageSize: initialPageSize}); - onOpenChange(false); - }; - - const resetFilters = () => { - setLabelsMapping([defaultKeyValuePair]); - onOpenChange(false); - setFilters({...filters, selector: '', pageSize: initialPageSize}); - }; - - const menu = ( - { - onEvent(event, applyFilters); - }} - > - - Filter tests by Key Value pairs. - {renderKeyValueInputs} - - - - - ); - - const isFilterApplied = filters.selector.length > 0; - - return ( - - e.preventDefault()} - data-testid="labels-filter-button" - isFiltersDisabled={isFiltersDisabled} - > - Labels - - - ); -}; - -export default LabelsFilter; diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/LabelsFilter/index.ts b/packages/web/src/components/organisms/EntityList/EntityListFilters/LabelsFilter/index.ts deleted file mode 100644 index 4dcb7e350..000000000 --- a/packages/web/src/components/organisms/EntityList/EntityListFilters/LabelsFilter/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {default} from './LabelsFilter'; diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/index.ts b/packages/web/src/components/organisms/EntityList/EntityListFilters/index.ts deleted file mode 100644 index 44e23b01c..000000000 --- a/packages/web/src/components/organisms/EntityList/EntityListFilters/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {default} from './EntityListFilters'; diff --git a/packages/web/src/components/organisms/EntityView/EmptyDataWithFilters/EmptyDataWithFilters.styled.tsx b/packages/web/src/components/organisms/EntityView/EmptyDataWithFilters/EmptyDataWithFilters.styled.tsx new file mode 100644 index 000000000..332531e27 --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EmptyDataWithFilters/EmptyDataWithFilters.styled.tsx @@ -0,0 +1,18 @@ +import {Space} from 'antd'; + +import styled from 'styled-components'; + +export const ButtonContainer = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +`; + +export const EmptyTestsDataContainer = styled(Space)` + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; +`; diff --git a/packages/web/src/components/organisms/EntityList/EntityListContent/EmptyDataWithFilters.tsx b/packages/web/src/components/organisms/EntityView/EmptyDataWithFilters/EmptyDataWithFilters.tsx similarity index 64% rename from packages/web/src/components/organisms/EntityList/EntityListContent/EmptyDataWithFilters.tsx rename to packages/web/src/components/organisms/EntityView/EmptyDataWithFilters/EmptyDataWithFilters.tsx index 1e36faa5e..e45875088 100644 --- a/packages/web/src/components/organisms/EntityList/EntityListContent/EmptyDataWithFilters.tsx +++ b/packages/web/src/components/organisms/EntityView/EmptyDataWithFilters/EmptyDataWithFilters.tsx @@ -1,7 +1,3 @@ -import {Space} from 'antd'; - -import styled from 'styled-components'; - import {ReactComponent as EmptySearch} from '@assets/empty-search.svg'; import {Button, Text, Title} from '@custom-antd'; @@ -10,15 +6,7 @@ import {SystemAccess, useSystemAccess} from '@hooks/useSystemAccess'; import Colors from '@styles/Colors'; -import {StyledButtonContainer} from './EntityListContent.styled'; - -const StyledEmptyTestsDataContainer = styled(Space)` - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - justify-content: center; -`; +import * as S from './EmptyDataWithFilters.styled'; const EmptyDataWithFilters: React.FC = props => { const {resetFilters} = props; @@ -26,18 +14,20 @@ const EmptyDataWithFilters: React.FC = props => { const isClusterAvailable = useSystemAccess(SystemAccess.agent); return ( - + No results found + We couldn’t find any results for your filters. - + + - - + + ); }; diff --git a/packages/web/src/components/organisms/EntityView/EmptyDataWithFilters/index.ts b/packages/web/src/components/organisms/EntityView/EmptyDataWithFilters/index.ts new file mode 100644 index 000000000..bc8d8dcb0 --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EmptyDataWithFilters/index.ts @@ -0,0 +1 @@ +export {default} from './EmptyDataWithFilters'; diff --git a/packages/web/src/components/organisms/EntityView/EntityView.styled.tsx b/packages/web/src/components/organisms/EntityView/EntityView.styled.tsx new file mode 100644 index 000000000..6b01b69fc --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EntityView.styled.tsx @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +import {Button} from '@custom-antd'; + +export const AddButton = styled(Button)` + height: 46px; +`; + +export const FiltersSection = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; +`; diff --git a/packages/web/src/components/organisms/EntityList/EntityListContent/EntityListContent.tsx b/packages/web/src/components/organisms/EntityView/EntityView.tsx similarity index 53% rename from packages/web/src/components/organisms/EntityList/EntityListContent/EntityListContent.tsx rename to packages/web/src/components/organisms/EntityView/EntityView.tsx index bc9dc6704..70bf11f71 100644 --- a/packages/web/src/components/organisms/EntityList/EntityListContent/EntityListContent.tsx +++ b/packages/web/src/components/organisms/EntityView/EntityView.tsx @@ -1,16 +1,17 @@ -import React, {memo, useEffect, useState} from 'react'; +import {useCallback, useEffect, useMemo, useState} from 'react'; import {useSearchParams} from 'react-router-dom'; +import {useEffectOnce} from 'react-use'; import {isEqual, merge} from 'lodash'; -import {Button} from '@custom-antd'; - import {SystemAccess, useSystemAccess} from '@hooks/useSystemAccess'; import useTrackTimeAnalytics from '@hooks/useTrackTimeAnalytics'; -import {EntityListBlueprint} from '@models/entity'; +import {EntityViewBlueprint} from '@models/entity'; +import {TestWithExecution} from '@models/test'; +import {TestSuiteWithExecution} from '@models/testSuite'; -import {EntityGrid} from '@molecules'; +import EntityGrid from '@molecules/EntityGrid'; import {PageHeader, PageToolbar, PageWrapper} from '@organisms'; @@ -18,49 +19,85 @@ import PageMetadata from '@pages/PageMetadata'; import {Permissions, usePermission} from '@permissions/base'; +import {useTestsSlotFirst} from '@plugins/tests-and-test-suites/hooks'; + import {initialPageSize} from '@redux/initialState'; import {useApiEndpoint} from '@services/apiEndpoint'; -import Filters from '../EntityListFilters'; - import EmptyDataWithFilters from './EmptyDataWithFilters'; -import {StyledFiltersSection} from './EntityListContent.styled'; +import * as S from './EntityView.styled'; +import EntityViewFilters from './EntityViewFilters'; + +export type ViewComponentBaseProps = { + data?: TestWithExecution[] | TestSuiteWithExecution[]; + empty?: JSX.Element; + hasMore?: boolean; + loadingInitially?: boolean; + loadingMore?: boolean; + onScrollEnd?: () => void; +}; -const EntityListContent: React.FC = props => { +const EntityView: React.FC = props => { const { - itemKey, - pageTitle, - pageTitleAddon, - pageDescription: PageDescription, - emptyDataComponent: EmptyData, + addEntityButtonText, CardComponent, + data, + dataTest, + emptyDataComponent: EmptyData, entity, initialFiltersState, - addEntityButtonText, - dataTest, - isLoading = false, - isFetching = false, + isFetching, + isLoading, + itemKey, + pageDescription, + pageTitle, + pageTitleAddon, queryFilters, - data, - setQueryFilters, - onAdd, - onItemClick, - onItemAbort, } = props; + const {onAdd, onItemClick, setQueryFilters} = props; - const [isFirstTimeLoading, setFirstTimeLoading] = useState(!data?.length); const [isApplyingFilters, setIsApplyingFilters] = useState(false); + const [isFirstTimeLoading, setFirstTimeLoading] = useState(!data?.length); const [isLoadingNext, setIsLoadingNext] = useState(false); + const apiEndpoint = useApiEndpoint(); + const canCreateEntity = usePermission(Permissions.createEntity); const isReadable = useSystemAccess(SystemAccess.system); const isWritable = useSystemAccess(SystemAccess.agent); - const apiEndpoint = useApiEndpoint(); - const mayCreate = usePermission(Permissions.createEntity); const [searchParams, setSearchParams] = useSearchParams(); - useEffect(() => { + const createButton = useMemo(() => { + if (canCreateEntity && isWritable) { + return ( + + {addEntityButtonText} + + ); + } + + return null; + }, [addEntityButtonText, canCreateEntity, dataTest, isWritable, onAdd]); + + const isFiltersEmpty = isEqual(initialFiltersState, queryFilters); + const isEmptyData = useMemo( + () => !data?.length && isFiltersEmpty && !isLoading, + [data?.length, isFiltersEmpty, isLoading] + ); + + const onScrollBottom = useCallback(() => { + setIsLoadingNext(true); + setQueryFilters({...queryFilters, pageSize: queryFilters.pageSize + initialPageSize}); + }, [queryFilters, setQueryFilters]); + + const resetFilters = useCallback(() => { + setQueryFilters(initialFiltersState); + }, [initialFiltersState, setQueryFilters]); + + useTrackTimeAnalytics(`${entity}-list`); + + useEffectOnce(() => { const filters = merge({}, initialFiltersState, queryFilters, { textSearch: searchParams.get('textSearch') ?? undefined, status: searchParams.get('status')?.split(',').filter(Boolean) ?? undefined, @@ -69,10 +106,12 @@ const EntityListContent: React.FC = props => { if (!isEqual(filters, queryFilters)) { setQueryFilters(filters); } - }, []); + }); useEffect(() => { + setIsApplyingFilters(true); const newSearchParams = new URLSearchParams(searchParams); + if (queryFilters.textSearch) { newSearchParams.set('textSearch', queryFilters.textSearch); } else { @@ -91,15 +130,6 @@ const EntityListContent: React.FC = props => { setSearchParams(newSearchParams); }, [queryFilters]); - const resetFilters = () => { - setQueryFilters(initialFiltersState); - }; - - const onScrollBottom = () => { - setIsLoadingNext(true); - setQueryFilters({...queryFilters, pageSize: queryFilters.pageSize + initialPageSize}); - }; - useEffect(() => { if (!isLoading && !isFetching) { setFirstTimeLoading(false); @@ -112,10 +142,6 @@ const EntityListContent: React.FC = props => { } }, [entity, apiEndpoint]); - useEffect(() => { - setIsApplyingFilters(true); - }, [queryFilters]); - useEffect(() => { if (!isFetching) { setIsLoadingNext(false); @@ -123,17 +149,22 @@ const EntityListContent: React.FC = props => { } }, [isFetching]); - const isFiltersEmpty = isEqual(initialFiltersState, queryFilters); - const isEmptyData = !data?.length && isFiltersEmpty && !isLoading; - - useTrackTimeAnalytics(`${entity}-list`); + const DefaultViewComponent: React.FC = useMemo( + () => componentProps => + ( + + ), + [CardComponent, EmptyData] + ); - const createButton = - mayCreate && isWritable ? ( - - ) : null; + const ViewComponent = useTestsSlotFirst('EntityViewComponent', [{value: DefaultViewComponent, metadata: {order: 2}}]); return ( @@ -142,41 +173,38 @@ const EntityListContent: React.FC = props => { } + description={pageDescription} loading={isApplyingFilters && !isFirstTimeLoading} > - - + - + - - ) : ( - - ) - } - itemHeight={163.85} - loadingInitially={isFirstTimeLoading} - loadingMore={isLoadingNext} - hasMore={!isLoadingNext && data && queryFilters.pageSize <= data.length} - onScrollEnd={onScrollBottom} - /> + {ViewComponent && ( + + ) : ( + + ) + } + hasMore={!isLoadingNext && data && queryFilters.pageSize <= data.length} + loadingMore={isLoadingNext} + onScrollEnd={onScrollBottom} + /> + )} ); }; -export default memo(EntityListContent); +export default EntityView; diff --git a/packages/web/src/components/organisms/EntityView/EntityViewFilters/EntityViewFilters.styled.tsx b/packages/web/src/components/organisms/EntityView/EntityViewFilters/EntityViewFilters.styled.tsx new file mode 100644 index 000000000..4f2f0e943 --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/EntityViewFilters.styled.tsx @@ -0,0 +1,44 @@ +import styled from 'styled-components'; + +import {maxDevice} from '@styles/MediaQueries'; + +export const FiltersContainer = styled.div<{$hasSwitch: boolean}>` + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: center; + width: 100%; + + ${({$hasSwitch}) => { + if ($hasSwitch) { + return ` + & > *:not(:last-child) { + flex: 1 1 auto; + min-width: 160px; + max-width: 248px; + } + + & > *:last-child { + flex: 0 0 auto; + width: max-content; + } + `; + } + + return ` + & > * { + flex: 1 1 auto; + min-width: 160px; + max-width: 248px; + } + `; + }} + + @media ${maxDevice.tablet} { + width: 100%; + + & > * { + width: 100%; + } + } +`; diff --git a/packages/web/src/components/organisms/EntityView/EntityViewFilters/EntityViewFilters.tsx b/packages/web/src/components/organisms/EntityView/EntityViewFilters/EntityViewFilters.tsx new file mode 100644 index 000000000..b0023ad29 --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/EntityViewFilters.tsx @@ -0,0 +1,28 @@ +import {FC, memo} from 'react'; + +import {EntityFilters} from '@models/entity'; + +import {useTestsSlotFirst} from '@plugins/tests-and-test-suites/hooks'; + +import * as S from './EntityViewFilters.styled'; +import LabelsFilter from './LabelsFilter'; +import StatusFilter from './StatusFilter'; +import TextSearchFilter from './TextSearchFilter'; + +const EntityViewFilters: FC = props => { + const {disabled, ...rest} = props; + + const switchComponent = useTestsSlotFirst('entityViewSwitch'); + + return ( + + + + + + {switchComponent} + + ); +}; + +export default memo(EntityViewFilters); diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/LabelsFilter/LabelsFilter.styled.ts b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/KeyValueInput.styled.tsx similarity index 76% rename from packages/web/src/components/organisms/EntityList/EntityListFilters/LabelsFilter/LabelsFilter.styled.ts rename to packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/KeyValueInput.styled.tsx index b0590d6ab..8263d932d 100644 --- a/packages/web/src/components/organisms/EntityList/EntityListFilters/LabelsFilter/LabelsFilter.styled.ts +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/KeyValueInput.styled.tsx @@ -2,25 +2,6 @@ import {AutoComplete as AntAutoComplete, AutoCompleteProps} from 'antd'; import styled from 'styled-components'; -export const StyledLabelsMenuContainer = styled.div` - display: flex; - flex-direction: column; - gap: 16px; - - padding: 24px; -`; - -export const StyledKeyValueRow = styled.div` - display: flex; - flex-direction: row; - align-items: center; - gap: 16px; -`; - -export const EmptyButton = styled.div` - width: 45px; -`; - export interface ICustomAutoCompleteProps extends AutoCompleteProps { color?: string; width?: string; @@ -32,3 +13,14 @@ export const AutoComplete = styled(AntAutoComplete)` ${props => (props.color ? `color: ${props.color};` : '')} } `; + +export const EmptyButton = styled.div` + width: 45px; +`; + +export const KeyValueRow = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +`; diff --git a/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/KeyValueInput.tsx b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/KeyValueInput.tsx new file mode 100644 index 000000000..2c7f7e1ff --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/KeyValueInput.tsx @@ -0,0 +1,83 @@ +import {useCallback} from 'react'; + +import {Button} from '@custom-antd'; + +import {Entity} from '@models/entityMap'; + +import * as S from './KeyValueInput.styled'; + +type OptionsType = { + key: string; + label: string; + value: string; +}; + +type KeyValueInputProps = { + index: number; + itemKey: string; + itemValue: string; + keyOptions: OptionsType[]; + labelsMapping: Entity[]; + valuesOptions: OptionsType[]; + setLabelsMapping: React.Dispatch>; +}; + +const KeyValueInput: React.FC = props => { + const {index, itemKey, itemValue, keyOptions, labelsMapping, setLabelsMapping, valuesOptions, ...rest} = props; + + const onKeyChange = useCallback( + (key: string) => { + setLabelsMapping([ + ...labelsMapping.slice(0, index), + {key, value: labelsMapping[index].value}, + ...labelsMapping.slice(index + 1), + ]); + }, + [index, labelsMapping, setLabelsMapping] + ); + + const onValueChange = useCallback( + (value: string) => { + setLabelsMapping([ + ...labelsMapping.slice(0, index), + {key: labelsMapping[index].key, value}, + ...labelsMapping.slice(index + 1), + ]); + }, + [index, labelsMapping, setLabelsMapping] + ); + + const onDeleteRow = useCallback(() => { + setLabelsMapping([...labelsMapping.slice(0, index), ...labelsMapping.slice(index + 1)]); + }, [index, labelsMapping, setLabelsMapping]); + + return ( + + onKeyChange(key)} + value={itemKey} + data-testid={`key-input-${index}`} + placeholder="Key" + /> + onValueChange(value)} + value={itemValue} + data-testid={`value-input-${index}`} + placeholder="Value" + /> + {index > 0 ? ( + + ) : ( + + )} + + ); +}; + +export default KeyValueInput; diff --git a/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/LabelsFilter.styled.tsx b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/LabelsFilter.styled.tsx new file mode 100644 index 000000000..510991745 --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/LabelsFilter.styled.tsx @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const LabelsMenuContainer = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; +`; diff --git a/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/LabelsFilters.tsx b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/LabelsFilters.tsx new file mode 100644 index 000000000..bd78bd0a1 --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/LabelsFilters.tsx @@ -0,0 +1,143 @@ +import {useCallback, useEffect, useMemo, useState} from 'react'; + +import {FilterFilled} from '@ant-design/icons'; + +import {Button, Title} from '@custom-antd'; + +import {SystemAccess, useSystemAccess} from '@hooks/useSystemAccess'; + +import {EntityFilters} from '@models/entity'; +import {Entity} from '@models/entityMap'; + +import {FilterMenuFooter, StyledFilterDropdown, StyledFilterLabel, StyledFilterMenu} from '@molecules/FilterMenu'; + +import {initialPageSize} from '@redux/initialState'; + +import {useGetLabelsQuery} from '@services/labels'; + +import Colors from '@styles/Colors'; + +import {PollingIntervals} from '@utils/numbers'; +import {decodeSelectorArray, encodeSelectorArray} from '@utils/selectors'; + +import KeyValueInput from './KeyValueInput'; +import * as S from './LabelsFilter.styled'; + +const defaultKeyValuePair: Entity = { + key: '', + value: '', +}; + +const LabelsFilter: React.FC = props => { + const {disabled, filters, setFilters} = props; + + const isClusterAvailable = useSystemAccess(SystemAccess.agent); + + const {data} = useGetLabelsQuery(null, { + pollingInterval: PollingIntervals.default, + skip: !isClusterAvailable, + }); + + const [isVisible, setIsVisible] = useState(false); + const [labelsMapping, setLabelsMapping] = useState([]); + + const onOpenChange = useCallback((flag: boolean) => { + setIsVisible(flag); + }, []); + + const applyFilters = useCallback(() => { + const selector = encodeSelectorArray(labelsMapping); + setFilters({...filters, selector, pageSize: initialPageSize}); + onOpenChange(false); + }, [filters, labelsMapping, onOpenChange, setFilters]); + + const onAddRow = useCallback(() => { + setLabelsMapping([...labelsMapping, defaultKeyValuePair]); + }, [labelsMapping]); + + const resetFilters = useCallback(() => { + setLabelsMapping([defaultKeyValuePair]); + onOpenChange(false); + setFilters({...filters, selector: '', pageSize: initialPageSize}); + }, [filters, onOpenChange, setFilters]); + + const keysLabels = useMemo(() => Object.keys(data ?? {}).map(key => ({key, label: key, value: key})), [data]); + + const valuesLabels = useMemo( + () => (data ? keysLabels.map(item => data[item.key].map(v => ({key: item.key, value: v}))).flat() : []), + [data, keysLabels] + ); + + const renderedKeyValueInputs = useMemo( + () => + labelsMapping.map((item, index) => { + const key = `key-value-pair${index}`; + + const keyOptions = keysLabels.filter(f => f.key.startsWith(item.key)); + + const valuesOptions = valuesLabels + .filter(f => f.key === item.key && f.value.startsWith(item.value)) + .map(v => ({key: v.value, label: v.value, value: v.value})); + + return ( + + ); + }), + [keysLabels, labelsMapping, valuesLabels] + ); + + const menu = useMemo( + () => ( + { + if (e.key === 'Enter') { + applyFilters(); + } + }} + > + + Filter tests by Key Value pairs. + {renderedKeyValueInputs} + + + + + + ), + [applyFilters, onAddRow, renderedKeyValueInputs, resetFilters] + ); + + useEffect(() => { + const mapping = decodeSelectorArray(filters.selector); + setLabelsMapping(mapping.length === 0 ? [defaultKeyValuePair] : mapping); + }, [filters.selector]); + + return ( + + e.preventDefault()}> + Labels 0 ? Colors.purple : Colors.slate500}} /> + + + ); +}; + +export default LabelsFilter; diff --git a/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/index.ts b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/index.ts new file mode 100644 index 000000000..d356a4834 --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/LabelsFilter/index.ts @@ -0,0 +1 @@ +export {default} from './LabelsFilters'; diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/StatusFilter/StatusFilter.tsx b/packages/web/src/components/organisms/EntityView/EntityViewFilters/StatusFilter/StatusFilter.tsx similarity index 79% rename from packages/web/src/components/organisms/EntityList/EntityListFilters/StatusFilter/StatusFilter.tsx rename to packages/web/src/components/organisms/EntityView/EntityViewFilters/StatusFilter/StatusFilter.tsx index 2507807dd..b81fc1efd 100644 --- a/packages/web/src/components/organisms/EntityList/EntityListFilters/StatusFilter/StatusFilter.tsx +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/StatusFilter/StatusFilter.tsx @@ -4,7 +4,7 @@ import {FilterFilled} from '@ant-design/icons'; import {capitalize} from 'lodash'; -import {FilterProps} from '@models/filters'; +import {EntityFilters} from '@models/entity'; import { FilterMenuFooter, @@ -21,8 +21,8 @@ import Colors from '@styles/Colors'; const statusList = ['queued', 'running', 'passed', 'failed', 'aborted']; -const StatusFilter: React.FC = props => { - const {filters, setFilters, isFiltersDisabled} = props; +const StatusFilter: React.FC = props => { + const {filters, setFilters, disabled} = props; const [isVisible, setVisibilityState] = useState(false); @@ -53,7 +53,7 @@ const StatusFilter: React.FC = props => { handleClick(status)} - data-cy={status} + data-testid={status} > {capitalize(status)} @@ -74,8 +74,6 @@ const StatusFilter: React.FC = props => { ); - const isFilterApplied = filters.status.length > 0; - return ( = props => { placement="bottom" onOpenChange={onOpenChange} open={isVisible} - disabled={isFiltersDisabled} + disabled={disabled} > - e.preventDefault()} - data-cy="status-filter-button" - isFiltersDisabled={isFiltersDisabled} - > - Status + e.preventDefault()} data-cy="status-filter-button" $disabled={disabled}> + Status 0 ? Colors.purple : Colors.slate500}} /> ); diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/StatusFilter/index.ts b/packages/web/src/components/organisms/EntityView/EntityViewFilters/StatusFilter/index.ts similarity index 100% rename from packages/web/src/components/organisms/EntityList/EntityListFilters/StatusFilter/index.ts rename to packages/web/src/components/organisms/EntityView/EntityViewFilters/StatusFilter/index.ts diff --git a/packages/web/src/components/organisms/EntityView/EntityViewFilters/TextSearchFilter/TextSearchFilter.styled.tsx b/packages/web/src/components/organisms/EntityView/EntityViewFilters/TextSearchFilter/TextSearchFilter.styled.tsx new file mode 100644 index 000000000..85b0fabcd --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/TextSearchFilter/TextSearchFilter.styled.tsx @@ -0,0 +1,8 @@ +import {Input} from 'antd'; + +import styled from 'styled-components'; + +export const SearchInput = styled(Input)` + height: 46px; + width: auto; +`; diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/TextSearchFilter/TextSearchFilter.tsx b/packages/web/src/components/organisms/EntityView/EntityViewFilters/TextSearchFilter/TextSearchFilter.tsx similarity index 78% rename from packages/web/src/components/organisms/EntityList/EntityListFilters/TextSearchFilter/TextSearchFilter.tsx rename to packages/web/src/components/organisms/EntityView/EntityViewFilters/TextSearchFilter/TextSearchFilter.tsx index c721801c5..f71ac68b0 100644 --- a/packages/web/src/components/organisms/EntityList/EntityListFilters/TextSearchFilter/TextSearchFilter.tsx +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/TextSearchFilter/TextSearchFilter.tsx @@ -2,16 +2,17 @@ import {useEffect, useState} from 'react'; import {useDebounce} from 'react-use'; import {SearchOutlined} from '@ant-design/icons'; -import {Input} from 'antd'; -import {FilterProps} from '@models/filters'; +import {EntityFilters} from '@models/entity'; import {initialPageSize} from '@redux/initialState'; import Colors from '@styles/Colors'; -const TextSearchFilter: React.FC = props => { - const {filters, setFilters, isFiltersDisabled} = props; +import * as S from './TextSearchFilter.styled'; + +const TextSearchFilter: React.FC = props => { + const {filters, setFilters, disabled} = props; const [inputValue, setInputValue] = useState(filters.textSearch); @@ -26,6 +27,7 @@ const TextSearchFilter: React.FC = props => { 300, [inputValue] ); + useEffect(cancel, []); useEffect(() => { @@ -33,13 +35,13 @@ const TextSearchFilter: React.FC = props => { }, [filters]); return ( - } placeholder="Search" onChange={onChange} value={inputValue} data-cy="search-filter" - disabled={isFiltersDisabled} + disabled={disabled} /> ); }; diff --git a/packages/web/src/components/organisms/EntityList/EntityListFilters/TextSearchFilter/index.ts b/packages/web/src/components/organisms/EntityView/EntityViewFilters/TextSearchFilter/index.ts similarity index 100% rename from packages/web/src/components/organisms/EntityList/EntityListFilters/TextSearchFilter/index.ts rename to packages/web/src/components/organisms/EntityView/EntityViewFilters/TextSearchFilter/index.ts diff --git a/packages/web/src/components/organisms/EntityView/EntityViewFilters/index.ts b/packages/web/src/components/organisms/EntityView/EntityViewFilters/index.ts new file mode 100644 index 000000000..09b5b7ce9 --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/EntityViewFilters/index.ts @@ -0,0 +1 @@ +export {default} from './EntityViewFilters'; diff --git a/packages/web/src/components/organisms/EntityView/index.ts b/packages/web/src/components/organisms/EntityView/index.ts new file mode 100644 index 000000000..bd48c733e --- /dev/null +++ b/packages/web/src/components/organisms/EntityView/index.ts @@ -0,0 +1 @@ +export {default} from './EntityView'; diff --git a/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/ExecutionsFilters.styled.tsx b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/ExecutionsFilters.styled.tsx new file mode 100644 index 000000000..a134d81f5 --- /dev/null +++ b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/ExecutionsFilters.styled.tsx @@ -0,0 +1,17 @@ +import {Input} from 'antd'; + +import styled from 'styled-components'; + +export const FiltersContainer = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(120px, 248px)); + grid-column-gap: 16px; + align-items: center; + width: 100%; + margin: 8px 0 24px; +`; + +export const SearchInput = styled(Input)` + height: 46px; + width: auto; +`; diff --git a/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/ExecutionsFilters.tsx b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/ExecutionsFilters.tsx new file mode 100644 index 000000000..8941966c9 --- /dev/null +++ b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/ExecutionsFilters.tsx @@ -0,0 +1,52 @@ +import {useEffect, useState} from 'react'; +import {useDebounce} from 'react-use'; + +import {SearchOutlined} from '@ant-design/icons'; + +import {useEntityDetailsField, useEntityDetailsPick} from '@store/entityDetails'; + +import Colors from '@styles/Colors'; + +import * as S from './ExecutionsFilters.styled'; +import ExecutionsStatusFilter from './ExecutionsStatusFilter'; + +const ExecutionsFilters: React.FC = () => { + const [executionsFilters, setExecutionsFilters] = useEntityDetailsField('executionsFilters'); + const {executionsLoading} = useEntityDetailsPick('executionsLoading'); + + const [searchInputValue, setSearchInputValue] = useState(executionsFilters.textSearch); + + const onChange = (e: React.ChangeEvent) => { + setSearchInputValue(e.target.value); + }; + + const [, cancel] = useDebounce( + () => { + setExecutionsFilters({...executionsFilters, textSearch: searchInputValue}); + }, + 300, + [searchInputValue] + ); + + useEffect(() => { + return () => { + cancel(); + }; + }, [cancel]); + + return ( + + } + placeholder="Search execution" + data-cy="executions-search-filter" + value={searchInputValue} + onChange={onChange} + disabled={executionsLoading} + /> + + + ); +}; + +export default ExecutionsFilters; diff --git a/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/ExecutionsStatusFilter.tsx b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/ExecutionsStatusFilter.tsx new file mode 100644 index 000000000..6d7baa875 --- /dev/null +++ b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/ExecutionsStatusFilter.tsx @@ -0,0 +1,95 @@ +import {useCallback, useMemo, useState} from 'react'; + +import {FilterFilled} from '@ant-design/icons'; + +import {capitalize} from 'lodash'; + +import {ExecutionStatusEnum, executionStatusList} from '@models/execution'; + +import { + FilterMenuFooter, + StyledFilterCheckbox, + StyledFilterDropdown, + StyledFilterLabel, + StyledFilterMenu, + StyledFilterMenuItem, +} from '@molecules/FilterMenu'; + +import {useEntityDetailsField, useEntityDetailsPick} from '@store/entityDetails'; + +import Colors from '@styles/Colors'; + +const ExecutionsStatusFilter: React.FC = () => { + const [executionsFilters, setExecutionsFilters] = useEntityDetailsField('executionsFilters'); + const {executionsLoading} = useEntityDetailsPick('executionsLoading'); + + const [isVisible, setVisibilityState] = useState(false); + + const handleClick = useCallback( + (status: ExecutionStatusEnum) => { + if (executionsFilters.status.includes(status)) { + setExecutionsFilters({ + ...executionsFilters, + status: executionsFilters.status.filter((currentStatus: string) => { + return status !== currentStatus; + }), + }); + } else { + setExecutionsFilters({ + ...executionsFilters, + status: [...executionsFilters.status, status], + }); + } + }, + [executionsFilters, setExecutionsFilters] + ); + + const renderedStatuses = useMemo(() => { + return executionStatusList.map(status => { + return ( + + handleClick(status)} + data-testid={status} + > + {capitalize(status)} + + + ); + }); + }, [executionsFilters.status, handleClick]); + + const resetFilter = () => { + setExecutionsFilters({...executionsFilters, status: []}); + setVisibilityState(false); + }; + + const menu = ( + + {renderedStatuses} + setVisibilityState(false)} /> + + ); + + return ( + setVisibilityState(value)} + open={isVisible} + disabled={executionsLoading} + > + e.preventDefault()} + data-cy="executions-status-filter-button" + $disabled={executionsLoading} + > + Status + + + ); +}; + +export default ExecutionsStatusFilter; diff --git a/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/index.ts b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/index.ts new file mode 100644 index 000000000..aafb786f9 --- /dev/null +++ b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsFilters/index.ts @@ -0,0 +1 @@ +export {default} from './ExecutionsFilters'; diff --git a/packages/web/src/components/organisms/ExecutionsTable/ExecutionsTable.tsx b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsTable.tsx index 2649e6e1c..362752d9d 100644 --- a/packages/web/src/components/organisms/ExecutionsTable/ExecutionsTable.tsx +++ b/packages/web/src/components/organisms/ExecutionsTable/ExecutionsTable.tsx @@ -10,10 +10,13 @@ import {Skeleton} from '@custom-antd'; import {SystemAccess, useSystemAccess} from '@hooks/useSystemAccess'; +import EmptyDataWithFilters from '@organisms/EntityView/EmptyDataWithFilters'; + import {useEntityDetailsField, useEntityDetailsPick} from '@store/entityDetails'; import {useExecutionDetailsPick} from '@store/executionDetails'; import EmptyExecutionsListContent from './EmptyExecutionsListContent'; +import ExecutionsFilters from './ExecutionsFilters'; import TableRow from './TableRow'; interface ExecutionsTableProps { @@ -25,7 +28,13 @@ const getKey = (record: any) => record.id; const ExecutionsTable: React.FC = ({onRun, useAbortExecution}) => { const [currentPage, setCurrentPage] = useEntityDetailsField('currentPage'); - const {executions, id, isFirstTimeLoading} = useEntityDetailsPick('executions', 'id', 'isFirstTimeLoading'); + const [executionsFilters, setExecutionsFilters] = useEntityDetailsField('executionsFilters'); + const {executions, executionsLoading, id, isFirstTimeLoading} = useEntityDetailsPick( + 'executions', + 'id', + 'isFirstTimeLoading', + 'executionsLoading' + ); const {id: execId, open} = useExecutionDetailsPick('id', 'open'); const isWritable = useSystemAccess(SystemAccess.agent); @@ -45,10 +54,14 @@ const ExecutionsTable: React.FC = ({onRun, useAbortExecuti onChange: setCurrentPage, showSizeChanger: false, }), - [currentPage] + [currentPage, setCurrentPage] ); - const isEmptyExecutions = !executions?.results || !executions?.results.length; + const isFiltering = useMemo( + () => Boolean(executionsFilters.textSearch.trim().length || executionsFilters.status.length), + [executionsFilters] + ); + const isEmptyExecutions = useMemo(() => !isFiltering && !executions?.results.length, [executions, isFiltering]); const [abortExecution] = useAbortExecution(); const onAbortExecution = useCallback( @@ -75,30 +88,44 @@ const ExecutionsTable: React.FC = ({onRun, useAbortExecuti ); if (isFirstTimeLoading) { - return ( - <> - - - - - ); + return ; } - if (isEmptyExecutions) { + if (isEmptyExecutions && !isFiltering) { return ; } return ( - + <> + + + {isFiltering && executionsLoading ? ( + + ) : isFiltering && !executionsLoading && !executions?.results.length ? ( + setExecutionsFilters({textSearch: '', status: []})} /> + ) : ( +
+ )} + + ); +}; + +const LoadingSkeleton = () => { + return ( + <> + + + + ); }; diff --git a/packages/web/src/components/organisms/PageBlueprint/PageBlueprint.styled.tsx b/packages/web/src/components/organisms/PageBlueprint/PageBlueprint.styled.tsx index c853aa37d..3329d73c7 100644 --- a/packages/web/src/components/organisms/PageBlueprint/PageBlueprint.styled.tsx +++ b/packages/web/src/components/organisms/PageBlueprint/PageBlueprint.styled.tsx @@ -14,7 +14,8 @@ export const PageWrapper = styled.div` `; export const ToolbarContainer = styled.div` - display: flex; + display: grid; + grid-template-columns: 1fr max-content; flex-wrap: wrap; align-items: center; gap: 20px; @@ -45,6 +46,9 @@ export const StyledPageHeader = styled(AntdPageHeader)` export const PageTitle = styled(Paragraph)` min-width: 0; flex: 1; + display: flex; + align-items: center; + gap: 12px; &.ant-typography, .ant-typography p { diff --git a/packages/web/src/components/organisms/PageBlueprint/PageBlueprint.tsx b/packages/web/src/components/organisms/PageBlueprint/PageBlueprint.tsx index cf510603b..8f989cd6c 100644 --- a/packages/web/src/components/organisms/PageBlueprint/PageBlueprint.tsx +++ b/packages/web/src/components/organisms/PageBlueprint/PageBlueprint.tsx @@ -7,7 +7,7 @@ import PageHeader from './PageHeader'; type PageBlueprintProps = { title: string; - description: React.ReactNode; + description?: React.ReactNode; headerButton?: React.ReactNode; }; diff --git a/packages/web/src/components/organisms/TestConfigurationForm/StringContentFields/StringContentFields.tsx b/packages/web/src/components/organisms/TestConfigurationForm/StringContentFields/StringContentFields.tsx index 23ae719d1..bc7bed3a4 100644 --- a/packages/web/src/components/organisms/TestConfigurationForm/StringContentFields/StringContentFields.tsx +++ b/packages/web/src/components/organisms/TestConfigurationForm/StringContentFields/StringContentFields.tsx @@ -36,7 +36,7 @@ const StringContentFields: React.FC> = props => { const placeholder = stringPlaceholders[executorType!] || 'String...'; return ( -