Skip to content

Commit 81a90c1

Browse files
vauwebAleksandr Gasselbakh
andauthored
StatsHouse UI: display dynamic tooltip outer content (#1948)
* display dynamic tooltip for tags based on metric metadata * StatsHouse UI: display dynamic tooltip outer content --------- Co-authored-by: Aleksandr Gasselbakh <[email protected]>
1 parent 0d93d69 commit 81a90c1

20 files changed

+307
-112
lines changed

statshouse-ui/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# StatsHouse UI
2+
3+
## Requirements
4+
5+
* Node 20+
6+
7+
## Working with remote instance of StatsHouse
8+
9+
It is useful to use remote backend, so you can work with actual data.
10+
To do that you need to set up proxy to that instance.
11+
12+
### Setting up proxy
13+
14+
```shell
15+
cp .env .env.local
16+
echo REACT_APP_PROXY=https://statshouse.example.org/ >> .env.local
17+
echo REACT_APP_PROXY_COOKIE="copy Cookie http response header from the instance" >> .env.local
18+
echo REACT_APP_CONFIG="copy <meta name='setting' content='...'/> content from StatsHouse page" >> .env.local
19+
```
20+
21+
## Run dev mode
22+
23+
```shell
24+
npm install
25+
npm run start
26+
```

statshouse-ui/src/api/proxy.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useMemo } from 'react';
2+
import { UndefinedInitialDataOptions, useQuery, UseQueryResult } from '@tanstack/react-query';
3+
import { ExtendedError } from '@/api/api';
4+
5+
export const ApiProxyEndpoint = '/api/proxy';
6+
7+
export function getMarkdownProxiedUrl(
8+
url: string,
9+
enabled: boolean = true
10+
): UndefinedInitialDataOptions<string | undefined, ExtendedError, string, [string, string]> {
11+
return {
12+
enabled,
13+
queryKey: [ApiProxyEndpoint, url],
14+
queryFn: async () => {
15+
const proxiedUrl = `${ApiProxyEndpoint}?url=${encodeURIComponent(url)}`;
16+
const result = await fetch(proxiedUrl, {
17+
headers: {
18+
Accept: 'text/markdown,text/plain',
19+
},
20+
});
21+
const response = await result.text();
22+
if (result.status !== 200) {
23+
return '';
24+
}
25+
return response;
26+
},
27+
};
28+
}
29+
30+
export function useApiProxy(url: string, enabled: boolean = true): UseQueryResult<string, ExtendedError> {
31+
const options = useMemo(() => getMarkdownProxiedUrl(url, enabled), [enabled, url]);
32+
return useQuery({
33+
...options,
34+
});
35+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright 2025 V Kontakte LLC
2+
//
3+
// This Source Code Form is subject to the terms of the Mozilla Public
4+
// License, v. 2.0. If a copy of the MPL was not distributed with this
5+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
6+
7+
import { memo, useContext, useMemo } from 'react';
8+
import ReactMarkdown, { Components } from 'react-markdown';
9+
import remarkGfm from 'remark-gfm';
10+
import { useApiProxy } from '@/api/proxy';
11+
import { useMetricName } from '@/hooks/useMetricName';
12+
import { OuterInfoContextProvider } from '@/contexts/OuterInfoContextProvider';
13+
import { OuterInfoContext } from '@/contexts/OuterInfoContext';
14+
import cn from 'classnames';
15+
import css from './style.module.css';
16+
17+
const remarkPlugins = [remarkGfm];
18+
19+
function usePlaceholderInfo(href: string, value: string) {
20+
const metric_name = useMetricName(true);
21+
return useMemo(() => {
22+
const valuePlaceholder = value.indexOf('⚡ ') === 0 ? value.replace('⚡ ', '') : value;
23+
return href.replace(`%7Bvalue%7D`, valuePlaceholder).replace('%7Bmetric_name%7D', metric_name);
24+
}, [href, metric_name, value]);
25+
}
26+
27+
type MarkdownLoadUrlProps = {
28+
description?: string;
29+
href: string;
30+
};
31+
function MarkdownLoadUrl({ description, href }: MarkdownLoadUrlProps) {
32+
const value = useContext(OuterInfoContext);
33+
const loadHref = usePlaceholderInfo(href, value);
34+
const query = useApiProxy(loadHref);
35+
if (query.isLoading) {
36+
return <p>loading...</p>;
37+
}
38+
if (!query.data) {
39+
return <p>{value || description}</p>;
40+
}
41+
return (
42+
<ReactMarkdown remarkPlugins={remarkPlugins} components={customLinkComponents}>
43+
{query.data}
44+
</ReactMarkdown>
45+
);
46+
}
47+
48+
const customLinkComponents: Components = {
49+
a: function ATag({ node, ...props }) {
50+
return <a {...props} target="_blank" rel="noopener noreferrer" />;
51+
},
52+
p: function PTag({ node, ...props }) {
53+
const otherChildren = useMemo(() => {
54+
if (Array.isArray(props.children)) {
55+
const [_1, _2, ...ch] = props.children;
56+
return ch;
57+
}
58+
return props.children;
59+
}, [props.children]);
60+
const description = useMemo(
61+
() =>
62+
(node &&
63+
node.children?.[1]?.type === 'element' &&
64+
node.children[1].children?.[0]?.type === 'text' &&
65+
node.children[1].children?.[0]?.value) ||
66+
undefined,
67+
[node]
68+
);
69+
if (
70+
node &&
71+
node.children[0].type === 'text' &&
72+
node.children[0].value === '$' &&
73+
node.children[1].type === 'element' &&
74+
node.children[1].tagName === 'a' &&
75+
typeof node.children[1].properties.href === 'string'
76+
) {
77+
return (
78+
<>
79+
<MarkdownLoadUrl href={node.children[1].properties.href} description={description} />
80+
<p>{otherChildren}</p>
81+
</>
82+
);
83+
}
84+
return <p {...props} />;
85+
},
86+
};
87+
88+
const inlineMarkdownAllowedElements = ['p', 'a'];
89+
90+
export type MarkdownRenderProps = {
91+
children?: string;
92+
className?: string;
93+
value?: string;
94+
inline?: boolean;
95+
};
96+
97+
export const MarkdownRender = memo(function MarkdownRender({
98+
children = '',
99+
className,
100+
value = '',
101+
inline,
102+
}: MarkdownRenderProps) {
103+
return (
104+
<OuterInfoContextProvider value={value}>
105+
<div className={cn(css.markdown, inline && css.markdownPreview, className)}>
106+
<ReactMarkdown
107+
remarkPlugins={remarkPlugins}
108+
components={customLinkComponents}
109+
allowedElements={inline ? inlineMarkdownAllowedElements : undefined}
110+
unwrapDisallowed={inline}
111+
>
112+
{children}
113+
</ReactMarkdown>
114+
</div>
115+
</OuterInfoContextProvider>
116+
);
117+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2025 V Kontakte LLC
2+
//
3+
// This Source Code Form is subject to the terms of the Mozilla Public
4+
// License, v. 2.0. If a copy of the MPL was not distributed with this
5+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
6+
7+
import { memo } from 'react';
8+
import { MarkdownRender } from './MarkdownRender';
9+
10+
export type ITooltipMarkdownProps = {
11+
description?: string;
12+
value?: string;
13+
};
14+
15+
export const TooltipMarkdown = memo(function TooltipMarkdown({ description, value }: ITooltipMarkdownProps) {
16+
return (
17+
<>
18+
<div style={{ maxWidth: '80vw', maxHeight: '80vh' }}>
19+
<MarkdownRender value={value}>{description}</MarkdownRender>
20+
</div>
21+
<div className="opacity-0 overflow-hidden h-0" style={{ maxWidth: '80vw', whiteSpace: 'pre' }}>
22+
<MarkdownRender value={value}>{description}</MarkdownRender>
23+
</div>
24+
</>
25+
);
26+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './MarkdownRender';
2+
export * from './TooltipMarkdown';

statshouse-ui/src/components2/style.module.css renamed to statshouse-ui/src/components/Markdown/style.module.css

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,22 @@
55
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
66
*/
77

8-
/* referring to tag <p> because <Markdown> returns content inside tag <p> */
98
.markdown {
10-
white-space: nowrap;
11-
overflow: hidden;
12-
text-overflow: ellipsis;
139
margin: 0;
1410
}
1511

16-
.markdownMargin * {
12+
.markdown * {
1713
margin: 0;
1814
}
1915

20-
.markdownSpace p,
21-
.markdownSpace h1,
22-
.markdownSpace h2,
23-
.markdownSpace h3,
24-
.markdownSpace h4,
25-
.markdownSpace h5,
26-
.markdownSpace h6,
27-
.markdownSpace li {
28-
white-space: break-spaces;
16+
.markdownPreview {
17+
white-space: nowrap;
18+
overflow: hidden;
19+
text-overflow: ellipsis;
20+
flex-grow: 1;
21+
width: 0;
22+
}
23+
24+
.markdownPreview *{
25+
display: inline;
2926
}

statshouse-ui/src/components/UI/Button.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@
55
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
66

77
import React from 'react';
8-
import { Tooltip } from './Tooltip';
8+
import { Tooltip, type TooltipProps } from './Tooltip';
99

1010
export type ButtonProps = {
1111
children?: React.ReactNode;
12-
title?: React.ReactNode;
13-
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'title'>;
12+
} & TooltipProps<'button'>;
1413

1514
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
1615
{ children, title, ...props }: ButtonProps,

statshouse-ui/src/components/UI/Tooltip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export const Tooltip = React.forwardRef<Element, TooltipProps<'div'>>(function T
173173
className={cn(titleClassName, !noStyle && 'card overflow-auto')}
174174
onClick={stopPropagation}
175175
>
176-
<div className={cn(!noStyle && 'card-body p-1')} style={{ minHeight, minWidth, maxHeight, maxWidth }}>
176+
<div className={cn(!noStyle && 'card-body px-3 py-1')} style={{ minHeight, minWidth, maxHeight, maxWidth }}>
177177
<TooltipTitleContent>{title}</TooltipTitleContent>
178178
</div>
179179
</div>

statshouse-ui/src/components/VariableControl/VariableControl.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { MetricMetaTag } from '@/api/metric';
1414
import { MetricTagValueInfo } from '@/api/metricTagValues';
1515
import { escapeHTML } from '@/common/helpers';
1616
import { Button } from '@/components/UI';
17-
import { formatPercent } from '@/view/utils2';
17+
import { clearOuterInfo, formatPercent, isOuterInfo } from '@/view/utils2';
18+
import { TooltipMarkdown } from '@/components/Markdown/TooltipMarkdown';
1819

1920
const emptyListArray: MetricTagValueInfo[] = [];
2021
const emptyValues: string[] = [];
@@ -39,7 +40,7 @@ export type VariableControlProps<T> = {
3940
setOpen?: (name: T | undefined, value: boolean) => void;
4041
customBadge?: React.ReactNode;
4142
};
42-
export function VariableControl<T>({
43+
export function VariableControl<T extends string>({
4344
target,
4445
placeholder,
4546
className,
@@ -126,7 +127,7 @@ export function VariableControl<T>({
126127
<div className={cn('input-group flex-nowrap w-100', small ? 'input-group-sm' : 'input-group')}>
127128
<TagSelect
128129
values={negative ? notValues : values}
129-
placeholder={placeholder}
130+
placeholder={clearOuterInfo(placeholder)}
130131
loading={loaded}
131132
onChange={onChangeFilter}
132133
moreOption={more}
@@ -147,24 +148,46 @@ export function VariableControl<T>({
147148
{customBadge}
148149
{values.map((v) => (
149150
<Button
150-
type="button"
151151
key={v}
152+
type="button"
152153
data-value={v}
153154
className="overflow-force-wrap btn btn-sm py-0 btn-success"
154155
style={{ userSelect: 'text' }}
155156
onClick={onRemoveFilter}
157+
title={
158+
isOuterInfo(placeholder) ? (
159+
<div className="small text-secondary overflow-auto">
160+
<TooltipMarkdown
161+
description={placeholder}
162+
value={formatTagValue(v, tagMeta?.value_comments?.[v], tagMeta?.raw, tagMeta?.raw_kind)}
163+
/>
164+
</div>
165+
) : undefined
166+
}
167+
hover
156168
>
157169
{formatTagValue(v, tagMeta?.value_comments?.[v], tagMeta?.raw, tagMeta?.raw_kind)}
158170
</Button>
159171
))}
160172
{notValues.map((v) => (
161173
<Button
162-
type="button"
163174
key={v}
175+
type="button"
164176
data-value={v}
165177
className="overflow-force-wrap btn btn-sm py-0 btn-danger"
166178
style={{ userSelect: 'text' }}
167179
onClick={onRemoveFilter}
180+
title={
181+
isOuterInfo(placeholder) ? (
182+
<div className="small text-secondary overflow-auto">
183+
<TooltipMarkdown
184+
description={placeholder}
185+
value={formatTagValue(v, tagMeta?.value_comments?.[v], tagMeta?.raw, tagMeta?.raw_kind)}
186+
/>
187+
</div>
188+
) : undefined
189+
}
190+
hover
168191
>
169192
{formatTagValue(v, tagMeta?.value_comments?.[v], tagMeta?.raw, tagMeta?.raw_kind)}
170193
</Button>

statshouse-ui/src/components2/Dashboard/DashboardName.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ import { memo, useMemo, useState } from 'react';
88
import { Tooltip } from '@/components/UI';
99
import { DashboardNameTitle } from './DashboardNameTitle';
1010
import { useStatsHouseShallow } from '@/store2';
11-
import css from '../style.module.css';
12-
import { MarkdownRender } from '@/components2/Plot/PlotView/MarkdownRender';
11+
import { MarkdownRender } from '@/components/Markdown/MarkdownRender';
1312
import { produce } from 'immer';
1413
import { StickyTop } from '../StickyTop';
1514
import { SaveButton } from '../SaveButton';
@@ -80,17 +79,8 @@ export const DashboardName = memo(function DashboardName() {
8079
{!!dashboardDescription && ':'}
8180
</div>
8281
{!!dashboardDescription && (
83-
<div className="text-secondary flex-grow-1 w-0 overflow-hidden">
84-
<MarkdownRender
85-
className={css.markdown}
86-
allowedElements={['p', 'a']}
87-
components={{
88-
p: ({ node, ...props }) => <span {...props} />,
89-
}}
90-
unwrapDisallowed
91-
>
92-
{dashboardDescription}
93-
</MarkdownRender>
82+
<div className="text-secondary flex-grow-1 w-0 d-flex overflow-hidden">
83+
<MarkdownRender inline>{dashboardDescription}</MarkdownRender>
9484
</div>
9585
)}
9686
</Tooltip>

0 commit comments

Comments
 (0)