mirror of
https://github.com/mudita/mudita-center.git
synced 2025-12-23 22:28:03 -05:00
405 lines
11 KiB
TypeScript
405 lines
11 KiB
TypeScript
/**
|
|
* Copyright (c) Mudita sp. z o.o. All rights reserved.
|
|
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
|
|
*/
|
|
|
|
import React, {
|
|
Children,
|
|
ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react"
|
|
import styled, { css } from "styled-components"
|
|
import { difference, intersection } from "lodash"
|
|
import { TableTestIds } from "e2e-test-ids"
|
|
import { APIFC, useViewFormContext } from "generic-view/utils"
|
|
import { TableConfig, TableData, tableHeaderCell } from "generic-view/models"
|
|
import { TableCell } from "./table-cell"
|
|
import { TableHeaderCell } from "./table-header-cell"
|
|
import {
|
|
listItemActiveStyles,
|
|
listItemBaseStyles,
|
|
listItemClickableStyles,
|
|
listItemSelectedStyles,
|
|
listRawItemStyles,
|
|
} from "../list/list-item"
|
|
import { toastAnimationDuration } from "../interactive/toast/toast"
|
|
import { FilePreview } from "../predefined/file-preview/file-preview"
|
|
|
|
const rowHeight = 64
|
|
|
|
export const Table: APIFC<TableData, TableConfig> & {
|
|
Cell: typeof TableCell
|
|
HeaderCell: typeof TableCell
|
|
} = ({
|
|
data = [],
|
|
config: { formOptions, previewOptions },
|
|
children,
|
|
...props
|
|
}) => {
|
|
const getFormContext = useViewFormContext()
|
|
const formContext = getFormContext(formOptions.formKey)
|
|
const scrollWrapperRef = useRef<HTMLDivElement>(null)
|
|
const [visibleRowsBounds, setVisibleRowsBounds] = useState<[number, number]>([
|
|
-1, -1,
|
|
])
|
|
|
|
const [previewOpened, setPreviewOpened] = useState(false)
|
|
const [initialPreviewId, setInitialPreviewId] = useState<string>()
|
|
const [activePreviewId, setActivePreviewId] = useState<string>()
|
|
const nextActivePreviewId = useRef<string | undefined>(undefined)
|
|
const { activeIdFieldName } = formOptions || {}
|
|
const isClickable = Boolean(activeIdFieldName)
|
|
const activeRowId = activeIdFieldName
|
|
? formContext.watch(activeIdFieldName)
|
|
: undefined
|
|
|
|
const previewMode = previewOptions?.enabled
|
|
? formContext.watch("previewMode")
|
|
: undefined
|
|
|
|
const onRowClick = useCallback(
|
|
(id: string) => {
|
|
if (previewOptions?.enabled) {
|
|
// handleActivePreviewIdChange(id)
|
|
setInitialPreviewId(id)
|
|
setPreviewOpened(true)
|
|
return
|
|
}
|
|
if (activeIdFieldName) {
|
|
formContext.setValue(activeIdFieldName!, id)
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[activeIdFieldName]
|
|
)
|
|
|
|
const handleScroll = useCallback(() => {
|
|
if (!scrollWrapperRef.current) return
|
|
const { scrollTop, clientHeight } = scrollWrapperRef.current
|
|
if (clientHeight === 0) {
|
|
setTimeout(handleScroll, 10)
|
|
return
|
|
}
|
|
const rowsPerPage = Math.ceil(clientHeight / rowHeight) || 0
|
|
const currentRowIndex = Math.floor(scrollTop / rowHeight)
|
|
const firstVisibleRowIndex = currentRowIndex - rowsPerPage
|
|
const lastVisibleRowIndex = currentRowIndex + rowsPerPage * 2
|
|
setVisibleRowsBounds([firstVisibleRowIndex, lastVisibleRowIndex])
|
|
}, [])
|
|
|
|
const scrollToActiveItem = useCallback(() => {
|
|
const activeElement = scrollWrapperRef.current?.querySelector("tr.active")
|
|
if (activeElement) {
|
|
activeElement.scrollIntoView({
|
|
block: "nearest",
|
|
})
|
|
}
|
|
}, [])
|
|
|
|
const scrollToPreviewActiveItem = useCallback(() => {
|
|
const activeElement = scrollWrapperRef.current?.querySelector(
|
|
`tr[data-item-id="${activePreviewId}"]`
|
|
)
|
|
if (activeElement) {
|
|
activeElement.scrollIntoView({
|
|
block: "nearest",
|
|
})
|
|
}
|
|
}, [activePreviewId])
|
|
|
|
const handleActivePreviewIdChange = useCallback((id?: string) => {
|
|
setActivePreviewId(id)
|
|
}, [])
|
|
|
|
const handlePreviewClose = useCallback(() => {
|
|
setPreviewOpened(false)
|
|
setInitialPreviewId(undefined)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (activePreviewId !== undefined) {
|
|
scrollToPreviewActiveItem()
|
|
formContext.setValue("previewMode", true)
|
|
} else if (previewMode) {
|
|
formContext.setValue("previewMode", false)
|
|
nextActivePreviewId.current = undefined
|
|
if (formOptions.selectedIdsFieldName) {
|
|
formContext.setValue(formOptions.selectedIdsFieldName, [])
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
activePreviewId,
|
|
formOptions.selectedIdsFieldName,
|
|
previewMode,
|
|
scrollToPreviewActiveItem,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (activeRowId) {
|
|
scrollToActiveItem()
|
|
}
|
|
}, [activeRowId, scrollToActiveItem])
|
|
|
|
useEffect(() => {
|
|
if (formOptions.allIdsFieldName) {
|
|
formContext.setValue(formOptions.allIdsFieldName, data)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [data, formOptions.allIdsFieldName])
|
|
|
|
useEffect(() => {
|
|
if (formOptions.selectedIdsFieldName) {
|
|
const selectedIds = formContext.getValues(
|
|
formOptions.selectedIdsFieldName
|
|
)
|
|
const unavailableIds = difference(selectedIds, data)
|
|
|
|
if (unavailableIds.length > 0) {
|
|
setTimeout(() => {
|
|
if (formOptions.selectedIdsFieldName !== undefined) {
|
|
formContext.setValue(
|
|
formOptions.selectedIdsFieldName,
|
|
intersection(data, unavailableIds)
|
|
)
|
|
}
|
|
}, toastAnimationDuration)
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [data, formOptions.selectedIdsFieldName])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
formOptions.activeIdFieldName &&
|
|
activeRowId &&
|
|
!data.includes(activeRowId)
|
|
) {
|
|
formContext.setValue(formOptions.activeIdFieldName, undefined)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeRowId, data, formOptions.activeIdFieldName])
|
|
|
|
useLayoutEffect(() => {
|
|
const scrollWrapper = scrollWrapperRef.current
|
|
if (!scrollWrapper) return
|
|
|
|
handleScroll()
|
|
scrollWrapper.addEventListener("scroll", handleScroll)
|
|
return () => {
|
|
scrollWrapper.removeEventListener("scroll", handleScroll)
|
|
}
|
|
}, [data.length, handleScroll])
|
|
|
|
const renderPlaceholder = useCallback(
|
|
(id: string) => {
|
|
const isActive = activeRowId === id
|
|
return (
|
|
<RowPlaceholder
|
|
key={id}
|
|
data-item-id={id}
|
|
data-testid={TableTestIds.TablePlaceholderRow}
|
|
className={isActive ? "active" : ""}
|
|
>
|
|
<td
|
|
data-testid={TableTestIds.TableCell}
|
|
colSpan={Children.count(children)}
|
|
>
|
|
<div />
|
|
</td>
|
|
</RowPlaceholder>
|
|
)
|
|
},
|
|
[activeRowId, children]
|
|
)
|
|
|
|
const renderChildren = useCallback(
|
|
(id: string) => {
|
|
const filteredChildren = React.Children.toArray(children).map((child) => {
|
|
if (
|
|
!React.isValidElement(child) ||
|
|
child.props.componentName === tableHeaderCell.key
|
|
) {
|
|
return null
|
|
}
|
|
return React.cloneElement(child as ReactElement, {
|
|
dataItemId: id,
|
|
})
|
|
})
|
|
|
|
return <>{filteredChildren}</>
|
|
},
|
|
[children]
|
|
)
|
|
|
|
const renderHeaderChildren = useCallback(() => {
|
|
const filteredChildren = React.Children.toArray(children).filter(
|
|
(child) => {
|
|
if (!React.isValidElement(child)) return false
|
|
return child.props.componentName === tableHeaderCell.key
|
|
}
|
|
)
|
|
|
|
return <>{filteredChildren}</>
|
|
}, [children])
|
|
|
|
const renderRow = useCallback(
|
|
(id: string, index: number) => {
|
|
if (index < visibleRowsBounds[0] || index > visibleRowsBounds[1]) {
|
|
return renderPlaceholder(id)
|
|
}
|
|
const onClick = () => onRowClick(id)
|
|
const isActive = activeRowId === id
|
|
|
|
return (
|
|
<tr
|
|
key={id}
|
|
data-item-id={id}
|
|
data-testid={TableTestIds.TableRow}
|
|
onClick={onClick}
|
|
className={isActive ? "active" : ""}
|
|
>
|
|
{renderChildren(id)}
|
|
</tr>
|
|
)
|
|
},
|
|
[
|
|
activeRowId,
|
|
onRowClick,
|
|
renderChildren,
|
|
renderPlaceholder,
|
|
visibleRowsBounds,
|
|
]
|
|
)
|
|
|
|
const preview = useMemo(() => {
|
|
if (!previewOptions || !previewOptions.enabled) return null
|
|
|
|
return (
|
|
<FilePreview
|
|
items={data}
|
|
initialItem={initialPreviewId}
|
|
opened={previewOpened}
|
|
onActiveItemChange={handleActivePreviewIdChange}
|
|
onClose={handlePreviewClose}
|
|
componentKey={previewOptions.componentKey}
|
|
entitiesConfig={{
|
|
type: previewOptions.entitiesType,
|
|
idField: previewOptions.entityIdFieldName,
|
|
pathField: previewOptions.entityPathFieldName,
|
|
titleField: previewOptions.entityTitleFieldName,
|
|
mimeTypeField: previewOptions.entityMimeTypeFieldName,
|
|
sizeField: previewOptions.entitySizeFieldName,
|
|
}}
|
|
/>
|
|
)
|
|
}, [
|
|
data,
|
|
handleActivePreviewIdChange,
|
|
handlePreviewClose,
|
|
initialPreviewId,
|
|
previewOpened,
|
|
previewOptions,
|
|
])
|
|
|
|
return useMemo(
|
|
() => (
|
|
<>
|
|
<ScrollableWrapper ref={scrollWrapperRef} {...props}>
|
|
<TableWrapper data-testid={TableTestIds.Table}>
|
|
<TableHeader
|
|
$hasClickableRows={isClickable || previewOptions?.enabled}
|
|
>
|
|
<tr data-testid={TableTestIds.TableHeaderRow}>
|
|
{renderHeaderChildren()}
|
|
</tr>
|
|
</TableHeader>
|
|
<TableBody $clickable={isClickable || previewOptions?.enabled}>
|
|
{data?.map((id, index) => renderRow(id, index))}
|
|
</TableBody>
|
|
</TableWrapper>
|
|
</ScrollableWrapper>
|
|
{preview}
|
|
</>
|
|
),
|
|
[
|
|
props,
|
|
isClickable,
|
|
previewOptions?.enabled,
|
|
renderHeaderChildren,
|
|
data,
|
|
preview,
|
|
renderRow,
|
|
]
|
|
)
|
|
}
|
|
|
|
Table.Cell = TableCell
|
|
Table.HeaderCell = TableHeaderCell
|
|
|
|
const ScrollableWrapper = styled.div`
|
|
height: 100%;
|
|
overflow: auto;
|
|
position: relative;
|
|
scroll-behavior: smooth;
|
|
`
|
|
|
|
const TableWrapper = styled.table`
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
max-height: 100%;
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
`
|
|
|
|
const TableHeader = styled.thead<{ $hasClickableRows?: boolean }>`
|
|
position: sticky;
|
|
z-index: 2;
|
|
top: 0;
|
|
background: #fff;
|
|
|
|
tr {
|
|
${({ $hasClickableRows }) =>
|
|
$hasClickableRows &&
|
|
css`
|
|
&:before {
|
|
content: "";
|
|
}
|
|
`};
|
|
}
|
|
|
|
th {
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
border-bottom: solid 0.1rem ${({ theme }) => theme.color.grey4};
|
|
}
|
|
`
|
|
|
|
const RowPlaceholder = styled.tr`
|
|
${listRawItemStyles};
|
|
height: ${rowHeight / 10}rem;
|
|
`
|
|
|
|
const TableBody = styled.tbody<{ $clickable?: boolean }>`
|
|
tr:not(${RowPlaceholder}) {
|
|
${listItemBaseStyles};
|
|
${listItemSelectedStyles};
|
|
height: ${rowHeight / 10}rem;
|
|
|
|
&.active {
|
|
${listItemActiveStyles};
|
|
}
|
|
${({ $clickable }) => $clickable && listItemClickableStyles}
|
|
}
|
|
td {
|
|
text-align: left;
|
|
}
|
|
`
|