mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-28 22:30:43 +01:00
Compare commits
8 Commits
test/inter
...
bottom-str
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af94bf33a | ||
|
|
a27d5b6a02 | ||
|
|
57dadb5745 | ||
|
|
5dada860f0 | ||
|
|
7699337bd7 | ||
|
|
663b98690b | ||
|
|
5378e2d0e7 | ||
|
|
56365625dd |
@@ -23,10 +23,6 @@ const IGNORED_MESSAGES = [
|
||||
// (YouTube embeds, the docs pane) so they hit the real network instead of
|
||||
// an unanswered msw request; the block is the point, not a bug.
|
||||
/violates the following Content Security Policy directive/,
|
||||
// The filter editor's ANTLR parser reports every syntax error through
|
||||
// `console.error` (`line 1:14 missing ...`), so each partial expression
|
||||
// typed into it logs one; the editor shows the same errors on screen.
|
||||
/^line \d+:\d+ /,
|
||||
];
|
||||
|
||||
interface CapturedMessage {
|
||||
|
||||
@@ -12,13 +12,14 @@ import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
|
||||
import NotFound from 'components/NotFound';
|
||||
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AppLayout from 'container/AppLayout';
|
||||
import Hex from 'crypto-js/enc-hex';
|
||||
import HmacSHA256 from 'crypto-js/hmac-sha256';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
@@ -58,7 +59,6 @@ function App(): JSX.Element {
|
||||
isFetchingActiveLicense,
|
||||
activeLicenseFetchError,
|
||||
userFetchError,
|
||||
featureFlagsFetchError,
|
||||
isLoggedIn: isLoggedInState,
|
||||
featureFlags,
|
||||
org,
|
||||
@@ -66,6 +66,8 @@ function App(): JSX.Element {
|
||||
} = useAppContext();
|
||||
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
const chatSupport = useChatSupport();
|
||||
|
||||
const { hostname } = window.location;
|
||||
const [pathname, setPathname] = useState(history.location.pathname);
|
||||
@@ -253,7 +255,9 @@ function App(): JSX.Element {
|
||||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// The bottom strip carries Support, so the floating bubble goes entirely.
|
||||
if (
|
||||
isSavedViewEnabled ||
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname === '/ai-assistant' ||
|
||||
@@ -263,71 +267,32 @@ function App(): JSX.Element {
|
||||
} else {
|
||||
window.Pylon?.('showChatBubble');
|
||||
}
|
||||
}, [pathname]);
|
||||
}, [pathname, isSavedViewEnabled]);
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
// Identity for the Pylon widget. Whether this user gets Pylon at all is
|
||||
// `useChatSupport`'s call — this only fills in who they are.
|
||||
useEffect(() => {
|
||||
// feature flag shouldn't be loading and featureFlags or fetchError any one of this should be true indicating that req is complete
|
||||
// licenses should also be present. there is no check for licenses for loading and error as that is mandatory if not present then routing
|
||||
// to something went wrong which would ideally need a reload.
|
||||
if (
|
||||
!isFetchingFeatureFlags &&
|
||||
(featureFlags || featureFlagsFetchError) &&
|
||||
activeLicense &&
|
||||
trialInfo
|
||||
) {
|
||||
let isChatSupportEnabled = false;
|
||||
let isPremiumSupportEnabled = false;
|
||||
if (featureFlags && featureFlags.length > 0) {
|
||||
isChatSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.CHAT_SUPPORT)
|
||||
?.active || false;
|
||||
|
||||
isPremiumSupportEnabled =
|
||||
featureFlags.find((flag) => flag.name === FeatureKeys.PREMIUM_SUPPORT)
|
||||
?.active || false;
|
||||
}
|
||||
const showAddCreditCardModal =
|
||||
!isPremiumSupportEnabled && !trialInfo?.trialConvertedToSubscription;
|
||||
|
||||
if (
|
||||
isLoggedInState &&
|
||||
isChatSupportEnabled &&
|
||||
!showAddCreditCardModal &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) &&
|
||||
window.signozBootData?.settings?.pylon?.enabled
|
||||
) {
|
||||
const email = user.email || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
|
||||
}
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (chatSupport !== ChatSupportState.Pylon) {
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
isLoggedInState,
|
||||
user,
|
||||
pathname,
|
||||
trialInfo?.trialConvertedToSubscription,
|
||||
featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
activeLicense,
|
||||
trialInfo,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
]);
|
||||
|
||||
const email = user.email || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
|
||||
}
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
},
|
||||
};
|
||||
}, [chatSupport, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingUser && isCloudUser && user && user.email) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, X } from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
interface AddCreditCardModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAddCreditCard?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown to trial users who have not added a card, in place of chat support.
|
||||
* Submitting creates the subscription and opens the returned billing URL.
|
||||
*/
|
||||
function AddCreditCardModal({
|
||||
open,
|
||||
onClose,
|
||||
onAddCreditCard,
|
||||
}: AddCreditCardModalProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{ onSuccess: handleBillingOnSuccess, onError: handleBillingOnError },
|
||||
);
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
onAddCreditCard?.();
|
||||
updateCreditCard({ url: getBaseUrl() });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
open={open}
|
||||
closable
|
||||
onCancel={onClose}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={onClose}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>.
|
||||
Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddCreditCardModal.defaultProps = { onAddCreditCard: undefined };
|
||||
|
||||
export default AddCreditCardModal;
|
||||
@@ -1,63 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
export default function ChatSupportGateway(): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
onError: handleBillingOnError,
|
||||
},
|
||||
);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `chat support icon`,
|
||||
page: pathname,
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="chat-support-gateway">
|
||||
@@ -76,47 +28,16 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
onAddCreditCard={(): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `chat support icon`,
|
||||
page: pathname,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import './LaunchChatSupport.styles.scss';
|
||||
|
||||
@@ -41,7 +33,6 @@ function LaunchChatSupport({
|
||||
chatMessageDisabled = false,
|
||||
}: LaunchChatSupportProps): JSX.Element | null {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
trialInfo,
|
||||
featureFlags,
|
||||
@@ -119,43 +110,12 @@ function LaunchChatSupport({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
newTab.target = '_blank';
|
||||
newTab.rel = 'noopener noreferrer';
|
||||
newTab.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnError = (error: APIError): void => {
|
||||
notifications.error({
|
||||
message: error.getErrorCode(),
|
||||
description: error.getErrorMessage(),
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
onError: handleBillingOnError,
|
||||
},
|
||||
);
|
||||
|
||||
const handleAddCreditCard = (): void => {
|
||||
logEvent('Add Credit card modal: Clicked', {
|
||||
source: `facing issues button`,
|
||||
page: pathname,
|
||||
...attributes,
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
return isCloudUserVal && isChatSupportEnabled ? ( // Note: we would need to move this condition to license based in future
|
||||
@@ -175,47 +135,11 @@ function LaunchChatSupport({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Add Credit Card Modal */}
|
||||
<Modal
|
||||
className="add-credit-card-modal"
|
||||
title={<span className="title">Add Credit Card for Chat Support</span>}
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
closable
|
||||
onCancel={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
destroyOnClose
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
className="cancel-btn"
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
You're currently on <span className="highlight-text">Trial plan</span>
|
||||
. Add a credit card to access SigNoz chat support to your workspace.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
onAddCreditCard={handleAddCreditCard}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
/** Suggestions wait on a 300ms debounce and a fetch, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The editor is controlled: each change round-trips through React state before
|
||||
* the next one is applied on top of it. People type slower than this.
|
||||
*/
|
||||
const KEYSTROKE_MS = 50;
|
||||
|
||||
/** Throws until `found` holds something, which is what `waitFor` retries on. */
|
||||
const present = <TValue>(
|
||||
found: TValue | null | undefined,
|
||||
what: string,
|
||||
): TValue => {
|
||||
if (found === null || found === undefined) {
|
||||
throw new Error(`${what} not found`);
|
||||
}
|
||||
|
||||
return found;
|
||||
};
|
||||
|
||||
const pause = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const suggestionList = (canvasElement: HTMLElement): HTMLElement | null =>
|
||||
canvasElement.querySelector<HTMLElement>('.cm-tooltip-autocomplete');
|
||||
|
||||
const suggestionRow = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): HTMLElement | undefined => {
|
||||
const list = suggestionList(canvasElement);
|
||||
|
||||
return list
|
||||
? within(list)
|
||||
.queryAllByRole('option')
|
||||
.find((option) => option.textContent?.includes(text))
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Ctrl+Space, the editor's own shortcut for asking for suggestions. */
|
||||
const requestSuggestions = (editor: HTMLElement): void => {
|
||||
editor.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: ' ',
|
||||
code: 'Space',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const currentView = (
|
||||
canvasElement: HTMLElement,
|
||||
): { editor: HTMLElement; view: EditorView } => {
|
||||
// An explorer renders one editor per query; the first is the one on screen.
|
||||
const editor = present(
|
||||
canvasElement.querySelector<HTMLElement>(
|
||||
'.code-mirror-where-clause .cm-content',
|
||||
),
|
||||
'filter editor',
|
||||
);
|
||||
|
||||
return {
|
||||
editor,
|
||||
view: present(EditorView.findFromDOM(editor), 'editor view'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The explorers wrap the filter in `OverlayScrollbar`, which initialises when
|
||||
* the browser is idle. Initialising moves the content, the editor with it, and
|
||||
* focuses the editor again through the DOM, which puts the caret back at the
|
||||
* start and swaps the suggestions for the key list. Throws until every wrapper
|
||||
* around the filter has initialised.
|
||||
*/
|
||||
const assertScrollbarsReady = (editor: HTMLElement): void => {
|
||||
for (
|
||||
let wrapper = editor.closest('.overlay-scrollbar');
|
||||
wrapper;
|
||||
wrapper = wrapper.parentElement?.closest('.overlay-scrollbar') ?? null
|
||||
) {
|
||||
if (!wrapper.hasAttribute('data-overlayscrollbars')) {
|
||||
throw new Error('scrollbars around the filter still initialising');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Waits until `text` shows in the suggestion list, asking for suggestions
|
||||
* whenever the list is shut. Focus and typing only open it once the keys have
|
||||
* loaded, and moving the caret never does.
|
||||
*/
|
||||
const waitForSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> =>
|
||||
waitFor(
|
||||
() => {
|
||||
const { editor } = currentView(canvasElement);
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionRow(canvasElement, text), `suggestion "${text}"`);
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
/**
|
||||
* Focuses the filter once the scrollbars around it have initialised, and waits
|
||||
* for its suggestion list.
|
||||
*/
|
||||
const focusFilter = async (canvasElement: HTMLElement): Promise<EditorView> => {
|
||||
await waitFor(
|
||||
() => {
|
||||
const { editor, view } = currentView(canvasElement);
|
||||
|
||||
assertScrollbarsReady(editor);
|
||||
|
||||
if (!view.hasFocus) {
|
||||
view.focus();
|
||||
}
|
||||
|
||||
if (!suggestionList(canvasElement)) {
|
||||
requestSuggestions(editor);
|
||||
}
|
||||
|
||||
return present(suggestionList(canvasElement), 'suggestion list');
|
||||
},
|
||||
{ ...untilLoaded, interval: 250 },
|
||||
);
|
||||
|
||||
return currentView(canvasElement).view;
|
||||
};
|
||||
|
||||
/** Waits for a row of the suggestion list. */
|
||||
export const findSuggestion = (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<HTMLElement> => waitForSuggestion(canvasElement, text);
|
||||
|
||||
/** Focuses the empty filter: every key, with any recent filters above them. */
|
||||
export const openKeySuggestions = async (
|
||||
canvasElement: HTMLElement,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await focusFilter(canvasElement);
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Focuses the filter and types onto the end of it one character at a time,
|
||||
* each as the transaction a keystroke makes, leaving the caret at the end so
|
||||
* the suggestion list follows what was typed. Quotes and brackets are not
|
||||
* closed for it: type both.
|
||||
*
|
||||
* `userEvent.type` cannot be used: CodeMirror redraws the line as tokens are
|
||||
* highlighted, which strands the caret `userEvent` tracks.
|
||||
*/
|
||||
export const typeFilter = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const view = await focusFilter(canvasElement);
|
||||
|
||||
for (const character of text) {
|
||||
const at = view.state.doc.length;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: at, insert: character },
|
||||
selection: { anchor: at + character.length },
|
||||
userEvent: 'input.type',
|
||||
});
|
||||
await pause(KEYSTROKE_MS);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Types an expression, then steps the caret back inside it, before a closing
|
||||
* bracket or parenthesis, where the suggestions are about what goes in there.
|
||||
*/
|
||||
export const typeFilterWithCaretBack = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
stepsBack: number,
|
||||
row: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
|
||||
const { view } = currentView(canvasElement);
|
||||
|
||||
view.dispatch({
|
||||
selection: { anchor: view.state.doc.length - stepsBack },
|
||||
userEvent: 'select',
|
||||
});
|
||||
await waitForSuggestion(canvasElement, row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves focus off the filter, which is when the expression is validated and
|
||||
* the error marker can show.
|
||||
*/
|
||||
export const blurFilter = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await userEvent.click(canvasElement.ownerDocument.body);
|
||||
};
|
||||
|
||||
/** Types an expression, leaves the filter and opens its validation errors. */
|
||||
export const showFilterErrors = async (
|
||||
canvasElement: HTMLElement,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
await typeFilter(canvasElement, text);
|
||||
await blurFilter(canvasElement);
|
||||
|
||||
const marker = await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.querySelector<HTMLElement>('.query-status-container button'),
|
||||
'error marker',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.hover(marker);
|
||||
await waitFor(
|
||||
() =>
|
||||
present(
|
||||
canvasElement.ownerDocument.querySelector('.query-validation-error'),
|
||||
'validation error',
|
||||
),
|
||||
untilLoaded,
|
||||
);
|
||||
};
|
||||
@@ -885,7 +885,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{showAddCreditCardModal && <ChatSupportGateway />}
|
||||
{showAddCreditCardModal && !isSavedViewEnabled && <ChatSupportGateway />}
|
||||
{showChangelogModal && changelog && (
|
||||
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
|
||||
)}
|
||||
|
||||
36
frontend/src/container/BottomStrip/AskNoz/AskNoz.module.scss
Normal file
36
frontend/src/container/BottomStrip/AskNoz/AskNoz.module.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.askNoz {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
line-height: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.pulseDot {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
animation: askNozDotPulse 1.5s ease-in-out infinite;
|
||||
transform: scale(0.8);
|
||||
margin-right: -12px;
|
||||
}
|
||||
|
||||
@keyframes askNozDotPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
76
frontend/src/container/BottomStrip/AskNoz/AskNoz.tsx
Normal file
76
frontend/src/container/BottomStrip/AskNoz/AskNoz.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
|
||||
import styles from './AskNoz.module.scss';
|
||||
|
||||
/**
|
||||
* Opens the Noz drawer, matching the header entry it replaces. Cmd+K opens the
|
||||
* modal instead; this is deliberately the drawer.
|
||||
*
|
||||
* Carries the header's pending badge: when Noz is blocked on the user
|
||||
* (`awaiting_approval` / `awaiting_clarification`) a dot pulses. Without it,
|
||||
* hiding the header button would remove a notification rather than move it.
|
||||
*/
|
||||
function AskNoz(): JSX.Element | null {
|
||||
const { pathname } = useLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const isDrawerOpen = useAIAssistantStore((state) => state.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((state) => state.isModalOpen);
|
||||
const pendingUserInputCount = useAIAssistantStore(
|
||||
selectPendingUserInputStreamCount,
|
||||
);
|
||||
|
||||
// Noz is already on screen in the modal, so the "needs you" dot would be noise.
|
||||
const showPendingBadge = pendingUserInputCount > 0 && !isModalOpen;
|
||||
|
||||
// The drawer does not render on the Noz full page, so the button would be inert.
|
||||
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
|
||||
|
||||
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.askNoz} data-testid="bottom-strip-ask-noz">
|
||||
{showPendingBadge && (
|
||||
<span className={styles.badge} aria-hidden>
|
||||
<span className={styles.pulseDot}>
|
||||
<Dot size={36} />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="noz-wave"
|
||||
prefix={<Noz size={16} />}
|
||||
onClick={(): void => openAIAssistant()}
|
||||
aria-label={
|
||||
showPendingBadge
|
||||
? `Ask Noz, ${pendingUserInputCount} ${
|
||||
pendingUserInputCount === 1 ? 'action needs' : 'actions need'
|
||||
} your response`
|
||||
: 'Ask Noz'
|
||||
}
|
||||
>
|
||||
Ask Noz
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AskNoz;
|
||||
@@ -0,0 +1,133 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import AskNoz from '../AskNoz';
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled');
|
||||
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
|
||||
openAIAssistant: jest.fn(),
|
||||
useAIAssistantStore: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockEnabled = useIsAIAssistantEnabled as jest.MockedFunction<
|
||||
typeof useIsAIAssistantEnabled
|
||||
>;
|
||||
const mockOpen = openAIAssistant as jest.MockedFunction<typeof openAIAssistant>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { useAIAssistantStore } = jest.requireMock(
|
||||
'container/AIAssistant/store/useAIAssistantStore',
|
||||
) as { useAIAssistantStore: jest.Mock };
|
||||
|
||||
/** The component reads the store through three separate selector calls. */
|
||||
function mockStore({
|
||||
isDrawerOpen = false,
|
||||
isModalOpen = false,
|
||||
pendingCount = 0,
|
||||
}: {
|
||||
isDrawerOpen?: boolean;
|
||||
isModalOpen?: boolean;
|
||||
pendingCount?: number;
|
||||
} = {}): void {
|
||||
const state = { isDrawerOpen, isModalOpen, streams: {} };
|
||||
useAIAssistantStore.mockImplementation((selector: (s: unknown) => unknown) => {
|
||||
const picked = selector(state);
|
||||
// `selectPendingUserInputStreamCount` walks `streams`, which is empty here,
|
||||
// so stand in the count we want to assert against.
|
||||
return typeof picked === 'number' ? pendingCount : picked;
|
||||
});
|
||||
}
|
||||
|
||||
// `TooltipSimple` replaces its trigger's props, so the testId lives on the
|
||||
// wrapper and the button itself is reached by role — there is only ever one.
|
||||
const SLOT = 'bottom-strip-ask-noz';
|
||||
|
||||
describe('AskNoz', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEnabled.mockReturnValue(true);
|
||||
mockStore();
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
it('renders when the assistant is enabled', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when the assistant is disabled', () => {
|
||||
mockEnabled.mockReturnValue(false);
|
||||
|
||||
const { queryByTestId } = render(<AskNoz />);
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing while the drawer is open', () => {
|
||||
mockStore({ isDrawerOpen: true });
|
||||
|
||||
const { queryByTestId } = render(<AskNoz />);
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing on the Noz full page, where the drawer does not mount', () => {
|
||||
const { queryByTestId } = render(<AskNoz />, undefined, {
|
||||
initialRoute: '/ai-assistant/some-conversation-id',
|
||||
});
|
||||
|
||||
expect(queryByTestId(SLOT)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what it does', () => {
|
||||
it('opens the drawer on click', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
fireEvent.click(getByRole('button'));
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending badge', () => {
|
||||
it('announces the count when Noz is waiting on the user', () => {
|
||||
mockStore({ pendingCount: 2 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Ask Noz, 2 actions need your response',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the singular for one pending action', () => {
|
||||
mockStore({ pendingCount: 1 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute(
|
||||
'aria-label',
|
||||
'Ask Noz, 1 action needs your response',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays quiet when nothing is pending', () => {
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
|
||||
});
|
||||
|
||||
it('stays quiet while the modal is open, Noz is already on screen', () => {
|
||||
mockStore({ isModalOpen: true, pendingCount: 3 });
|
||||
|
||||
const { getByRole } = render(<AskNoz />);
|
||||
|
||||
expect(getByRole('button')).toHaveAttribute('aria-label', 'Ask Noz');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
|
||||
--button-font-size: 12px;
|
||||
|
||||
font-family: var(--font-family-sf-mono, monospace);
|
||||
|
||||
// Above page content, below the body-portalled overlays that are meant to
|
||||
@@ -23,10 +25,14 @@
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.left {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// Temporary placeholder for the left slot. Replaced later.
|
||||
.version {
|
||||
color: var(--l2-foreground);
|
||||
|
||||
29
frontend/src/container/BottomStrip/LeftSlot/LeftSlot.tsx
Normal file
29
frontend/src/container/BottomStrip/LeftSlot/LeftSlot.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import StripTypography from '../components/StripTypography/StripTypography';
|
||||
import { useBottomStripStore } from '../store/useBottomStripStore';
|
||||
|
||||
function LeftSlot(): JSX.Element | null {
|
||||
const { versionData } = useAppContext();
|
||||
const left = useBottomStripStore((state) => state.left);
|
||||
const ownerId = useBottomStripStore((state) => state.ownerId);
|
||||
const version = versionData?.version?.trim();
|
||||
const versionNode = version ? (
|
||||
<StripTypography>{version}</StripTypography>
|
||||
) : null;
|
||||
|
||||
if (!left) {
|
||||
return versionNode;
|
||||
}
|
||||
|
||||
return (
|
||||
// Keyed so a page whose node threw does not leave the boundary latched on
|
||||
// the fallback for every page after it.
|
||||
<Sentry.ErrorBoundary key={ownerId} fallback={<>{versionNode}</>}>
|
||||
{left}
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeftSlot;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { MessageSquareText } from '@signozhq/icons';
|
||||
|
||||
function SupportButton(): JSX.Element | null {
|
||||
const chatSupport = useChatSupport();
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
if (chatSupport === ChatSupportState.Unavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (chatSupport === ChatSupportState.NeedsCard) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
return;
|
||||
}
|
||||
window.Pylon?.('show');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<MessageSquareText size={16} />}
|
||||
onClick={handleClick}
|
||||
testId="bottom-strip-support"
|
||||
>
|
||||
Support
|
||||
</Button>
|
||||
|
||||
<AddCreditCardModal
|
||||
open={isAddCreditCardModalOpen}
|
||||
onClose={(): void => setIsAddCreditCardModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SupportButton;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import SupportButton from '../SupportButton';
|
||||
|
||||
jest.mock('hooks/useChatSupport', () => ({
|
||||
...jest.requireActual('hooks/useChatSupport'),
|
||||
useChatSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockChatSupport = useChatSupport as jest.MockedFunction<
|
||||
typeof useChatSupport
|
||||
>;
|
||||
|
||||
const BUTTON = 'bottom-strip-support';
|
||||
const MODAL_TITLE = 'Add Credit Card for Chat Support';
|
||||
|
||||
describe('SupportButton', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.Pylon = jest.fn() as never;
|
||||
});
|
||||
|
||||
describe('when Pylon is available', () => {
|
||||
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.Pylon));
|
||||
|
||||
it('shows the button', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(getByTestId(BUTTON)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the Pylon widget on click', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(window.Pylon).toHaveBeenCalledWith('show');
|
||||
});
|
||||
|
||||
it('does not open the credit card modal', () => {
|
||||
const { getByTestId, queryByText } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(queryByText(MODAL_TITLE)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user needs a card', () => {
|
||||
beforeEach(() => mockChatSupport.mockReturnValue(ChatSupportState.NeedsCard));
|
||||
|
||||
it('shows the same button', () => {
|
||||
const { getByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(getByTestId(BUTTON)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the credit card modal on click, not Pylon', () => {
|
||||
const { getByTestId, getByText } = render(<SupportButton />);
|
||||
|
||||
fireEvent.click(getByTestId(BUTTON));
|
||||
|
||||
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
|
||||
expect(window.Pylon).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when support is unavailable', () => {
|
||||
beforeEach(() =>
|
||||
mockChatSupport.mockReturnValue(ChatSupportState.Unavailable),
|
||||
);
|
||||
|
||||
it('renders nothing at all', () => {
|
||||
const { queryByTestId } = render(<SupportButton />);
|
||||
|
||||
expect(queryByTestId(BUTTON)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,50 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
import { Info } from 'types/api/v1/version/get';
|
||||
|
||||
import BottomStrip, {
|
||||
BOTTOM_STRIP_HEIGHT,
|
||||
BOTTOM_STRIP_HEIGHT_VAR,
|
||||
BOTTOM_STRIP_ON_CLASS,
|
||||
} from '..';
|
||||
import { useBottomStripStore } from '../store/useBottomStripStore';
|
||||
import { useBottomStripLeft } from '../useBottomStripLeft';
|
||||
|
||||
/** Stands in for a page that puts something on the left of the strip. */
|
||||
function Page({ children }: { children: ReactNode }): null {
|
||||
useBottomStripLeft(children);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A page whose value changes without needing `rerender`. */
|
||||
function ChangingPage(): JSX.Element {
|
||||
const [count, setCount] = useState(600);
|
||||
|
||||
useBottomStripLeft(`${count} traces`);
|
||||
|
||||
return (
|
||||
<button type="button" onClick={(): void => setCount(42)}>
|
||||
change
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A page node that blows up while the strip renders it. */
|
||||
function Boom(): JSX.Element {
|
||||
throw new Error('bad left node');
|
||||
}
|
||||
|
||||
const VERSION = 'v0.134.67';
|
||||
const versionData: Info = { version: VERSION, ee: 'Y', setupCompleted: true };
|
||||
const withVersion = { appContextOverrides: { versionData } };
|
||||
|
||||
describe('BottomStrip', () => {
|
||||
// The store is module level, so it outlives each test.
|
||||
beforeEach(() => {
|
||||
useBottomStripStore.setState({ left: null, ownerId: null });
|
||||
});
|
||||
|
||||
it('publishes the body class and height property while mounted', () => {
|
||||
const { unmount } = render(<BottomStrip />);
|
||||
|
||||
@@ -28,22 +66,100 @@ describe('BottomStrip', () => {
|
||||
it.each([['v0.134.67'], ['main-64f1c2a']])(
|
||||
'renders the build version %p exactly as given',
|
||||
(version) => {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
const { getByText } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: {
|
||||
versionData: { version, ee: 'Y', setupCompleted: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
|
||||
expect(getByText(version)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders the strip without a version when none is available', () => {
|
||||
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
|
||||
const { getByTestId } = render(<BottomStrip />, undefined, {
|
||||
appContextOverrides: { versionData: null },
|
||||
});
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
|
||||
const strip = getByTestId('bottom-strip');
|
||||
|
||||
expect(strip).toBeInTheDocument();
|
||||
expect(strip).toHaveTextContent('');
|
||||
});
|
||||
|
||||
// `tests/test-utils` builds its wrapper around the first `ui`, so `rerender`
|
||||
// re-renders the original tree. These drive change through state and through
|
||||
// separate trees instead, which the module-level store lets them share.
|
||||
describe('left slot', () => {
|
||||
it('shows what the page put there instead of the version', () => {
|
||||
render(<Page>600 traces</Page>);
|
||||
const { getByText, queryByText } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByText('600 traces')).toBeInTheDocument();
|
||||
expect(queryByText(VERSION)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the version once the page is gone', () => {
|
||||
const page = render(<Page>600 traces</Page>);
|
||||
const { getByText } = render(<BottomStrip />, undefined, withVersion);
|
||||
|
||||
page.unmount();
|
||||
|
||||
expect(getByText(VERSION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates when the page changes what it shows', () => {
|
||||
render(<ChangingPage />);
|
||||
const { getByText, getByRole } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByText('600 traces')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'change' }));
|
||||
|
||||
expect(getByText('42 traces')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the version when the page node throws', () => {
|
||||
// React logs the caught error, which is noise here.
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<Page>
|
||||
<Boom />
|
||||
</Page>,
|
||||
);
|
||||
const { getByTestId, getByText } = render(
|
||||
<BottomStrip />,
|
||||
undefined,
|
||||
withVersion,
|
||||
);
|
||||
|
||||
expect(getByTestId('bottom-strip')).toBeInTheDocument();
|
||||
expect(getByText(VERSION)).toBeInTheDocument();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps the new page value when the old page unmounts after it', () => {
|
||||
// Navigation order: the next page mounts before the last one unmounts,
|
||||
// so without the owner guard the outgoing page wipes the incoming value.
|
||||
const pageA = render(<Page>page A</Page>);
|
||||
render(<Page>page B</Page>);
|
||||
const { getByText } = render(<BottomStrip />, undefined, withVersion);
|
||||
|
||||
pageA.unmount();
|
||||
|
||||
expect(getByText('page B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.separator {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
|
||||
// Strip items hide themselves by rendering null, which would otherwise leave a
|
||||
// separator dangling at an edge or two of them side by side. Collapsing them
|
||||
// here keeps the call sites free of visibility plumbing.
|
||||
.separator:first-child,
|
||||
.separator:last-child,
|
||||
.separator + .separator {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import styles from './StripSeparator.module.scss';
|
||||
|
||||
function StripSeparator(): JSX.Element {
|
||||
return <span className={styles.separator} aria-hidden />;
|
||||
}
|
||||
|
||||
export default StripSeparator;
|
||||
@@ -0,0 +1,10 @@
|
||||
.stripTypography {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './StripTypography.module.scss';
|
||||
|
||||
interface StripTypographyProps {
|
||||
children: ReactNode;
|
||||
/** Leading icon, aligned and spaced for you. Same shape as `Button`'s. */
|
||||
prefix?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the strip renders goes through this: the version, a plain count, or
|
||||
* an icon with a key and value. It owns the strip's type and alignment and
|
||||
* nothing else — how a consumer colours its own content is theirs.
|
||||
*/
|
||||
function StripTypography({
|
||||
children,
|
||||
prefix,
|
||||
className,
|
||||
}: StripTypographyProps): JSX.Element {
|
||||
return (
|
||||
<span className={cx(styles.stripTypography, className)}>
|
||||
{prefix}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
StripTypography.defaultProps = { prefix: undefined, className: undefined };
|
||||
|
||||
export default StripTypography;
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import AskNoz from './AskNoz/AskNoz';
|
||||
import LeftSlot from './LeftSlot/LeftSlot';
|
||||
import StripSeparator from './components/StripSeparator/StripSeparator';
|
||||
import SupportButton from './SupportButton/SupportButton';
|
||||
|
||||
import styles from './BottomStrip.module.scss';
|
||||
|
||||
@@ -9,9 +13,6 @@ export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
|
||||
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
|
||||
|
||||
function BottomStrip(): JSX.Element {
|
||||
const { versionData } = useAppContext();
|
||||
const version = versionData?.version?.trim();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
|
||||
document.body.style.setProperty(
|
||||
@@ -28,13 +29,13 @@ function BottomStrip(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.strip} data-testid="bottom-strip">
|
||||
<div className={styles.left}>
|
||||
{version && (
|
||||
<span className={styles.version} data-testid="bottom-strip-version">
|
||||
{version}
|
||||
</span>
|
||||
)}
|
||||
<LeftSlot />
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<AskNoz />
|
||||
<StripSeparator />
|
||||
<SupportButton />
|
||||
</div>
|
||||
<div className={styles.right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface BottomStripState {
|
||||
/** What the strip shows on the left, or null to fall back to the version. */
|
||||
left: ReactNode | null;
|
||||
/** Which page owns the current value — see `clearLeft`. */
|
||||
ownerId: string | null;
|
||||
setLeft: (ownerId: string, left: ReactNode) => void;
|
||||
clearLeft: (ownerId: string) => void;
|
||||
}
|
||||
|
||||
export const useBottomStripStore = create<BottomStripState>()((set, get) => ({
|
||||
left: null,
|
||||
ownerId: null,
|
||||
setLeft: (ownerId, left): void => set({ left, ownerId }),
|
||||
// Only the current owner may clear. On a plain route swap React runs the old
|
||||
// page's cleanup before the new page's effect, so this is moot, but two
|
||||
// consumers can be mounted at once (a page under a drawer): the one that set
|
||||
// last owns the slot, and the other unmounting must not wipe it.
|
||||
clearLeft: (ownerId): void => {
|
||||
if (get().ownerId === ownerId) {
|
||||
set({ left: null, ownerId: null });
|
||||
}
|
||||
},
|
||||
}));
|
||||
23
frontend/src/container/BottomStrip/useBottomStripLeft.ts
Normal file
23
frontend/src/container/BottomStrip/useBottomStripLeft.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type ReactNode, useEffect, useId } from 'react';
|
||||
|
||||
import { useBottomStripStore } from './store/useBottomStripStore';
|
||||
|
||||
/**
|
||||
* Puts `node` on the left of the bottom strip for as long as the calling page is
|
||||
* mounted. Pass null to show nothing and let the version through.
|
||||
*
|
||||
* There is no refresh API by design: a page that refetches re-renders, which
|
||||
* produces a new node, which re-runs this effect. Wrap the node in `useMemo`
|
||||
* keyed on the values it shows, or the store is written on every render.
|
||||
*/
|
||||
export function useBottomStripLeft(node: ReactNode | null): void {
|
||||
const ownerId = useId();
|
||||
const setLeft = useBottomStripStore((state) => state.setLeft);
|
||||
const clearLeft = useBottomStripStore((state) => state.clearLeft);
|
||||
|
||||
useEffect(() => {
|
||||
setLeft(ownerId, node);
|
||||
|
||||
return (): void => clearLeft(ownerId);
|
||||
}, [node, ownerId, setLeft, clearLeft]);
|
||||
}
|
||||
148
frontend/src/hooks/__tests__/useChatSupport.test.tsx
Normal file
148
frontend/src/hooks/__tests__/useChatSupport.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
jest.mock('providers/App/App');
|
||||
jest.mock('hooks/useGetTenantLicense');
|
||||
|
||||
const mockAppContext = useAppContext as jest.MockedFunction<
|
||||
typeof useAppContext
|
||||
>;
|
||||
const mockLicense = useGetTenantLicense as jest.MockedFunction<
|
||||
typeof useGetTenantLicense
|
||||
>;
|
||||
|
||||
const flag = (name: FeatureKeys, active: boolean): Record<string, unknown> => ({
|
||||
name,
|
||||
active,
|
||||
usage: 0,
|
||||
usage_limit: -1,
|
||||
route: '',
|
||||
});
|
||||
|
||||
function setup({
|
||||
chatSupport = true,
|
||||
premiumSupport = false,
|
||||
trialConverted = false,
|
||||
isCloudUser = true,
|
||||
isEnterpriseSelfHostedUser = false,
|
||||
isLoggedIn = true,
|
||||
pylonEnabled = true,
|
||||
isFetchingFeatureFlags = false,
|
||||
activeLicense = {} as unknown,
|
||||
trialInfo = {} as unknown,
|
||||
} = {}): void {
|
||||
window.signozBootData = {
|
||||
settings: { pylon: { enabled: pylonEnabled } },
|
||||
} as never;
|
||||
|
||||
mockAppContext.mockReturnValue({
|
||||
featureFlags: [
|
||||
flag(FeatureKeys.CHAT_SUPPORT, chatSupport),
|
||||
flag(FeatureKeys.PREMIUM_SUPPORT, premiumSupport),
|
||||
],
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError: null,
|
||||
trialInfo: trialInfo && { trialConvertedToSubscription: trialConverted },
|
||||
isLoggedIn,
|
||||
activeLicense,
|
||||
} as never);
|
||||
|
||||
mockLicense.mockReturnValue({
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
} as never);
|
||||
}
|
||||
|
||||
const state = (): ChatSupportState =>
|
||||
renderHook(() => useChatSupport()).result.current;
|
||||
|
||||
describe('useChatSupport', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('pylon', () => {
|
||||
it('hands off to Pylon for a cloud user past trial', () => {
|
||||
setup({ trialConverted: true });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('hands off to Pylon for enterprise self-hosted', () => {
|
||||
setup({
|
||||
trialConverted: true,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('hands off to Pylon when premium support is on, card or not', () => {
|
||||
setup({ premiumSupport: true, trialConverted: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Pylon);
|
||||
});
|
||||
|
||||
it('offers nothing when Pylon is not configured server side', () => {
|
||||
setup({ trialConverted: true, pylonEnabled: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsCard', () => {
|
||||
it('offers the card flow to a cloud user still on trial', () => {
|
||||
setup({ trialConverted: false, premiumSupport: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.NeedsCard);
|
||||
});
|
||||
|
||||
it('offers nothing to a non-cloud user needing a card', () => {
|
||||
setup({
|
||||
trialConverted: false,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unavailable', () => {
|
||||
it('offers nothing without the chat support flag', () => {
|
||||
setup({ chatSupport: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing when logged out', () => {
|
||||
setup({ isLoggedIn: false });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing while the flags are still loading', () => {
|
||||
setup({ isFetchingFeatureFlags: true });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing before the licence has loaded', () => {
|
||||
setup({ activeLicense: null });
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
|
||||
it('offers nothing on a tenant that is neither cloud nor enterprise', () => {
|
||||
setup({
|
||||
trialConverted: true,
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
});
|
||||
|
||||
expect(state()).toBe(ChatSupportState.Unavailable);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
frontend/src/hooks/useChatSupport.ts
Normal file
70
frontend/src/hooks/useChatSupport.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export enum ChatSupportState {
|
||||
/** Pylon is configured for this user — hand off to the widget. */
|
||||
Pylon = 'pylon',
|
||||
/** Trial without a card — offer the Add Credit Card flow instead. */
|
||||
NeedsCard = 'needsCard',
|
||||
/** No support entry at all. */
|
||||
Unavailable = 'unavailable',
|
||||
}
|
||||
|
||||
export function useChatSupport(): ChatSupportState {
|
||||
const {
|
||||
featureFlags,
|
||||
isFetchingFeatureFlags,
|
||||
featureFlagsFetchError,
|
||||
trialInfo,
|
||||
isLoggedIn,
|
||||
activeLicense,
|
||||
} = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
return useMemo(() => {
|
||||
const isReady =
|
||||
!isFetchingFeatureFlags &&
|
||||
(featureFlags || featureFlagsFetchError) &&
|
||||
activeLicense &&
|
||||
trialInfo;
|
||||
if (!isReady || !isLoggedIn) {
|
||||
return ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const flag = (name: FeatureKeys): boolean =>
|
||||
featureFlags?.find((f) => f.name === name)?.active || false;
|
||||
|
||||
if (!flag(FeatureKeys.CHAT_SUPPORT)) {
|
||||
return ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const needsCard =
|
||||
!flag(FeatureKeys.PREMIUM_SUPPORT) &&
|
||||
!trialInfo?.trialConvertedToSubscription;
|
||||
|
||||
if (needsCard) {
|
||||
// The credit card flow is cloud-only
|
||||
return isCloudUser
|
||||
? ChatSupportState.NeedsCard
|
||||
: ChatSupportState.Unavailable;
|
||||
}
|
||||
|
||||
const pylonConfigured = Boolean(
|
||||
window.signozBootData?.settings?.pylon?.enabled,
|
||||
);
|
||||
return (isCloudUser || isEnterpriseSelfHostedUser) && pylonConfigured
|
||||
? ChatSupportState.Pylon
|
||||
: ChatSupportState.Unavailable;
|
||||
}, [
|
||||
activeLicense,
|
||||
featureFlags,
|
||||
featureFlagsFetchError,
|
||||
isCloudUser,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isFetchingFeatureFlags,
|
||||
isLoggedIn,
|
||||
trialInfo,
|
||||
]);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { encode } from 'js-base64';
|
||||
import type { Tags } from 'hooks/useResourceAttribute/types';
|
||||
import { fireEvent, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -32,9 +31,6 @@ import {
|
||||
type ServiceHealth,
|
||||
} from './__story_mockdata__/serviceMap';
|
||||
|
||||
/** The keys are only fetched once the select opens, past the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const GRAPH = 'Service map · graph';
|
||||
const FILTERS = 'Service map · filters';
|
||||
|
||||
@@ -133,80 +129,3 @@ export const serviceMapMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: serviceMapRoute(values.filters) }),
|
||||
});
|
||||
|
||||
/** Opens the select under a test id; it closes again after every pick. */
|
||||
export const openSelect = async (
|
||||
canvasElement: HTMLElement,
|
||||
testId: string,
|
||||
): Promise<void> => {
|
||||
const select = await within(canvasElement).findByTestId(
|
||||
testId,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
};
|
||||
|
||||
export const OPEN_DROPDOWN =
|
||||
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)';
|
||||
|
||||
/** antd keeps a hidden copy of each label for screen readers; the title skips it. */
|
||||
const visibleOption = (title: string): HTMLElement | null =>
|
||||
document.querySelector<HTMLElement>(
|
||||
`${OPEN_DROPDOWN} .ant-select-item-option[title="${title}"]`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Picks the option titled `title` in the open dropdown. `userEvent.click`
|
||||
* moves focus off the select on the way, which closes it before the option
|
||||
* takes the click.
|
||||
*/
|
||||
export const pickOption = async (title: string): Promise<void> => {
|
||||
const option = await waitFor(() => {
|
||||
const match = visibleOption(title);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`option "${title}" not found`);
|
||||
}
|
||||
|
||||
return match;
|
||||
}, untilLoaded);
|
||||
|
||||
await fireEvent.click(option);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the attribute filter on its next step. Each single-choice pick closes
|
||||
* the dropdown and swaps the select for the next step's, and a click that lands
|
||||
* before the swap opens nothing, so it opens again until `title` shows.
|
||||
*/
|
||||
export const openAttributeFilterOn = (
|
||||
canvasElement: HTMLElement,
|
||||
title: string,
|
||||
): Promise<void> =>
|
||||
waitFor(
|
||||
async () => {
|
||||
if (visibleOption(title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.querySelector(OPEN_DROPDOWN)) {
|
||||
await openSelect(canvasElement, 'resource-attributes-filter');
|
||||
}
|
||||
|
||||
throw new Error(`option "${title}" not shown`);
|
||||
},
|
||||
{ ...untilLoaded, interval: 500 },
|
||||
);
|
||||
|
||||
/** Stages `k8s.cluster.name IN` and leaves the filter open on its values. */
|
||||
export const stageClusterIn = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
|
||||
await pickOption('k8s.cluster.name');
|
||||
await openAttributeFilterOn(canvasElement, 'IN');
|
||||
await pickOption('IN');
|
||||
await openAttributeFilterOn(canvasElement, 'staging-eu');
|
||||
};
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
OPEN_DROPDOWN,
|
||||
openAttributeFilterOn,
|
||||
openSelect,
|
||||
pickOption,
|
||||
serviceMapMocks,
|
||||
stageClusterIn,
|
||||
} from './ServiceMap.stories.mocks';
|
||||
import { serviceMapMocks } from './ServiceMap.stories.mocks';
|
||||
|
||||
import ServiceMapContainer from '../index';
|
||||
|
||||
@@ -104,49 +97,3 @@ export const FilterAttributes: Story = {
|
||||
await screen.findByText('k8s.cluster.name', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key staged as a chip, the filter open again on how to match it. */
|
||||
export const FilterOperators: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
|
||||
await pickOption('k8s.cluster.name');
|
||||
await openAttributeFilterOn(canvasElement, 'Not IN');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and `IN` staged, the filter open on the values the key holds. */
|
||||
export const FilterValues: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await stageClusterIn(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** Two values ticked before the filter is left, which is what applies it. */
|
||||
export const FilterValuesSelected: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await stageClusterIn(canvasElement);
|
||||
await pickOption('prod-us-east');
|
||||
await pickOption('prod-eu-west');
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-selected'),
|
||||
).toHaveLength(2),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The environment selector open on the environments the calls came from. */
|
||||
export const EnvironmentOptions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openSelect(canvasElement, 'resource-environment-filter');
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector(`${OPEN_DROPDOWN} .ant-select-item-option`),
|
||||
).not.toBeNull(),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -103,18 +103,6 @@ export const DeleteDowntimeConfirm: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The new-downtime form set to repeat weekly, which adds the days and duration. */
|
||||
export const NewDowntimeRecurring: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await NewDowntime.play?.({ canvasElement } as never);
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Repeats every' }),
|
||||
);
|
||||
await userEvent.click(await screen.findByText('Weekly'));
|
||||
await screen.findByText('Duration');
|
||||
},
|
||||
};
|
||||
|
||||
/** A client-side search with no matching downtime schedule. */
|
||||
export const SearchNoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -6,19 +6,9 @@
|
||||
import { rest } from 'msw';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
countControl,
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { countControl, toggleControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
respondWith,
|
||||
} from '@/storybook/runtime/responseState';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
|
||||
import {
|
||||
@@ -33,9 +23,6 @@ import {
|
||||
type ListErrorsBody,
|
||||
} from './__story_mockdata__/exceptions';
|
||||
|
||||
/** The page fetches before it renders a row, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LIST = 'Exceptions · list';
|
||||
const FILTERS = 'Exceptions · filters';
|
||||
|
||||
@@ -55,13 +42,6 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
value: 6,
|
||||
max: EXCEPTION_QUICK_FILTER_CAP,
|
||||
}),
|
||||
filterKeys: choiceControl<ResponseState>('Filter keys', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'How `/autocomplete/attribute_keys` answers when the resource filter opens, apart from the page-wide Data control.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
filterPanel: toggleControl('Quick filters panel', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
@@ -112,7 +92,7 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v3/autocomplete/attribute_keys',
|
||||
respondWith(values.filterKeys, (req) =>
|
||||
response.json((req) =>
|
||||
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
|
||||
),
|
||||
),
|
||||
@@ -131,61 +111,3 @@ export const exceptionsMocks = defineStoryMocks({
|
||||
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
|
||||
},
|
||||
});
|
||||
|
||||
export const openResourceFilter = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const filter = await within(canvasElement).findByTestId(
|
||||
'qb-search-select',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(within(filter).getByRole('combobox'));
|
||||
|
||||
return filter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clicks the visible row whose label is `text`. The dropdown renders in the
|
||||
* body, a key row carries its type beside the label, and antd keeps a hidden
|
||||
* copy of each label for screen readers that takes no clicks.
|
||||
*
|
||||
* The key list renders twice after the filter opens, since a second key
|
||||
* request empties it until it answers. The click happens in the same task as
|
||||
* the lookup: `fireEvent` from `storybook/test` dispatches a tick later, which
|
||||
* can land on a row already removed and never reach React.
|
||||
*/
|
||||
export const pickSuggestion = async (text: string): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
const match = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'.query-builder-search.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
|
||||
),
|
||||
).find((option) =>
|
||||
Array.from(option.querySelectorAll('*')).some(
|
||||
(node) => node.children.length === 0 && node.textContent === text,
|
||||
),
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`suggestion "${text}" not found`);
|
||||
}
|
||||
|
||||
// `userEvent.click` moves focus off the search input on the way, which closes
|
||||
// the dropdown before the row takes the click.
|
||||
match.click();
|
||||
}, untilLoaded);
|
||||
};
|
||||
|
||||
export const commitFilter = async (
|
||||
key: string,
|
||||
operator: string,
|
||||
value: string,
|
||||
): Promise<void> => {
|
||||
await pickSuggestion(key);
|
||||
await screen.findByText('Operator for', { exact: false }, untilLoaded);
|
||||
await pickSuggestion(operator);
|
||||
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
|
||||
await pickSuggestion(value);
|
||||
};
|
||||
|
||||
@@ -5,12 +5,7 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
commitFilter,
|
||||
exceptionsMocks,
|
||||
openResourceFilter,
|
||||
pickSuggestion,
|
||||
} from './AllErrors.stories.mocks';
|
||||
import { exceptionsMocks } from './AllErrors.stories.mocks';
|
||||
import AllErrors from '../index';
|
||||
|
||||
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
|
||||
@@ -130,108 +125,3 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** The resource filter opened: every key the exceptions can be narrowed by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await screen.findByText('Suggested Filters', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** The key list grown past its first rows with the Show all shortcut. */
|
||||
export const FilterAllKeys: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await screen.findByText('Show all filter items', undefined, untilLoaded);
|
||||
await userEvent.keyboard('{Control>}/{/Control}');
|
||||
await screen.findByText('cloud.region', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A partial key: the typed text as a free search, then the keys that match. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const filter = await openResourceFilter(canvasElement);
|
||||
|
||||
await userEvent.type(within(filter).getByRole('combobox'), 'serv');
|
||||
await screen.findByText('service.namespace', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key picked: the operators the exceptions page allows for it. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await pickSuggestion('service.name');
|
||||
await screen.findByText('Operator for', { exact: false }, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator picked: the values the key holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await pickSuggestion('service.name');
|
||||
await pickSuggestion('=');
|
||||
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/** Two conditions committed as chips, with the dropdown closed again. */
|
||||
export const FilterChips: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await commitFilter('service.name', '=', 'checkout');
|
||||
await commitFilter('deployment.environment', '!=', 'staging');
|
||||
await userEvent.click(canvasElement.ownerDocument.body);
|
||||
await within(canvasElement).findByText(
|
||||
'deployment.environment != staging',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A committed chip clicked to change it: its text goes back into the input,
|
||||
* with the dropdown shut until the input is typed into.
|
||||
*/
|
||||
export const FilterEditChip: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await commitFilter('service.name', '=', 'checkout');
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
'service.name = checkout',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
// The select remounts whenever its chips change, so it is looked up again.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
within(within(canvasElement).getByTestId('qb-search-select')).getByRole(
|
||||
'combobox',
|
||||
),
|
||||
).toHaveValue('service.name = checkout'),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The key list while its request is still in flight. */
|
||||
export const FilterKeysLoading: Story = {
|
||||
args: { filterKeys: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openResourceFilter(canvasElement);
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector('.query-builder-search .ant-spin'),
|
||||
).not.toBeNull(),
|
||||
untilLoaded,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
|
||||
|
||||
import {
|
||||
@@ -34,7 +31,6 @@ import {
|
||||
import {
|
||||
emptyPanelResponse,
|
||||
NAMESPACE_VALUES,
|
||||
VARIABLE_ATTRIBUTES,
|
||||
panelResponse,
|
||||
serviceVariableValues,
|
||||
} from './__story_mockdata__/panelData';
|
||||
@@ -242,12 +238,6 @@ export const dashboardMocks = defineStoryMocks({
|
||||
response.json(() => fieldValuesResponse(NAMESPACE_VALUES)),
|
||||
),
|
||||
|
||||
// The dynamic variable editor lists the attributes a variable can read.
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json(() => fieldKeysResponse(VARIABLE_ATTRIBUTES)),
|
||||
),
|
||||
|
||||
// The header reads the public link on every load, so it answers even while
|
||||
// the panels are held in the loading or failed state.
|
||||
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Route } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -285,67 +285,6 @@ export const SectionActionsMenu: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The panel menu's move-to-section submenu, open on the sections it can go to. */
|
||||
export const PanelMoveToSectionSubmenu: Story = {
|
||||
play: async (context) => {
|
||||
await PanelActionsMenu.play?.(context);
|
||||
await userEvent.hover(await screen.findByText('Move to section'));
|
||||
// The submenu lists the sections the panel is not already in.
|
||||
await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(2), {
|
||||
timeout: 10000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Dashboard settings on the Publish tab, where the public link is managed. */
|
||||
export const SettingsPublicDashboard: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Configure' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Publish' }));
|
||||
await screen.findByText('Default time range');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Publish tab with its default time range select open. */
|
||||
export const SettingsPublicDashboardSelectOpen: Story = {
|
||||
play: async (context) => {
|
||||
await SettingsPublicDashboard.play?.(context);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('tabpanel')).getByRole('combobox'),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
/** The Variables tab of dashboard settings with a new variable's form open. */
|
||||
export const SettingsVariablesNew: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Configure' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
|
||||
const add = await within(await screen.findByRole('tabpanel')).findByRole(
|
||||
'button',
|
||||
{ name: 'Add variable' },
|
||||
);
|
||||
|
||||
// The button stays disabled until its permission check resolves.
|
||||
await waitFor(() => expect(add).toBeEnabled(), { timeout: 10000 });
|
||||
await userEvent.click(add);
|
||||
await screen.findByText('Variable Type');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
|
||||
*
|
||||
|
||||
@@ -168,15 +168,6 @@ export const serviceVariableValues = (count: number): string[] =>
|
||||
);
|
||||
|
||||
/** Values the dynamic `namespace` variable resolves from the fields endpoint. */
|
||||
/** Attributes the dynamic variable editor offers a variable to read. */
|
||||
export const VARIABLE_ATTRIBUTES = [
|
||||
'k8s.namespace.name',
|
||||
'k8s.cluster.name',
|
||||
'service.name',
|
||||
'deployment.environment',
|
||||
'host.name',
|
||||
];
|
||||
|
||||
export const NAMESPACE_VALUES = [
|
||||
'checkout-prod',
|
||||
'payments-prod',
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { rest } from 'msw';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
choiceControl,
|
||||
@@ -343,30 +342,3 @@ export const dashboardsListMocks = defineStoryMocks({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Opens the actions menu of the row at `index`. */
|
||||
export const openRowActions = async (
|
||||
canvasElement: HTMLElement,
|
||||
index: number,
|
||||
): Promise<void> => {
|
||||
// The icon-only trigger carries no accessible name.
|
||||
const triggers = await within(canvasElement).findAllByTestId(
|
||||
'dashboard-action-icon',
|
||||
{},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await userEvent.click(triggers[index]);
|
||||
await screen.findByText('Rename');
|
||||
};
|
||||
|
||||
/** Picks a row action, retrying while its permission check still disables it. */
|
||||
export const pickRowAction = async (label: string | RegExp): Promise<void> => {
|
||||
await waitFor(
|
||||
async () => {
|
||||
await userEvent.click(screen.getByText(label));
|
||||
await screen.findByRole('dialog', {}, { timeout: 500 });
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,9 +6,7 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
dashboardsListMocks,
|
||||
openRowActions,
|
||||
overflowingRows,
|
||||
pickRowAction,
|
||||
} from './DashboardsListPage.stories.mocks';
|
||||
import { BuiltinViewId } from '../types';
|
||||
|
||||
@@ -116,45 +114,6 @@ export const Tooltips: Story = {
|
||||
parameters: { msw: { handlers: [overflowingRows] } },
|
||||
};
|
||||
|
||||
/** The first row's actions menu, open over the list. */
|
||||
export const RowActionsMenu: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 0);
|
||||
},
|
||||
};
|
||||
|
||||
/** The rename dialog, opened from the menu of the second row (the first is locked). */
|
||||
export const RenameDashboardDialog: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction('Rename');
|
||||
await screen.findByRole('dialog', { name: 'Rename dashboard' });
|
||||
},
|
||||
};
|
||||
|
||||
/** The tags dialog, opened from the menu of the second row (the first is locked). */
|
||||
export const EditTagsDialog: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await openRowActions(canvasElement, 1);
|
||||
await pickRowAction(/^(Edit|Add) Tags$/);
|
||||
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
|
||||
},
|
||||
};
|
||||
|
||||
/** The popover that names the current filters as a new saved view. */
|
||||
export const SaveViewPopover: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: 'Save current filters as a view' },
|
||||
{ timeout: 10000 },
|
||||
),
|
||||
);
|
||||
await screen.findByText('Save as view');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The query the backend refused: the parse error it returned replaces the
|
||||
* generic failure copy, and there is nothing to retry.
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
|
||||
@@ -46,21 +41,6 @@ export const PodDetailsEvents: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true, drawerTab: VIEWS.EVENTS },
|
||||
};
|
||||
|
||||
/** The selected pod's details drawer, switched to its logs tab. */
|
||||
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
|
||||
args: { drawer: true },
|
||||
play: async () => {
|
||||
const drawer = within(
|
||||
await screen.findByRole('dialog', {}, { timeout: 10000 }),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
await drawer.findByText('Logs', {}, { timeout: 10000 }),
|
||||
);
|
||||
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the pod list carries, held open: Collapse Filters beside the
|
||||
* quick filters, Options above the table, the Pod Name, Status, Age and Restarts
|
||||
@@ -106,27 +86,3 @@ export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
|
||||
await screen.findByText('Columns');
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The pod list re-renders the filter when the viewport grows, and each render
|
||||
* reconfigures the editor, which closes its suggestions. Shot at the height
|
||||
* the page opened at.
|
||||
*/
|
||||
const heldViewport = { sbshot: { viewport: { height: 1200 } } };
|
||||
|
||||
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
|
||||
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.node.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** The pod filter on a namespace: the namespaces the pods run in. */
|
||||
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
|
||||
parameters: heldViewport,
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'k8s.namespace.name = ');
|
||||
await findSuggestion(canvasElement, 'kube-system');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,12 +69,3 @@ export const GroupActionsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The first mapping group's edit drawer, opened from its menu. */
|
||||
export const GroupFormDrawer: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await GroupActionsMenu.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Edit'));
|
||||
await screen.findByText('Edit group');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,24 +54,3 @@ export const ModelCostActionsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The first pricing rule's drawer, opened from its row menu. */
|
||||
export const ModelCostDrawer: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await ModelCostActionsMenu.play?.(context);
|
||||
await userEvent.click(await screen.findByText('Edit'));
|
||||
await screen.findByText('Edit model cost');
|
||||
},
|
||||
};
|
||||
|
||||
/** The drawer with its cache mode select open. */
|
||||
export const ModelCostDrawerCacheModeOpen: Story = {
|
||||
play: async (context): Promise<void> => {
|
||||
await ModelCostDrawer.play?.(context);
|
||||
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Cache mode' }),
|
||||
);
|
||||
await screen.findByRole('listbox');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,11 +25,6 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import {
|
||||
RESPONSE_STATES,
|
||||
type ResponseState,
|
||||
respondWith,
|
||||
} from '@/storybook/runtime/responseState';
|
||||
|
||||
import {
|
||||
logsSavedViewsResponse,
|
||||
@@ -49,8 +44,6 @@ import {
|
||||
logRowsResponse,
|
||||
QUICK_FILTER_MAX,
|
||||
logsQuickFiltersResponse,
|
||||
RECENT_FILTER_MAX,
|
||||
recentFiltersStorage,
|
||||
RELATIVE_TIME,
|
||||
timeRangeState,
|
||||
} from './__story_mockdata__/logs';
|
||||
@@ -165,20 +158,6 @@ export const logsMocks = defineStoryMocks({
|
||||
value: QUICK_FILTER_MAX,
|
||||
max: QUICK_FILTER_MAX,
|
||||
}),
|
||||
filterValues: choiceControl<ResponseState>('Filter values', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'How `/fields/values` answers once a key and an operator are typed in the filter, apart from the page-wide Data control.',
|
||||
options: RESPONSE_STATES,
|
||||
value: 'loaded',
|
||||
}),
|
||||
recentFilters: countControl('Recent filters', {
|
||||
group: FILTERS,
|
||||
description:
|
||||
'Filters run before in this browser, which the filter offers above its key suggestions.',
|
||||
value: 0,
|
||||
max: RECENT_FILTER_MAX,
|
||||
}),
|
||||
savedViews: countControl('Saved views', {
|
||||
group: FILTERS,
|
||||
description: 'The views the view picker above the query builder lists.',
|
||||
@@ -238,7 +217,7 @@ export const logsMocks = defineStoryMocks({
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
respondWith(values.filterValues, (req) =>
|
||||
response.json((req) =>
|
||||
logFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
@@ -285,16 +264,8 @@ export const logsMocks = defineStoryMocks({
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
effect: ({
|
||||
frequencyChart,
|
||||
filtersPanel,
|
||||
format,
|
||||
maxLines,
|
||||
fontSize,
|
||||
recentFilters,
|
||||
}) => {
|
||||
effect: ({ frequencyChart, filtersPanel, format, maxLines, fontSize }) => {
|
||||
setLocalStorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART, String(frequencyChart));
|
||||
setLocalStorage(...recentFiltersStorage(recentFilters));
|
||||
setLocalStorage(LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, String(filtersPanel));
|
||||
|
||||
// The preferences loader reads localStorage ahead of the URL, so this is
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
blurFilter,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
typeFilterWithCaretBack,
|
||||
findSuggestion,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -217,138 +209,3 @@ export const Tooltips: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: every key the logs carry. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'severity_text');
|
||||
},
|
||||
};
|
||||
|
||||
/** A partial key, with the keys that still match and the typed part marked. */
|
||||
export const FilterPartialKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'serv');
|
||||
await findSuggestion(canvasElement, 'service.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** A string key followed by a space: the operators a string compares with. */
|
||||
export const FilterOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name ');
|
||||
await findSuggestion(canvasElement, 'CONTAINS');
|
||||
},
|
||||
};
|
||||
|
||||
/** A number key puts the range comparisons first. */
|
||||
export const FilterNumberOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'http.status_code ');
|
||||
await findSuggestion(canvasElement, 'BETWEEN');
|
||||
},
|
||||
};
|
||||
|
||||
/** `NOT` after a key narrows the list to the operators it can negate. */
|
||||
export const FilterNegatedOperatorSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name NOT ');
|
||||
await findSuggestion(canvasElement, 'IN');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator: the values the key holds, fetched for it. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
/** Values still being fetched for the key. */
|
||||
export const FilterValuesLoading: Story = {
|
||||
args: { filterValues: 'loading' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Loading suggestions');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key the backend holds no values for, such as the free-text body. */
|
||||
export const FilterNoValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'body = ');
|
||||
await findSuggestion(canvasElement, 'No suggestions available');
|
||||
},
|
||||
};
|
||||
|
||||
/** The values request failed. */
|
||||
export const FilterValuesError: Story = {
|
||||
args: { filterValues: 'error' },
|
||||
// The values request deliberately fails.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'Error loading suggestions');
|
||||
},
|
||||
};
|
||||
|
||||
/** Inside an `IN` list, after the first value: the rest of the values. */
|
||||
export const FilterInList: Story = {
|
||||
// The editor logs a TypeError while the list is open, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(
|
||||
canvasElement,
|
||||
"service.name IN ['auth', ]",
|
||||
1,
|
||||
'checkout',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A complete condition: the conjunctions that start the next one. */
|
||||
export const FilterConjunctionSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, "service.name = 'checkout' ");
|
||||
await findSuggestion(canvasElement, 'OR');
|
||||
},
|
||||
};
|
||||
|
||||
/** Inside an opened group: keys, another group and `NOT`. */
|
||||
export const FilterNestedGroup: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
|
||||
},
|
||||
};
|
||||
|
||||
/** A long valid expression mixing operators, left for the next run. */
|
||||
export const FilterComplete: Story = {
|
||||
// The editor logs a TypeError while the `IN` list is typed, which it survives.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(
|
||||
canvasElement,
|
||||
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
|
||||
);
|
||||
await blurFilter(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** An incomplete expression after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'service.name = ');
|
||||
},
|
||||
};
|
||||
|
||||
/** Filters run before, offered above the key suggestions. */
|
||||
export const FilterRecentSearches: Story = {
|
||||
args: { recentFilters: 3 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(
|
||||
canvasElement,
|
||||
"k8s.namespace.name = 'observability'",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import type { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { STORAGE_VERSION } from 'lib/recentQueries/constants';
|
||||
import type { RecentQueriesStoreShape } from 'lib/recentQueries/types';
|
||||
import { makeId, storageKeyFor } from 'lib/recentQueries/utils';
|
||||
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { quickFiltersListResponse } from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import type { AppState } from 'store/reducers';
|
||||
@@ -473,34 +470,3 @@ const DASHBOARD_NAMES = [
|
||||
|
||||
export const dashboardsResponse = (): ListDashboardsForUserV2200 =>
|
||||
dashboardsForUserResponse(DASHBOARD_NAMES);
|
||||
|
||||
const RECENT_FILTERS = [
|
||||
"service.name = 'checkout' AND severity_text = 'ERROR'",
|
||||
"k8s.namespace.name = 'observability'",
|
||||
"body CONTAINS 'timeout'",
|
||||
'http.status_code >= 500',
|
||||
"deployment.environment IN ['production', 'staging']",
|
||||
];
|
||||
|
||||
/** The filter lists this many recent entries at most. */
|
||||
export const RECENT_FILTER_MAX = RECENT_FILTERS.length;
|
||||
|
||||
/**
|
||||
* The explorer's recent filters as the store keeps them in localStorage,
|
||||
* newest first, five minutes apart. Dated off `new Date()`, which the story
|
||||
* clock freezes, so the "5 minutes ago" labels hold still.
|
||||
*/
|
||||
export const recentFiltersStorage = (count: number): [string, string] => {
|
||||
const store: RecentQueriesStoreShape = {
|
||||
version: STORAGE_VERSION,
|
||||
entries: RECENT_FILTERS.slice(0, count).map((expression, index) => ({
|
||||
id: makeId('logs', '', expression),
|
||||
signal: 'logs',
|
||||
source: '',
|
||||
filter: { expression },
|
||||
lastUsedAt: new Date().getTime() - (index + 1) * 5 * 60_000,
|
||||
})),
|
||||
};
|
||||
|
||||
return [storageKeyFor('logs', ''), JSON.stringify(store)];
|
||||
};
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { screen, userEvent } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
@@ -119,18 +114,3 @@ export const MetricDetailsDashboardsMenu: Story = {
|
||||
await screen.findByRole('menu');
|
||||
},
|
||||
};
|
||||
|
||||
/** The summary's metric search focused: the attributes metrics can be found by. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** The metric search on an attribute and an operator: the values it holds. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,14 +15,10 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockResolver } from '@/storybook/msw/types';
|
||||
|
||||
import {
|
||||
CREATE_OUTCOMES,
|
||||
type CreateOutcome,
|
||||
EXPIRIES,
|
||||
type Expiry,
|
||||
ingestionKeyCreateError,
|
||||
ingestionKeysResponse,
|
||||
KEYS_PER_PAGE,
|
||||
legacyIngestionResponse,
|
||||
@@ -33,11 +29,6 @@ import {
|
||||
const KEYS = 'Ingestion · keys';
|
||||
const LIMITS = 'Ingestion · limits';
|
||||
|
||||
const rejectCreate: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(409), ctx.json(ingestionKeyCreateError()));
|
||||
|
||||
const holdCreate: MockResolver = (_req, res, ctx) => res(ctx.delay('infinite'));
|
||||
|
||||
export const ingestionMocks = defineStoryMocks({
|
||||
controls: {
|
||||
gateway: toggleControl('Gateway', {
|
||||
@@ -59,13 +50,6 @@ export const ingestionMocks = defineStoryMocks({
|
||||
options: EXPIRIES,
|
||||
value: 'none',
|
||||
}),
|
||||
create: choiceControl<CreateOutcome>('Creating a key', {
|
||||
group: KEYS,
|
||||
description:
|
||||
'What the create behind the new key form answers. `hangs` holds the submit button in its loading state.',
|
||||
options: CREATE_OUTCOMES,
|
||||
value: 'succeeds',
|
||||
}),
|
||||
limits: multiChoiceControl<LimitSignal>('Signals with a limit', {
|
||||
group: LIMITS,
|
||||
description:
|
||||
@@ -101,14 +85,10 @@ export const ingestionMocks = defineStoryMocks({
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/gateway/ingestion_keys',
|
||||
{
|
||||
succeeds: response.json(() => ({
|
||||
status: 'success',
|
||||
data: { id: 'ingestion-key-new', value: 'sk_new' },
|
||||
})),
|
||||
fails: rejectCreate,
|
||||
hangs: holdCreate,
|
||||
}[values.create],
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: { id: 'ingestion-key-new', value: 'sk_new' },
|
||||
})),
|
||||
),
|
||||
|
||||
rest.patch(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import dayjs from 'dayjs';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -77,149 +76,16 @@ export const KeyLimits: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
async function openCreateKey(
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<ReturnType<typeof within>> {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText(
|
||||
'New Ingestion key',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
return within(
|
||||
await screen.findByRole(
|
||||
'dialog',
|
||||
{ name: 'Create new ingestion key' },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function addTag(
|
||||
dialog: ReturnType<typeof within>,
|
||||
tag: string,
|
||||
): Promise<void> {
|
||||
await userEvent.click(await dialog.findByRole('button', { name: /New Tag/ }));
|
||||
await userEvent.keyboard(`${tag}{Enter}`);
|
||||
}
|
||||
|
||||
async function submitCreateKey(
|
||||
dialog: ReturnType<typeof within>,
|
||||
): Promise<void> {
|
||||
await userEvent.type(dialog.getByLabelText('Name'), 'otel-collectors');
|
||||
await userEvent.click(dialog.getByLabelText('Expiration'));
|
||||
await userEvent.click(
|
||||
await screen.findByTitle(dayjs().add(1, 'day').format('YYYY-MM-DD')),
|
||||
);
|
||||
await userEvent.click(
|
||||
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The form a new key is named and dated in. */
|
||||
export const CreateKey: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openCreateKey(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
/** A tag typed on the new key and not confirmed yet. */
|
||||
export const CreateKeyAddingTag: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await userEvent.click(dialog.getByRole('button', { name: /New Tag/ }));
|
||||
await userEvent.keyboard('team-payments');
|
||||
},
|
||||
};
|
||||
|
||||
/** The new key with its tags confirmed, each one removable. */
|
||||
export const CreateKeyTagsAdded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
for (const tag of ['team-payments', 'env:production']) {
|
||||
await addTag(dialog, tag);
|
||||
await dialog.findByText(tag);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A tag typed again while the key already has it: Enter keeps the input open
|
||||
* and adds nothing, with no message saying why.
|
||||
*/
|
||||
export const CreateKeyDuplicateTag: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await addTag(dialog, 'team-payments');
|
||||
await addTag(dialog, 'team-payments');
|
||||
await dialog.findByDisplayValue('team-payments');
|
||||
},
|
||||
};
|
||||
|
||||
/** A tag longer than its pill, cut short with an ellipsis. */
|
||||
export const CreateKeyLongTag: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
const tag =
|
||||
'team-payments-platform-observability-production-us-east-1-canary-collectors';
|
||||
await addTag(dialog, tag);
|
||||
await dialog.findByText(tag);
|
||||
},
|
||||
};
|
||||
|
||||
/** A name with a space and no expiration, submitted: each field names its rule. */
|
||||
export const CreateKeyInvalid: Story = {
|
||||
// The page logs the rejected validation through `console.error`.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await userEvent.type(dialog.getByLabelText('Name'), 'otel collectors');
|
||||
await userEvent.click(
|
||||
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
|
||||
);
|
||||
await dialog.findByText(/should only contain letters/);
|
||||
},
|
||||
};
|
||||
|
||||
/** The expiration calendar, where today and every day before it are disabled. */
|
||||
export const CreateKeyExpirationOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await userEvent.click(dialog.getByLabelText('Expiration'));
|
||||
await screen.findByTitle(dayjs().format('YYYY-MM-DD'));
|
||||
},
|
||||
};
|
||||
|
||||
/** The new key sent, with the create still in flight. */
|
||||
export const CreateKeySubmitting: Story = {
|
||||
args: { create: 'hangs' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await submitCreateKey(dialog);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
|
||||
).toHaveAttribute('aria-busy', 'true'),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A create the gateway rejects: the notification names the conflict and the
|
||||
* form keeps what was typed.
|
||||
*/
|
||||
export const CreateKeyFailed: Story = {
|
||||
args: { create: 'fails' },
|
||||
// The deliberate 409 is the state under test.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const dialog = await openCreateKey(canvasElement);
|
||||
await submitCreateKey(dialog);
|
||||
await screen.findByText(
|
||||
'An ingestion key with this name already exists.',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
await within(canvasElement).findByText(
|
||||
'New Ingestion key',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText('Create new ingestion key', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
GatewaytypesIngestionKeyDTO,
|
||||
GatewaytypesLimitDTO,
|
||||
GetIngestionKeys200,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { IngestionInfo } from 'types/api/settings/ingestion';
|
||||
|
||||
@@ -22,10 +21,6 @@ export const EXPIRIES = ['none', 'soon', 'expired'] as const;
|
||||
|
||||
export type Expiry = (typeof EXPIRIES)[number];
|
||||
|
||||
export const CREATE_OUTCOMES = ['succeeds', 'fails', 'hangs'] as const;
|
||||
|
||||
export type CreateOutcome = (typeof CREATE_OUTCOMES)[number];
|
||||
|
||||
const KEY_NAMES = [
|
||||
'production-us-east',
|
||||
'production-eu-west',
|
||||
@@ -122,18 +117,6 @@ export const ingestionKeysResponse = (
|
||||
},
|
||||
});
|
||||
|
||||
export const ingestionKeyCreateError = (): RenderErrorResponseDTO => ({
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'already_exists',
|
||||
type: 'already_exists',
|
||||
message: 'An ingestion key with this name already exists.',
|
||||
url: '',
|
||||
errors: [],
|
||||
suggestions: [],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The pre-gateway endpoint, which answers with one record and no limits: the tab
|
||||
* falls back to it when the gateway feature is off.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
import { RoleType } from 'types/roles';
|
||||
|
||||
import { choiceControl } from '@/storybook/controls/controls';
|
||||
@@ -85,18 +84,3 @@ export const roleEditorMocks = defineStoryMocks({
|
||||
],
|
||||
config: (values) => ({ route: routeFor(values.mode, values.editor) }),
|
||||
});
|
||||
|
||||
/** Opens the Logs card and returns it. */
|
||||
export const openLogsCard = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const header = await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /^Logs:/ },
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await userEvent.click(header);
|
||||
|
||||
return header.parentElement as HTMLElement;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { screen, userEvent, within } from 'storybook/test';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { openLogsCard, roleEditorMocks } from './RoleEditor.stories.mocks';
|
||||
import { roleEditorMocks } from './RoleEditor.stories.mocks';
|
||||
|
||||
import SettingsPage from '../../../Settings';
|
||||
|
||||
@@ -75,27 +75,3 @@ export const Tooltips: Story = {
|
||||
export const TooltipsInJsonEditor: Story = {
|
||||
args: { tooltipsOpen: true, mode: 'edit', editor: 'json' },
|
||||
};
|
||||
|
||||
/** The Logs card's first verb granted over everything instead of nothing. */
|
||||
export const PermissionEditorScope: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const card = await openLogsCard(canvasElement);
|
||||
const [all] = await within(card).findAllByText('All');
|
||||
|
||||
await userEvent.click(all);
|
||||
},
|
||||
};
|
||||
|
||||
/** The selector wizard, opened from a Logs verb scoped to named objects. */
|
||||
export const TelemetrySelectorWizard: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const card = await openLogsCard(canvasElement);
|
||||
const [onlySelected] = await within(card).findAllByText('Only selected');
|
||||
|
||||
await userEvent.click(onlySelected);
|
||||
await userEvent.click(
|
||||
await within(card).findByRole('button', { name: 'Wizard' }),
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Selector Wizard' });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.hasErrors {
|
||||
color: var(--destructive);
|
||||
}
|
||||
37
frontend/src/pages/TraceDetailsV3/StripInfo/StripInfo.tsx
Normal file
37
frontend/src/pages/TraceDetailsV3/StripInfo/StripInfo.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import StripSeparator from 'container/BottomStrip/components/StripSeparator/StripSeparator';
|
||||
import StripTypography from 'container/BottomStrip/components/StripTypography/StripTypography';
|
||||
import cx from 'classnames';
|
||||
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import styles from './StripInfo.module.scss';
|
||||
|
||||
interface StripInfoProps {
|
||||
totalSpansCount: number;
|
||||
totalErrorSpansCount: number;
|
||||
}
|
||||
|
||||
function StripInfo({
|
||||
totalSpansCount,
|
||||
totalErrorSpansCount,
|
||||
}: StripInfoProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<StripTypography prefix={<ChartNoAxesGantt size={13} />}>
|
||||
Spans: {totalSpansCount}
|
||||
</StripTypography>
|
||||
<StripSeparator />
|
||||
<StripTypography
|
||||
prefix={
|
||||
<TriangleAlert
|
||||
size={13}
|
||||
className={cx({ [styles.hasErrors]: totalErrorSpansCount > 0 })}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Errors: {totalErrorSpansCount}
|
||||
</StripTypography>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default StripInfo;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import StripInfo from '../StripInfo';
|
||||
|
||||
describe('StripInfo', () => {
|
||||
it('shows the span and error counts', () => {
|
||||
const { getByText } = render(
|
||||
<StripInfo totalSpansCount={600} totalErrorSpansCount={4} />,
|
||||
);
|
||||
|
||||
expect(getByText('Spans: 600')).toBeInTheDocument();
|
||||
expect(getByText('Errors: 4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero counts rather than hiding them', () => {
|
||||
const { getByText } = render(
|
||||
<StripInfo totalSpansCount={0} totalErrorSpansCount={0} />,
|
||||
);
|
||||
|
||||
expect(getByText('Spans: 0')).toBeInTheDocument();
|
||||
expect(getByText('Errors: 0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,9 @@ import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useBottomStripLeft } from 'container/BottomStrip/useBottomStripLeft';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import StripInfo from './StripInfo/StripInfo';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
@@ -144,6 +146,19 @@ function TraceDetailsV3(): JSX.Element {
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
||||
|
||||
useBottomStripLeft(
|
||||
useMemo(
|
||||
() => (
|
||||
<StripInfo
|
||||
totalSpansCount={totalSpansCount}
|
||||
totalErrorSpansCount={totalErrorSpansCount}
|
||||
/>
|
||||
),
|
||||
[totalSpansCount, totalErrorSpansCount],
|
||||
),
|
||||
);
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
@@ -441,7 +456,10 @@ function TraceDetailsV3(): JSX.Element {
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -172,31 +172,3 @@ export const TraceOptionsMenu: Story = {
|
||||
await screen.findByText('Preview fields');
|
||||
},
|
||||
};
|
||||
|
||||
/** The span panel moved from the right edge to the bottom through its dock toggle. */
|
||||
export const DockModeSwitched: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
// The dock options are icon-only and carry no accessible name.
|
||||
const option = await within(canvasElement).findByTestId(
|
||||
'dock-mode-docked',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await userEvent.click(option.querySelector('button') ?? option);
|
||||
},
|
||||
};
|
||||
|
||||
/** The span's percentile badge expanded into the distribution it was ranked in. */
|
||||
export const SpanPercentileOpen: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
'button',
|
||||
{ name: /^p\d+/ },
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await screen.findByText(/This span duration is/);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
findSuggestion,
|
||||
openKeySuggestions,
|
||||
showFilterErrors,
|
||||
typeFilter,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
@@ -148,41 +142,3 @@ export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** The filter focused before anything is typed: span and resource keys together. */
|
||||
export const FilterKeySuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openKeySuggestions(canvasElement, 'status_code_string');
|
||||
},
|
||||
};
|
||||
|
||||
/** A context prefix: only the keys that live on the resource. */
|
||||
export const FilterResourceKeys: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'resource.');
|
||||
await findSuggestion(canvasElement, 'resource.service.name');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and an operator: the services the spans came from. */
|
||||
export const FilterValueSuggestions: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'service.name = ');
|
||||
await findSuggestion(canvasElement, 'checkout');
|
||||
},
|
||||
};
|
||||
|
||||
/** A boolean key offers its two values. */
|
||||
export const FilterBooleanValues: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await typeFilter(canvasElement, 'has_error = ');
|
||||
await findSuggestion(canvasElement, 'false');
|
||||
},
|
||||
};
|
||||
|
||||
/** A dangling conjunction after focus left: the marker and its errors. */
|
||||
export const FilterSyntaxError: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await showFilterErrors(canvasElement, 'has_error = true AND');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -813,6 +813,12 @@ body.ai-assistant-panel-open {
|
||||
}
|
||||
}
|
||||
|
||||
body.bottom-strip-on {
|
||||
.PylonChat-chatWindowFrameContainer {
|
||||
bottom: var(--bottom-strip-height, 0px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
[role='tab'] {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user