ui: drag and drop table comp, separate item page
This commit is contained in:
parent
a9db2f224d
commit
3a07a20195
6 changed files with 348 additions and 146 deletions
|
|
@ -10,14 +10,14 @@ function EventDetailsModal({ ev, onClose }: { ev: EventData, onClose: () => void
|
|||
label: 'All Properties',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: 'Openreplay Properties',
|
||||
value: 'default',
|
||||
},
|
||||
{
|
||||
label: 'Custom Properties',
|
||||
value: 'custom',
|
||||
},
|
||||
{
|
||||
label: 'Default Properties',
|
||||
value: 'default',
|
||||
}
|
||||
]
|
||||
|
||||
const views = [
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from 'react';
|
||||
import { EventsList, FilterList } from 'Shared/Filters/FilterList';
|
||||
import { Table, Dropdown } from 'antd';
|
||||
import { Dropdown, Button } from 'antd';
|
||||
import { MoreOutlined } from '@ant-design/icons';
|
||||
import { numberWithCommas } from 'App/utils';
|
||||
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
|
||||
import ColumnsModal from 'Components/DataManagement/Activity/ColumnsModal';
|
||||
import Event from './data/Event';
|
||||
|
|
@ -11,10 +10,12 @@ import EventDetailsModal from './EventDetailsModal';
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'Shared/Select';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { dataManagement, withSiteId } from 'App/routes'
|
||||
import { dataManagement, withSiteId } from 'App/routes';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import FullPagination from "Shared/FullPagination";
|
||||
import FullPagination from 'Shared/FullPagination';
|
||||
import AnimatedSVG from 'Shared/AnimatedSVG';
|
||||
import DndTable from 'Shared/DNDTable';
|
||||
|
||||
const limit = 100;
|
||||
|
||||
|
|
@ -61,27 +62,11 @@ const fetcher = async (
|
|||
});
|
||||
};
|
||||
|
||||
function ActivityPage() {
|
||||
const { projectsStore } = useStore()
|
||||
const siteId = projectsStore.activeSiteId;
|
||||
const columnOrderKey = '$__activity_columns_order__$';
|
||||
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hiddenCols, setHiddenCols] = React.useState([]);
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: ['data', 'events', page],
|
||||
queryFn: () => fetcher(page),
|
||||
initialData: { list: [], total: 0 },
|
||||
});
|
||||
const { list, total } = data;
|
||||
const appliedFilter = { filters: [] };
|
||||
const onAddFilter = () => {};
|
||||
const onUpdateFilter = () => {};
|
||||
const onRemoveFilter = () => {};
|
||||
const onChangeEventsOrder = () => {};
|
||||
const saveRequestPayloads = () => {};
|
||||
const onFilterMove = () => {};
|
||||
const [editCols, setEditCols] = React.useState(false);
|
||||
const { showModal, hideModal } = useModal();
|
||||
function ActivityPage() {
|
||||
const { projectsStore } = useStore();
|
||||
const siteId = projectsStore.activeSiteId;
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
|
|
@ -90,7 +75,6 @@ function ActivityPage() {
|
|||
onClick: () => setTimeout(() => setEditCols(true), 1),
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Event Name',
|
||||
|
|
@ -100,9 +84,7 @@ function ActivityPage() {
|
|||
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||
render: (text, row) => (
|
||||
<div className={'flex items-center gap-2'}>
|
||||
{row.$_isAutoCapture && (
|
||||
<span className={'text-gray-500'}>[auto]</span>
|
||||
)}
|
||||
{row.$_isAutoCapture && <span className={'text-gray-500'}>[a]</span>}
|
||||
<span>{row.name}</span>
|
||||
</div>
|
||||
),
|
||||
|
|
@ -164,7 +146,53 @@ function ActivityPage() {
|
|||
},
|
||||
];
|
||||
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [cols, setCols] = React.useState(columns);
|
||||
const [hiddenCols, setHiddenCols] = React.useState([]);
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: ['data', 'events', page],
|
||||
queryFn: () => fetcher(page),
|
||||
initialData: { list: [], total: 0 },
|
||||
});
|
||||
const { list, total } = data;
|
||||
const appliedFilter = { filters: [] };
|
||||
const onAddFilter = () => {};
|
||||
const onUpdateFilter = () => {};
|
||||
const onRemoveFilter = () => {};
|
||||
const onChangeEventsOrder = () => {};
|
||||
const saveRequestPayloads = () => {};
|
||||
const onFilterMove = () => {};
|
||||
const [editCols, setEditCols] = React.useState(false);
|
||||
const { showModal, hideModal } = useModal();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hiddenCols.length) {
|
||||
setCols((cols) =>
|
||||
cols.map((col) => ({
|
||||
...col,
|
||||
hidden: hiddenCols.includes(col.key),
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [hiddenCols]);
|
||||
React.useEffect(() => {
|
||||
const savedColumnOrder = localStorage.getItem(columnOrderKey);
|
||||
if (savedColumnOrder) {
|
||||
const keys = savedColumnOrder.split(',');
|
||||
setCols((cols) => {
|
||||
return cols.sort((a, b) => {
|
||||
return keys.indexOf(a.key) - keys.indexOf(b.key);
|
||||
});
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onOrderChange = (newCols) => {
|
||||
const order = newCols.map((col) => col.key).join(',');
|
||||
localStorage.setItem(columnOrderKey, order);
|
||||
|
||||
setCols(newCols);
|
||||
};
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
setPage(page);
|
||||
|
|
@ -177,10 +205,6 @@ function ActivityPage() {
|
|||
});
|
||||
};
|
||||
|
||||
const shownCols = columns.map((col) => ({
|
||||
...col,
|
||||
hidden: hiddenCols.includes(col.key),
|
||||
}));
|
||||
const onUpdateVisibleCols = (cols: string[]) => {
|
||||
setHiddenCols((_) => {
|
||||
return columns
|
||||
|
|
@ -271,23 +295,50 @@ function ActivityPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
loading={isPending}
|
||||
onRow={(record) => ({
|
||||
onClick: () => onItemClick(record),
|
||||
})}
|
||||
dataSource={list}
|
||||
pagination={false}
|
||||
columns={shownCols}
|
||||
/>
|
||||
<FullPagination
|
||||
page={page}
|
||||
limit={limit}
|
||||
total={total}
|
||||
listLen={list.length}
|
||||
onPageChange={onPageChange}
|
||||
entity={'events'}
|
||||
/>
|
||||
{total === 0 ? (
|
||||
<div className={'flex items-center justify-center flex-col gap-4'}>
|
||||
<AnimatedSVG name={'no-results'} size={56} />
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<div className={'text-lg font-semibold'}>
|
||||
No results in the{' '}
|
||||
</div>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Past 24 Hours', value: 'DESC' },
|
||||
{ label: 'Weekly', value: 'ASC' },
|
||||
{ label: 'Other', value: 'Stuff' },
|
||||
]}
|
||||
defaultValue={'DESC'}
|
||||
plain
|
||||
onChange={({ value }) => {
|
||||
console.log(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button type={'text'}>Refresh</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DndTable
|
||||
loading={isPending}
|
||||
onRow={(record) => ({
|
||||
onClick: () => onItemClick(record),
|
||||
})}
|
||||
dataSource={list}
|
||||
pagination={false}
|
||||
columns={cols}
|
||||
onOrderChange={onOrderChange}
|
||||
/>
|
||||
<FullPagination
|
||||
page={page}
|
||||
limit={limit}
|
||||
total={total}
|
||||
listLen={list.length}
|
||||
onPageChange={onPageChange}
|
||||
entity={'events'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
112
frontend/app/components/DataManagement/DataItemPage.tsx
Normal file
112
frontend/app/components/DataManagement/DataItemPage.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React from 'react';
|
||||
import { Button, Input } from 'antd';
|
||||
import Breadcrumb from 'Shared/Breadcrumb';
|
||||
import { Triangle } from './Activity/EventDetailsModal';
|
||||
import cn from 'classnames';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
|
||||
function DataItemPage({
|
||||
sessionId,
|
||||
footer,
|
||||
item,
|
||||
backLink,
|
||||
}: {
|
||||
sessionId?: string;
|
||||
footer?: React.ReactNode;
|
||||
item: Record<string, any>;
|
||||
backLink: { name: string; to: string };
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={'flex flex-col gap-2 mx-auto w-full'}
|
||||
style={{ maxWidth: 1360 }}
|
||||
>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: backLink.name, to: backLink.to },
|
||||
{ label: item.name },
|
||||
]}
|
||||
/>
|
||||
<div className={'rounded-lg border bg-white flex flex-col'}>
|
||||
<div
|
||||
className={'p-4 border-b w-full flex items-center justify-between'}
|
||||
>
|
||||
<div
|
||||
className={'bg-gray-lighter rounded-xl px-2 font-semibold text-lg'}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
{sessionId ? (
|
||||
<div className={'link flex gap-1 items-center'}>
|
||||
<span>Play Sessions</span>
|
||||
<Triangle size={10} color={'blue'} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{item.fields.map((field) => (
|
||||
<EditableField
|
||||
onSave={() => null}
|
||||
fieldName={field.name}
|
||||
value={field.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableField({
|
||||
onSave,
|
||||
fieldName,
|
||||
value,
|
||||
}: {
|
||||
onSave: (value: string) => void;
|
||||
fieldName: string;
|
||||
value: string;
|
||||
}) {
|
||||
const [isEdit, setIsEdit] = React.useState(false);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex border-b last:border-b-0 items-center px-4 py-2 gap-2',
|
||||
isEdit ? 'bg-active-blue' : 'hover:bg-active-blue'
|
||||
)}
|
||||
>
|
||||
<div className={'font-semibold'} style={{ flex: 1 }}>
|
||||
{fieldName}
|
||||
</div>
|
||||
<div style={{ flex: 6 }}>
|
||||
{isEdit ? (
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<Input size={'small'} defaultValue={value} />
|
||||
<div className={'ml-auto'} />
|
||||
<Button
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
onClick={() => setIsEdit(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size={'small'} type={'primary'}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<span>{value}</span>
|
||||
<div
|
||||
className={'cursor-pointer text-main'}
|
||||
onClick={() => setIsEdit(true)}
|
||||
>
|
||||
<EditOutlined size={16} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataItemPage;
|
||||
|
|
@ -5,6 +5,7 @@ import Event from 'Components/DataManagement/Activity/data/Event';
|
|||
import { Triangle } from '../Activity/EventDetailsModal';
|
||||
import cn from 'classnames';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import DataItemPage from '../DataItemPage';
|
||||
|
||||
const testAutoEv = new Event({
|
||||
name: 'auto test ev',
|
||||
|
|
@ -30,104 +31,41 @@ function EventPage() {
|
|||
label: 'All Properties',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: 'Openreplay Properties',
|
||||
value: 'default',
|
||||
},
|
||||
{
|
||||
label: 'Custom Properties',
|
||||
value: 'custom',
|
||||
},
|
||||
{
|
||||
label: 'Default Properties',
|
||||
value: 'default',
|
||||
}
|
||||
]
|
||||
return (
|
||||
<div
|
||||
className={'flex flex-col gap-2 mx-auto w-full'}
|
||||
style={{ maxWidth: 1360 }}
|
||||
>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: 'Events', to: '/data-management/events' },
|
||||
{ label: testAutoEv.name },
|
||||
]}
|
||||
/>
|
||||
<div className={'rounded-lg border bg-white flex flex-col'}>
|
||||
<div
|
||||
className={
|
||||
'p-4 border-b w-full flex items-center justify-between'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={'bg-gray-lighter rounded-xl px-2 font-semibold text-lg'}
|
||||
>
|
||||
{testAutoEv.name}
|
||||
</div>
|
||||
<div className={'link flex gap-1 items-center'}>
|
||||
<span>Play Sessions</span>
|
||||
<Triangle size={10} color={'blue'} />
|
||||
</div>
|
||||
</div>
|
||||
<EditableField onSave={() => null} fieldName={'Display Name'} value={testAutoEv.displayName} />
|
||||
<EditableField onSave={() => null} fieldName={'Description'} value={testAutoEv.description} />
|
||||
<EditableField onSave={() => null} fieldName={'30 Day Volume'} value={testAutoEv.monthVolume} />
|
||||
</div>
|
||||
];
|
||||
|
||||
<div className={'rounded-lg border bg-white'}>
|
||||
<div className={'flex items-center gap-2 p-4'}>
|
||||
<div className={'font-semibold text-lg'}>Event Properties</div>
|
||||
<Segmented options={tabs} value={tab} onChange={(v) => setTab(v)} />
|
||||
const evWithFields = {
|
||||
...testAutoEv,
|
||||
fields: [
|
||||
{ name: 'User ID', value: testAutoEv.defaultFields.userId },
|
||||
{ name: 'User Location', value: testAutoEv.defaultFields.userLocation },
|
||||
{
|
||||
name: 'User Environment',
|
||||
value: testAutoEv.defaultFields.userEnvironment,
|
||||
},
|
||||
],
|
||||
};
|
||||
return (
|
||||
<DataItemPage
|
||||
item={evWithFields}
|
||||
backLink={{ name: 'Events', to: '/data/events' }}
|
||||
footer={
|
||||
<div className={'rounded-lg border bg-white'}>
|
||||
<div className={'flex items-center gap-2 p-4'}>
|
||||
<div className={'font-semibold text-lg'}>Event Properties</div>
|
||||
<Segmented options={tabs} value={tab} onChange={(v) => setTab(v)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableField({
|
||||
onSave,
|
||||
fieldName,
|
||||
value,
|
||||
}: {
|
||||
onSave: (value: string) => void
|
||||
fieldName: string
|
||||
value: string
|
||||
}) {
|
||||
const [isEdit, setIsEdit] = React.useState(false);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex border-b last:border-b-0 items-center px-4 py-2 gap-2',
|
||||
isEdit ? 'bg-active-blue' : 'hover:bg-active-blue'
|
||||
)}
|
||||
>
|
||||
<div className={'font-semibold'} style={{ flex: 1 }}>
|
||||
{fieldName}
|
||||
</div>
|
||||
<div style={{ flex: 6 }}>
|
||||
{isEdit ? (
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<Input size={'small'} defaultValue={value} />
|
||||
<div className={'ml-auto'} />
|
||||
<Button
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
onClick={() => setIsEdit(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size={'small'} type={'primary'}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<span>{value}</span>
|
||||
<div className={'cursor-pointer text-main'} onClick={() => setIsEdit(true)}>
|
||||
<EditOutlined size={16} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventPage
|
||||
export default EventPage;
|
||||
|
|
|
|||
100
frontend/app/components/shared/DNDTable.tsx
Normal file
100
frontend/app/components/shared/DNDTable.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { useDrag, useDrop, DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
|
||||
const type = 'COLUMN';
|
||||
|
||||
function DraggableHeaderCell({ index, moveColumn, children, ...rest }) {
|
||||
const ref = useRef<HTMLTableHeaderCellElement>(null);
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type,
|
||||
item: { index },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
const [, drop] = useDrop({
|
||||
accept: type,
|
||||
hover: (item, monitor) => {
|
||||
if (!ref.current) return;
|
||||
const dragIndex = item.index;
|
||||
if (dragIndex === index) return;
|
||||
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
||||
const hoverMiddleX =
|
||||
(hoverBoundingRect.right - hoverBoundingRect.left) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientX = clientOffset.x - hoverBoundingRect.left;
|
||||
if (dragIndex < index && hoverClientX < hoverMiddleX) return;
|
||||
if (dragIndex > index && hoverClientX > hoverMiddleX) return;
|
||||
moveColumn(dragIndex, index);
|
||||
item.index = index;
|
||||
},
|
||||
});
|
||||
drag(drop(ref));
|
||||
return (
|
||||
<th
|
||||
ref={ref}
|
||||
{...rest}
|
||||
style={{
|
||||
...rest.style,
|
||||
cursor: 'default',
|
||||
opacity: isDragging ? 0.3 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={'cursor-grab'}>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
const DNDTable = ({ columns: initCols, onOrderChange, ...tableProps }) => {
|
||||
const [cols, setCols] = useState(initCols);
|
||||
|
||||
const moveColumn = useCallback(
|
||||
(dragIndex, hoverIndex) => {
|
||||
const updated = [...cols];
|
||||
const [removed] = updated.splice(dragIndex, 1);
|
||||
updated.splice(hoverIndex, 0, removed);
|
||||
setCols(updated);
|
||||
onOrderChange?.(updated);
|
||||
},
|
||||
[cols, onOrderChange]
|
||||
);
|
||||
|
||||
const components = {
|
||||
header: {
|
||||
cell: (cellProps) => {
|
||||
const i = cols.findIndex((c) => c.key === cellProps['data-col-key']);
|
||||
const isOptionsCell = cellProps['data-col-key'] === '$__opts__$';
|
||||
return !isOptionsCell && i > -1 ? (
|
||||
<DraggableHeaderCell
|
||||
{...cellProps}
|
||||
index={i}
|
||||
moveColumn={moveColumn}
|
||||
/>
|
||||
) : (
|
||||
<th {...cellProps} />
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mergedCols = cols.map((col) => ({
|
||||
...col,
|
||||
onHeaderCell: () => ({ 'data-col-key': col.key }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Table {...tableProps} columns={mergedCols} components={components} />
|
||||
</DndProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DNDTable;
|
||||
|
|
@ -108,7 +108,8 @@ function SideMenu(props: Props) {
|
|||
item.key === MENU.ALERTS && modules.includes(MODULES.ALERTS),
|
||||
item.key === MENU.USABILITY_TESTS && modules.includes(MODULES.USABILITY_TESTS),
|
||||
item.isAdmin && !isAdmin,
|
||||
item.isEnterprise && !isEnterprise
|
||||
item.isEnterprise && !isEnterprise,
|
||||
(item.key === MENU.ACTIVITY || item.key === MENU.USERS_EVENTS) && isMobile
|
||||
].some((cond) => cond);
|
||||
|
||||
return { ...item, hidden: isHidden };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue