Mobile UI updates (#2427)
* change(ui): mobile resolution * change(ui): removed empty tag that showign as green icon * change(ui): mobile specific filters * change(ui): remove browser card for mobile * change(ui): summary ai toggle
This commit is contained in:
parent
69e8fab2cc
commit
139f0e68c4
22 changed files with 268 additions and 105 deletions
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
import ExampleFunnel from './Examples/Funnel';
|
||||
import ExamplePath from './Examples/Path';
|
||||
import ExampleTrend from './Examples/Trend';
|
||||
|
|
|
|||
|
|
@ -10,13 +10,15 @@ interface NewDashboardModalProps {
|
|||
open: boolean;
|
||||
isAddingFromLibrary?: boolean;
|
||||
isEnterprise?: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
||||
onClose,
|
||||
open,
|
||||
isAddingFromLibrary = false,
|
||||
isEnterprise = false
|
||||
isEnterprise = false,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const [step, setStep] = React.useState<number>(0);
|
||||
const [selectedCategory, setSelectedCategory] = React.useState<string>('product-analytics');
|
||||
|
|
@ -53,6 +55,7 @@ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
|||
setSelectedCategory={setSelectedCategory}
|
||||
onCard={() => setStep(step + 1)}
|
||||
isLibrary={isAddingFromLibrary}
|
||||
isMobile={isMobile}
|
||||
isEnterprise={isEnterprise} />}
|
||||
{step === 1 && <CreateCard onBack={() => setStep(0)} />}
|
||||
</div>
|
||||
|
|
@ -63,6 +66,7 @@ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
|||
};
|
||||
|
||||
const mapStateToProps = (state: any) => ({
|
||||
isMobile: state.getIn(['site', 'instance', 'platform']) === 'ios',
|
||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
||||
state.getIn(['user', 'account', 'edition']) === 'msaas'
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Button, Input, Segmented, Space } from 'antd';
|
||||
import { RightOutlined } from '@ant-design/icons'
|
||||
import { RightOutlined } from '@ant-design/icons';
|
||||
import { ArrowRight, Info } from 'lucide-react';
|
||||
import { CARD_LIST, CARD_CATEGORIES, CardType } from './ExampleCards';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
|
@ -8,6 +8,7 @@ import Option from './Option';
|
|||
import CardsLibrary from 'Components/Dashboard/components/DashboardList/NewDashModal/CardsLibrary';
|
||||
import { FUNNEL } from 'App/constants/card';
|
||||
import { useHistory } from 'react-router';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
|
||||
interface SelectCardProps {
|
||||
onClose: (refresh?: boolean) => void;
|
||||
|
|
@ -16,10 +17,11 @@ interface SelectCardProps {
|
|||
selected?: string;
|
||||
setSelectedCategory?: React.Dispatch<React.SetStateAction<string>>;
|
||||
isEnterprise?: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const SelectCard: React.FC<SelectCardProps> = (props: SelectCardProps) => {
|
||||
const { onCard, isLibrary = false, selected, setSelectedCategory, isEnterprise } = props;
|
||||
const { onCard, isLibrary = false, selected, setSelectedCategory, isEnterprise, isMobile } = props;
|
||||
const [selectedCards, setSelectedCards] = React.useState<number[]>([]);
|
||||
const { metricStore, dashboardStore } = useStore();
|
||||
const siteId: string = location.pathname.split('/')[1];
|
||||
|
|
@ -74,20 +76,23 @@ const SelectCard: React.FC<SelectCardProps> = (props: SelectCardProps) => {
|
|||
};
|
||||
|
||||
const cardItems = useMemo(() => {
|
||||
return CARD_LIST.filter((card) => card.category === selected && (!card.isEnterprise || (card.isEnterprise && isEnterprise)))
|
||||
.map((card) => (
|
||||
<div key={card.key} className={card.width ? `col-span-${card.width}` : 'col-span-2'}>
|
||||
<card.example
|
||||
onCard={handleCardSelection}
|
||||
type={card.key}
|
||||
title={card.title}
|
||||
data={card.data}
|
||||
height={card.height}
|
||||
hideLegend={card.data?.hideLegend}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}, [selected]);
|
||||
return CARD_LIST.filter((card) =>
|
||||
card.category === selected &&
|
||||
(!card.isEnterprise || (card.isEnterprise && isEnterprise)) &&
|
||||
(!isMobile || (isMobile && ![FilterKey.USER_BROWSER].includes(card.key)))
|
||||
).map((card) => (
|
||||
<div key={card.key} className={card.width ? `col-span-${card.width}` : 'col-span-2'}>
|
||||
<card.example
|
||||
onCard={handleCardSelection}
|
||||
type={card.key}
|
||||
title={card.title}
|
||||
data={card.data}
|
||||
height={card.height}
|
||||
hideLegend={card.data?.hideLegend}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}, [selected, isEnterprise, isMobile]);
|
||||
|
||||
const onCardClick = (cardId: number) => {
|
||||
if (selectedCards.includes(cardId)) {
|
||||
|
|
@ -119,7 +124,7 @@ const SelectCard: React.FC<SelectCardProps> = (props: SelectCardProps) => {
|
|||
)}
|
||||
</div>
|
||||
{isCreatingDashboard && (
|
||||
<Button type="link" onClick={createNewDashboard} loading={dashboardCreating} className='gap-2'>
|
||||
<Button type="link" onClick={createNewDashboard} loading={dashboardCreating} className="gap-2">
|
||||
<Space>
|
||||
Create Blank
|
||||
<RightOutlined />
|
||||
|
|
|
|||
|
|
@ -169,10 +169,10 @@ interface DevtoolsButtonsProps {
|
|||
bottomBlock: number;
|
||||
}
|
||||
|
||||
function DevtoolsButtons({
|
||||
const DevtoolsButtons = observer(({
|
||||
toggleBottomTools,
|
||||
bottomBlock,
|
||||
}: DevtoolsButtonsProps) {
|
||||
}: DevtoolsButtonsProps) => {
|
||||
const { aiSummaryStore } = useStore();
|
||||
const { store, player } = React.useContext(MobilePlayerContext);
|
||||
|
||||
|
|
@ -277,7 +277,7 @@ function DevtoolsButtons({
|
|||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})
|
||||
|
||||
const ControlPlayer = observer(Controls);
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ function UserCard({ className, request, session, width, height, similarSessions,
|
|||
userDisplayName,
|
||||
userDeviceType,
|
||||
revId,
|
||||
screenWidth,
|
||||
screenHeight
|
||||
} = session;
|
||||
|
||||
const hasUserDetails = !!userId || !!userAnonymousId;
|
||||
|
|
@ -137,7 +139,7 @@ function UserCard({ className, request, session, width, height, similarSessions,
|
|||
<SessionInfoItem
|
||||
icon={deviceTypeIcon(userDeviceType)}
|
||||
label={userDeviceType}
|
||||
value={getDimension(width, height)}
|
||||
value={getDimension(width || screenWidth, height || screenHeight)}
|
||||
isLast={!revId}
|
||||
/>
|
||||
{revId && <SessionInfoItem icon="info" label="Rev ID:" value={revId} isLast />}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { formatBytes } from 'App/utils';
|
||||
import CopyText from 'Shared/CopyText';
|
||||
import {Tag} from 'antd';
|
||||
import { Tag } from 'antd';
|
||||
import cn from 'classnames';
|
||||
|
||||
interface Props {
|
||||
resource: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
function FetchBasicDetails({ resource, timestamp }: Props) {
|
||||
const _duration = parseInt(resource.duration);
|
||||
const text = useMemo(() => {
|
||||
|
|
@ -22,14 +23,16 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
<div>
|
||||
<div className="flex items-start py-1">
|
||||
<div className="font-medium w-36">Name</div>
|
||||
<Tag className='text-base max-w-96 rounded-lg text-clip bg-indigo-50 whitespace-nowrap overflow-hidden text-clip cursor-pointer word-break' bordered={false}>
|
||||
<Tag
|
||||
className="text-base max-w-96 rounded-lg text-clip bg-indigo-50 whitespace-nowrap overflow-hidden text-clip cursor-pointer word-break"
|
||||
bordered={false}>
|
||||
<CopyText content={resource.url}>{resource.url}</CopyText>
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Type</div>
|
||||
<Tag className='text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip' bordered={false}>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
{resource.type}
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -37,7 +40,8 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
{resource.method && (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Request Method</div>
|
||||
<Tag className='text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip' bordered={false}>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{resource.method}
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -47,15 +51,12 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
<div className="flex items-center py-1">
|
||||
<div className="text-base font-medium w-36">Status Code</div>
|
||||
<Tag
|
||||
bordered={false}
|
||||
bordered={false}
|
||||
className={cn(
|
||||
'text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip flex items-center',
|
||||
{ 'error color-red': !resource.success }
|
||||
)}
|
||||
>
|
||||
{resource.status === '200' && (
|
||||
<Tag bordered={false} className="text-base bg-emerald-100 rounded-full mr-2"></Tag>
|
||||
)}
|
||||
{resource.status}
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -63,7 +64,8 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Type</div>
|
||||
<Tag className="text-base capitalize rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
<Tag className="text-base capitalize rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{resource.type}
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -71,18 +73,19 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
{!!resource.decodedBodySize && (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Size</div>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{formatBytes(resource.decodedBodySize)}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{!!_duration && (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Duration</div>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{_duration} ms
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -90,11 +93,12 @@ function FetchBasicDetails({ resource, timestamp }: Props) {
|
|||
|
||||
{timestamp && (
|
||||
<div className="flex items-center py-1">
|
||||
<div className="font-medium w-36">Time</div>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip" bordered={false}>
|
||||
{timestamp}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="font-medium w-36">Time</div>
|
||||
<Tag className="text-base rounded-lg bg-indigo-50 whitespace-nowrap overflow-hidden text-clip"
|
||||
bordered={false}>
|
||||
{timestamp}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { connect } from 'react-redux';
|
|||
import { Icon, Loader } from 'UI';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
|
||||
import { FilterKey } from '../../../../types/filter/filterType';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import stl from './FilterModal.module.css';
|
||||
|
||||
const IconMap = {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ interface Props {
|
|||
function Activity(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
|
||||
<svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6 2a.5.5 0 0 1 .47.33L10 12.036l1.53-4.208A.5.5 0 0 1 12 7.5h3.5a.5.5 0 0 1 0 1h-3.15l-1.88 5.17a.5.5 0 0 1-.94 0L6 3.964 4.47 8.171A.5.5 0 0 1 4 8.5H.5a.5.5 0 0 1 0-1h3.15l1.88-5.17A.5.5 0 0 1 6 2Z"/></svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface Props {
|
|||
}
|
||||
|
||||
function Console_info(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h12zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2z"/><path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/></svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function Filters_chevrons_up_down(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width={ `${ width }px` } height={ `${ height }px` } ><path d="m7 15 5 5 5-5M7 9l5-5 5 5"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Filters_chevrons_up_down;
|
||||
19
frontend/app/components/ui/Icons/filters_screen.tsx
Normal file
19
frontend/app/components/ui/Icons/filters_screen.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
/* Auto-generated, do not edit */
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
size?: number | string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
function Filters_screen(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width={ `${ width }px` } height={ `${ height }px` } ><path d="M21 17v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2M21 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2"/><circle cx="12" cy="12" r="1"/><path d="M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Filters_screen;
|
||||
|
|
@ -275,6 +275,7 @@ export { default as Filetype_pdf } from './filetype_pdf';
|
|||
export { default as Filter } from './filter';
|
||||
export { default as Filters_arrow_return_right } from './filters_arrow_return_right';
|
||||
export { default as Filters_browser } from './filters_browser';
|
||||
export { default as Filters_chevrons_up_down } from './filters_chevrons_up_down';
|
||||
export { default as Filters_click } from './filters_click';
|
||||
export { default as Filters_clickrage } from './filters_clickrage';
|
||||
export { default as Filters_code } from './filters_code';
|
||||
|
|
@ -303,6 +304,7 @@ export { default as Filters_platform } from './filters_platform';
|
|||
export { default as Filters_referrer } from './filters_referrer';
|
||||
export { default as Filters_resize } from './filters_resize';
|
||||
export { default as Filters_rev_id } from './filters_rev_id';
|
||||
export { default as Filters_screen } from './filters_screen';
|
||||
export { default as Filters_state_action } from './filters_state_action';
|
||||
export { default as Filters_tag_element } from './filters_tag_element';
|
||||
export { default as Filters_ttfb } from './filters_ttfb';
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface Props {
|
|||
function Pdf_download(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={ `${ width }px` } height={ `${ height }px` } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-down"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 18v-6"/><path d="m9 15 3 3 3-3"/></svg>
|
||||
<svg viewBox="0 0 19 19" width={ `${ width }px` } height={ `${ height }px` } ><path d="M10.094 5.249a.594.594 0 0 0-1.188 0v4.504l-1.36-1.362a.595.595 0 0 0-.841.84l2.375 2.376a.596.596 0 0 0 .84 0l2.375-2.375a.595.595 0 0 0-.84-.841l-1.361 1.362V5.249Z"/><path d="M16.625 16.625V5.344L11.281 0H4.75a2.375 2.375 0 0 0-2.375 2.375v14.25A2.375 2.375 0 0 0 4.75 19h9.5a2.375 2.375 0 0 0 2.375-2.375ZM11.281 3.562a1.781 1.781 0 0 0 1.781 1.782h2.376v11.281a1.188 1.188 0 0 1-1.188 1.188h-9.5a1.187 1.187 0 0 1-1.188-1.188V2.375A1.188 1.188 0 0 1 4.75 1.187h6.531v2.375Z"/><path clipRule="evenodd" d="M15.58 13.49H3.42v4.37h1.789v-3.512H6.61c.282 0 .524.051.726.154.205.103.361.245.47.425.11.178.165.383.165.613 0 .226-.055.424-.164.593-.11.169-.266.3-.47.396-.203.093-.445.14-.727.14h-.554v1.191h2.37v-3.512h1.13c.238 0 .455.041.653.123a1.537 1.537 0 0 1 .854.88c.08.205.12.431.12.68v.148c0 .247-.04.474-.12.68a1.512 1.512 0 0 1-.849.88c-.193.08-.404.12-.635.121h2.046v-3.512h2.35v.654h-1.503v.808h1.365v.651h-1.365v1.399h3.108v-4.37Zm-9.524 1.512v1.013h.554c.12 0 .216-.02.29-.06a.367.367 0 0 0 .161-.167.547.547 0 0 0 .054-.244.668.668 0 0 0-.054-.267.431.431 0 0 0-.161-.198.496.496 0 0 0-.29-.077h-.554Zm3.512 2.207h-.295v-2.207h.283c.123 0 .233.021.328.065a.604.604 0 0 1 .24.195.88.88 0 0 1 .146.321c.033.127.05.275.05.444v.152c0 .225-.03.415-.089.569a.707.707 0 0 1-.256.345.696.696 0 0 1-.407.116Z"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface Props {
|
|||
function Pencil(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={ `${ width }px` } height={ `${ height }px` } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-pen-line"><path d="m18 5-2.414-2.414A2 2 0 0 0 14.172 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2"/><path d="M21.378 12.626a1 1 0 0 0-3.004-3.004l-4.01 4.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/><path d="M8 18h1"/></svg>
|
||||
<svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface Props {
|
|||
function Trash(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={ `${ width }px` } height={ `${ height }px` } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" width={ `${ width }px` } height={ `${ height }px` } ><path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ interface Props {
|
|||
function Users(props: Props) {
|
||||
const { size = 14, width = size, height = size, fill = '' } = props;
|
||||
return (
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={ `${ width }px` } height={ `${ height }px` } viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users-round"><path d="M18 21a8 8 0 0 0-16 0"/><circle cx="10" cy="8" r="5"/><path d="M22 20c0-3.37-2-6.5-4-8a5 5 0 0 0-.45-8.3"/></svg>
|
||||
<svg viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M15 14s1 0 1-1-1-4-5-4-5 3-5 4 1 1 1 1h8zm-7.978-1A.261.261 0 0 1 7 12.996c.001-.264.167-1.03.76-1.72C8.312 10.629 9.282 10 11 10c1.717 0 2.687.63 3.24 1.276.593.69.758 1.457.76 1.72l-.008.002a.274.274 0 0 1-.014.002H7.022zM11 7a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm3-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM6.936 9.28a5.88 5.88 0 0 0-1.23-.247A7.35 7.35 0 0 0 5 9c-4 0-5 3-5 4 0 .667.333 1 1 1h4.216A2.238 2.238 0 0 1 5 13c0-1.01.377-2.042 1.09-2.904.243-.294.526-.569.846-.816zM4.92 10A5.493 5.493 0 0 0 4 13H1c0-.26.164-1.03.76-1.724.545-.636 1.492-1.256 3.16-1.275zM1.5 5.5a3 3 0 1 1 6 0 3 3 0 0 1-6 0zm3-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/></svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -76,7 +76,7 @@ function reducer(state = initialState, action = {}) {
|
|||
switch (action.type) {
|
||||
case REFRESH_FILTER_OPTIONS:
|
||||
return state
|
||||
.set('filterList', generateFilterOptions(filtersMap))
|
||||
.set('filterList', generateFilterOptions(filtersMap, action.isMobile))
|
||||
.set('filterListLive', generateFilterOptions(liveFiltersMap))
|
||||
.set(
|
||||
'filterListConditional',
|
||||
|
|
@ -466,10 +466,12 @@ export const editSavedSearch = (instance) => {
|
|||
};
|
||||
};
|
||||
|
||||
export const refreshFilterOptions = () => {
|
||||
return {
|
||||
export const refreshFilterOptions = () => (dispatch, getState) => {
|
||||
const currentProject = getState().getIn(['site', 'instance']);
|
||||
return dispatch({
|
||||
type: REFRESH_FILTER_OPTIONS,
|
||||
};
|
||||
isMobile: currentProject?.platform === 'ios'
|
||||
});
|
||||
};
|
||||
|
||||
export const setScrollPosition = (scrollPosition) => {
|
||||
|
|
|
|||
1
frontend/app/svg/icons/filters/chevrons-up-down.svg
Normal file
1
frontend/app/svg/icons/filters/chevrons-up-down.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
1
frontend/app/svg/icons/filters/screen.svg
Normal file
1
frontend/app/svg/icons/filters/screen.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-view"><path d="M21 17v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2"/><path d="M21 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2"/><circle cx="12" cy="12" r="1"/><path d="M18.944 12.33a1 1 0 0 0 0-.66 7.5 7.5 0 0 0-13.888 0 1 1 0 0 0 0 .66 7.5 7.5 0 0 0 13.888 0"/></svg>
|
||||
|
After Width: | Height: | Size: 430 B |
|
|
@ -196,6 +196,14 @@ export enum FilterType {
|
|||
}
|
||||
|
||||
export enum FilterKey {
|
||||
CLICK_MOBILE = 'clickMobile',
|
||||
INPUT_MOBILE = 'inputMobile',
|
||||
VIEW_MOBILE = 'viewMobile',
|
||||
CUSTOM_MOBILE = 'customMobile',
|
||||
REQUEST_MOBILE = 'requestMobile',
|
||||
ERROR_MOBILE = 'errorMobile',
|
||||
SWIPE_MOBILE = 'swipeMobile',
|
||||
|
||||
ERROR = 'error',
|
||||
MISSING_RESOURCE = 'missingResource',
|
||||
SLOW_SESSION = 'slowSession',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { stringConditional, tagElementOperators, targetConditional } from "App/constants/filterOptions";
|
||||
import { stringConditional, tagElementOperators, targetConditional } from 'App/constants/filterOptions';
|
||||
import { KEYS } from 'Types/filter/customFilter';
|
||||
import Record from 'Types/Record';
|
||||
import { FilterType, FilterKey, FilterCategory } from './filterType';
|
||||
|
|
@ -13,10 +13,78 @@ const filterOrder = {
|
|||
[FilterCategory.TECHNICAL]: 1,
|
||||
[FilterCategory.PERFORMANCE]: 2,
|
||||
[FilterCategory.USER]: 3,
|
||||
[FilterCategory.GEAR]: 4,
|
||||
}
|
||||
[FilterCategory.GEAR]: 4
|
||||
};
|
||||
|
||||
export const mobileFilters = [
|
||||
{
|
||||
key: FilterKey.CLICK_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.INTERACTIONS,
|
||||
label: 'Tap',
|
||||
operator: 'on',
|
||||
operatorOptions: filterOptions.targetOperators,
|
||||
icon: 'filters/click',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.INPUT_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.INTERACTIONS,
|
||||
label: 'Text Input',
|
||||
placeholder: 'Enter input label name',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/input',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.VIEW_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.INTERACTIONS,
|
||||
label: 'Screen',
|
||||
placeholder: 'Enter screen name',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/screen',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.CUSTOM_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.TECHNICAL,
|
||||
label: 'Custom Events',
|
||||
placeholder: 'Enter event key',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/custom',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.ERROR_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.TECHNICAL,
|
||||
label: 'Error Message',
|
||||
placeholder: 'E.g. Uncaught SyntaxError',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/error',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.SWIPE_MOBILE,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.INTERACTIONS,
|
||||
label: 'Swipe',
|
||||
operator: 'on',
|
||||
operatorOptions: filterOptions.targetOperators,
|
||||
icon: 'filters/chevrons-up-down',
|
||||
isEvent: true
|
||||
}
|
||||
];
|
||||
|
||||
export const filters = [
|
||||
...mobileFilters,
|
||||
{
|
||||
key: FilterKey.CLICK,
|
||||
type: FilterType.MULTIPLE,
|
||||
|
|
@ -96,7 +164,7 @@ export const filters = [
|
|||
operator: 'is',
|
||||
placeholder: 'Select method type',
|
||||
operatorOptions: filterOptions.stringOperatorsLimited,
|
||||
icon: 'filters/fetch',
|
||||
icon: 'filters/fetch',
|
||||
options: filterOptions.methodOptions
|
||||
},
|
||||
{
|
||||
|
|
@ -232,7 +300,7 @@ export const filters = [
|
|||
isEvent: true,
|
||||
icon: 'filters/tag-element',
|
||||
operatorOptions: filterOptions.tagElementOperators,
|
||||
options: [],
|
||||
options: []
|
||||
},
|
||||
{
|
||||
key: FilterKey.UTM_SOURCE,
|
||||
|
|
@ -241,7 +309,7 @@ export const filters = [
|
|||
label: 'UTM Source',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/country',
|
||||
icon: 'filters/country'
|
||||
},
|
||||
{
|
||||
key: FilterKey.UTM_MEDIUM,
|
||||
|
|
@ -250,7 +318,7 @@ export const filters = [
|
|||
label: 'UTM Medium',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/country',
|
||||
icon: 'filters/country'
|
||||
},
|
||||
{
|
||||
key: FilterKey.UTM_CAMPAIGN,
|
||||
|
|
@ -259,7 +327,7 @@ export const filters = [
|
|||
label: 'UTM Campaign',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/country',
|
||||
icon: 'filters/country'
|
||||
},
|
||||
{
|
||||
key: FilterKey.USER_COUNTRY,
|
||||
|
|
@ -471,12 +539,12 @@ export const filters = [
|
|||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'collection'
|
||||
},
|
||||
}
|
||||
].sort((a, b) => {
|
||||
const aOrder = filterOrder[a.category] ?? 9
|
||||
const bOrder = filterOrder[b.category] ?? 9
|
||||
return aOrder - bOrder
|
||||
})
|
||||
const aOrder = filterOrder[a.category] ?? 9;
|
||||
const bOrder = filterOrder[b.category] ?? 9;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
export const flagConditionFilters = [
|
||||
{
|
||||
|
|
@ -559,10 +627,10 @@ export const flagConditionFilters = [
|
|||
icon: 'filters/userid'
|
||||
}
|
||||
].sort((a, b) => {
|
||||
const aOrder = filterOrder[a.category] ?? 9
|
||||
const bOrder = filterOrder[b.category] ?? 9
|
||||
return aOrder - bOrder
|
||||
})
|
||||
const aOrder = filterOrder[a.category] ?? 9;
|
||||
const bOrder = filterOrder[b.category] ?? 9;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
export const conditionalFilters = [
|
||||
{
|
||||
|
|
@ -612,7 +680,7 @@ export const conditionalFilters = [
|
|||
placeholder: 'Enter path or URL',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringConditional,
|
||||
icon: "filters/fetch"
|
||||
icon: 'filters/fetch'
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_STATUS_CODE,
|
||||
|
|
@ -622,7 +690,7 @@ export const conditionalFilters = [
|
|||
placeholder: 'Enter status code',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
icon: 'filters/fetch'
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_METHOD,
|
||||
|
|
@ -643,8 +711,8 @@ export const conditionalFilters = [
|
|||
placeholder: 'E.g. 12',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
},
|
||||
icon: 'filters/fetch'
|
||||
}
|
||||
],
|
||||
icon: 'filters/fetch',
|
||||
isEvent: true
|
||||
|
|
@ -667,7 +735,7 @@ export const conditionalFilters = [
|
|||
label: 'Duration',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: "filters/duration",
|
||||
icon: 'filters/duration',
|
||||
isEvent: false
|
||||
},
|
||||
{
|
||||
|
|
@ -690,10 +758,10 @@ export const conditionalFilters = [
|
|||
icon: 'filters/userid'
|
||||
}
|
||||
].sort((a, b) => {
|
||||
const aOrder = filterOrder[a.category] ?? 9
|
||||
const bOrder = filterOrder[b.category] ?? 9
|
||||
return aOrder - bOrder
|
||||
})
|
||||
const aOrder = filterOrder[a.category] ?? 9;
|
||||
const bOrder = filterOrder[b.category] ?? 9;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
export const mobileConditionalFilters = [
|
||||
{
|
||||
|
|
@ -703,7 +771,7 @@ export const mobileConditionalFilters = [
|
|||
label: 'Duration',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: "filters/duration",
|
||||
icon: 'filters/duration',
|
||||
isEvent: false
|
||||
},
|
||||
{
|
||||
|
|
@ -721,7 +789,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'Enter path or URL',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringConditional,
|
||||
icon: "filters/fetch"
|
||||
icon: 'filters/fetch'
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_STATUS_CODE,
|
||||
|
|
@ -731,7 +799,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'Enter status code',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
icon: 'filters/fetch'
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_METHOD,
|
||||
|
|
@ -752,8 +820,8 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'E.g. 12',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
},
|
||||
icon: 'filters/fetch'
|
||||
}
|
||||
],
|
||||
icon: 'filters/fetch',
|
||||
isEvent: true
|
||||
|
|
@ -779,11 +847,11 @@ export const mobileConditionalFilters = [
|
|||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: 'filters/cpu-load',
|
||||
options: [
|
||||
{ label: 'nominal', value: "0" },
|
||||
{ label: 'warm', value: "1" },
|
||||
{ label: 'hot', value: "2" },
|
||||
{ label: 'critical', value: "3" }
|
||||
],
|
||||
{ label: 'nominal', value: '0' },
|
||||
{ label: 'warm', value: '1' },
|
||||
{ label: 'hot', value: '2' },
|
||||
{ label: 'critical', value: '3' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'mainThreadCPU',
|
||||
|
|
@ -793,7 +861,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: '0 .. 100',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: 'filters/cpu-load',
|
||||
icon: 'filters/cpu-load'
|
||||
},
|
||||
{
|
||||
key: 'viewComponent',
|
||||
|
|
@ -803,7 +871,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'View Name',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: 'filters/view',
|
||||
icon: 'filters/view'
|
||||
},
|
||||
{
|
||||
key: FilterKey.USERID,
|
||||
|
|
@ -833,7 +901,7 @@ export const mobileConditionalFilters = [
|
|||
placeholder: 'logged value',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
icon: 'filters/console',
|
||||
icon: 'filters/console'
|
||||
},
|
||||
{
|
||||
key: 'clickEvent',
|
||||
|
|
@ -854,7 +922,7 @@ export const mobileConditionalFilters = [
|
|||
operatorOptions: filterOptions.customOperators,
|
||||
icon: 'filters/memory-load'
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
export const eventKeys = filters.filter((i) => i.isEvent).map(i => i.key);
|
||||
export const nonFlagFilters = filters.filter(i => {
|
||||
|
|
@ -955,12 +1023,12 @@ export const addElementToFiltersMap = (
|
|||
|
||||
export const addOptionsToFilter = (
|
||||
key,
|
||||
options,
|
||||
options
|
||||
) => {
|
||||
if (filtersMap[key] && filtersMap[key].options) {
|
||||
filtersMap[key].options = options
|
||||
filtersMap[key].options = options;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getMetadataLabel(key) {
|
||||
return key.replace(/^_/, '').charAt(0).toUpperCase() + key.slice(2);
|
||||
|
|
@ -1008,11 +1076,11 @@ export const addElementToConditionalFiltersMap = (
|
|||
|
||||
export const addElementToMobileConditionalFiltersMap = (
|
||||
category = FilterCategory.METADATA,
|
||||
key,
|
||||
type = FilterType.MULTIPLE,
|
||||
operator = 'is',
|
||||
operatorOptions = filterOptions.stringOperators,
|
||||
icon = 'filters/metadata'
|
||||
key,
|
||||
type = FilterType.MULTIPLE,
|
||||
operator = 'is',
|
||||
operatorOptions = filterOptions.stringOperators,
|
||||
icon = 'filters/metadata'
|
||||
) => {
|
||||
mobileConditionalFiltersMap[key] = {
|
||||
key,
|
||||
|
|
@ -1023,8 +1091,8 @@ export const addElementToMobileConditionalFiltersMap = (
|
|||
operatorOptions,
|
||||
icon,
|
||||
isLive: true
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const addElementToLiveFiltersMap = (
|
||||
category = FilterCategory.METADATA,
|
||||
|
|
@ -1094,7 +1162,7 @@ export default Record({
|
|||
_filter = filtersMap[`_${filter.source}`];
|
||||
} else {
|
||||
if (filtersMap[filter.key]) {
|
||||
_filter = filtersMap[filter.key]
|
||||
_filter = filtersMap[filter.key];
|
||||
} else {
|
||||
_filter = filtersMap[type];
|
||||
}
|
||||
|
|
@ -1118,14 +1186,35 @@ export default Record({
|
|||
}
|
||||
});
|
||||
|
||||
const WEB_EXCLUDE = [
|
||||
FilterKey.CLICK_MOBILE, FilterKey.SWIPE_MOBILE, FilterKey.INPUT_MOBILE,
|
||||
FilterKey.VIEW_MOBILE, FilterKey.CUSTOM_MOBILE, FilterKey.REQUEST_MOBILE, FilterKey.ERROR_MOBILE
|
||||
];
|
||||
|
||||
const MOBILE_EXCLUDE = [
|
||||
FilterKey.CLICK, FilterKey.INPUT, FilterKey.ERROR, FilterKey.CUSTOM,
|
||||
FilterKey.LOCATION, FilterKey.FETCH, FilterKey.DOM_COMPLETE,
|
||||
FilterKey.LARGEST_CONTENTFUL_PAINT_TIME, FilterKey.TTFB, FilterKey.USER_BROWSER,
|
||||
FilterKey.PLATFORM
|
||||
];
|
||||
|
||||
/**
|
||||
* Group filters by category
|
||||
* @param {*} filtersMap
|
||||
* @returns
|
||||
* @param map
|
||||
* @param isMobile
|
||||
*/
|
||||
export const generateFilterOptions = (map) => {
|
||||
export const generateFilterOptions = (map, isMobile = false) => {
|
||||
const filterSection = {};
|
||||
Object.keys(map).forEach(key => {
|
||||
if (isMobile && MOBILE_EXCLUDE.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMobile && WEB_EXCLUDE.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = map[key];
|
||||
if (filterSection.hasOwnProperty(filter.category)) {
|
||||
filterSection[filter.category].push(filter);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue