Compare commits

...

4 Commits

Author SHA1 Message Date
bef3e47d29 Update RSS Feed label in ExportListButton for clarity
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
2026-06-20 23:10:34 +01:00
6cc3fdb0ce Added minimal object child table with modal popup. 2026-06-20 23:10:10 +01:00
27f5989eb8 Added splitter to filter sidebar. 2026-06-20 22:56:07 +01:00
476a01eafb Fixed warnings and bugs. 2026-06-20 22:36:48 +01:00
6 changed files with 140 additions and 62 deletions

View File

@ -82,7 +82,7 @@ const ExportListButton = ({
}, },
{ {
key: 'rss', key: 'rss',
label: 'RSS Feed Connection', label: 'RSS Feed',
icon: <RssIcon />, icon: <RssIcon />,
onClick: () => setRssModalOpen(true) onClick: () => setRssModalOpen(true)
} }

View File

@ -136,7 +136,7 @@ const FilterSidebar = ({
} }
return ( return (
<Card style={{ width: '25%', flexShrink: 0, height: '100%' }}> <Card style={{ flexShrink: 0, height: '100%' }}>
<Flex vertical gap='middle'> <Flex vertical gap='middle'>
<Flex justify='space-between'> <Flex justify='space-between'>
<Dropdown menu={menuItems} trigger={['hover']} placement='bottomLeft'> <Dropdown menu={menuItems} trigger={['hover']} placement='bottomLeft'>
@ -158,14 +158,14 @@ const FilterSidebar = ({
value={row.field || undefined} value={row.field || undefined}
onChange={(v) => changeField(row.field, v)} onChange={(v) => changeField(row.field, v)}
options={availableOptions(row.field)} options={availableOptions(row.field)}
style={{ minWidth: 80, flex: 1 }} style={{ minWidth: 80 }}
allowClear={false} allowClear={false}
/> />
<Input <Input
placeholder='Value' placeholder='Value'
value={row.value} value={row.value}
onChange={(e) => changeValue(row.field, e.target.value)} onChange={(e) => changeValue(row.field, e.target.value)}
style={{ width: 160 }} style={{ flex: 1 }}
/> />
<Button <Button
icon={<CloseOutlined />} icon={<CloseOutlined />}

View File

@ -1,11 +1,11 @@
import { useMemo, useEffect, useRef } from 'react' import { useMemo, useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Table, Skeleton, Card, Button, Flex, Typography } from 'antd' import { Table, Skeleton, Card, Button, Flex, Typography, Modal } from 'antd'
import PlusIcon from '../../Icons/PlusIcon' import PlusIcon from '../../Icons/PlusIcon'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import BinIcon from '../../Icons/BinIcon' import BinIcon from '../../Icons/BinIcon'
const { Text } = Typography const { Text, Link, Title } = Typography
const DEFAULT_COLUMN_WIDTHS = { const DEFAULT_COLUMN_WIDTHS = {
text: 200, text: 200,
@ -64,10 +64,44 @@ const ObjectChildTable = ({
value = [], value = [],
rollups = [], rollups = [],
onChange, onChange,
minimal = false,
label = '',
...tableProps ...tableProps
}) => { }) => {
const mainTableWrapperRef = useRef(null) const mainTableWrapperRef = useRef(null)
const rollupTableWrapperRef = useRef(null) const rollupTableWrapperRef = useRef(null)
const generatedRowKeysRef = useRef(new WeakMap())
const generatedRowKeyCountRef = useRef(0)
const [minimalModelOpen, setMinimalModelOpen] = useState(false)
const getFallbackRowKey = (record) => {
if (!record || typeof record !== 'object') {
return `object-child-table-row-${String(record)}`
}
if (record._objectChildTableKey != null) {
return record._objectChildTableKey
}
const existing = generatedRowKeysRef.current.get(record)
if (existing) return existing
const generated = `object-child-table-row-${generatedRowKeyCountRef.current}`
generatedRowKeyCountRef.current += 1
generatedRowKeysRef.current.set(record, generated)
return generated
}
const getResolvedRecordKey = (record) => {
if (typeof rowKey === 'function') {
return rowKey(record) ?? getFallbackRowKey(record)
}
if (typeof rowKey === 'string' && rowKey.length > 0) {
return record?.[rowKey] ?? getFallbackRowKey(record)
}
return getFallbackRowKey(record)
}
const propertyMap = useMemo(() => { const propertyMap = useMemo(() => {
const map = new Map() const map = new Map()
@ -130,10 +164,14 @@ const ObjectChildTable = ({
const currentItems = Array.isArray(itemsSource) const currentItems = Array.isArray(itemsSource)
? [...itemsSource] ? [...itemsSource]
: [] : []
const existingRowKey = getResolvedRecordKey(record)
const updatedItem = { const updatedItem = {
...currentItems[index], ...currentItems[index],
[property.name]: resolved [property.name]: resolved
} }
// Preserve fallback row identity across immutable updates so the row
// is not remounted while typing (which causes input focus loss).
generatedRowKeysRef.current.set(updatedItem, existingRowKey)
currentItems[index] = updatedItem currentItems[index] = updatedItem
if (typeof onChange === 'function') { if (typeof onChange === 'function') {
onChange(currentItems) onChange(currentItems)
@ -186,10 +224,11 @@ const ObjectChildTable = ({
(item) => item[rowKey] !== record[rowKey] (item) => item[rowKey] !== record[rowKey]
) )
} else if (typeof rowKey === 'function') { } else if (typeof rowKey === 'function') {
// If rowKey is a function, find the item by comparing the resolved keys // If rowKey is a function, find the item by comparing resolved keys.
const recordKey = rowKey(record, index) // Ant Design deprecates index-based rowKey callbacks.
newItems = currentItems.filter((item, i) => { const recordKey = getResolvedRecordKey(record)
const itemKey = rowKey(item, i) newItems = currentItems.filter((item) => {
const itemKey = getResolvedRecordKey(item)
return itemKey !== recordKey return itemKey !== recordKey
}) })
} else { } else {
@ -216,6 +255,7 @@ const ObjectChildTable = ({
resolvedProperties, resolvedProperties,
additionalColumns, additionalColumns,
isEditing, isEditing,
canAddRemove,
itemsSource, itemsSource,
onChange, onChange,
rowKey rowKey
@ -236,8 +276,7 @@ const ObjectChildTable = ({
return itemsSource return itemsSource
}, [itemsSource, loading, skeletonData]) }, [itemsSource, loading, skeletonData])
const resolvedRowKey = const resolvedRowKey = (record) => getResolvedRecordKey(record)
typeof rowKey === 'function' ? rowKey : (_record, index) => index
const scrollConfig = const scrollConfig =
scrollHeight != null scrollHeight != null
@ -281,7 +320,9 @@ const ObjectChildTable = ({
// Single summary row where each rollup value is placed under // Single summary row where each rollup value is placed under
// the column that matches its `property` field. // the column that matches its `property` field.
const summaryRow = {} const summaryRow = {
_objectChildTableKey: 'object-child-table-rollup-summary'
}
properties.forEach((property) => { properties.forEach((property) => {
const rollup = rollups.find( const rollup = rollups.find(
@ -354,7 +395,7 @@ const ObjectChildTable = ({
...propertyColumns, ...propertyColumns,
...(blankDeleteColumn ? [blankDeleteColumn] : []) ...(blankDeleteColumn ? [blankDeleteColumn] : [])
] ]
}, [properties, rollups, isEditing]) }, [properties, rollups, isEditing, canAddRemove])
const hasRollups = useMemo( const hasRollups = useMemo(
() => Array.isArray(rollups) && rollups.length > 0, () => Array.isArray(rollups) && rollups.length > 0,
@ -414,13 +455,14 @@ const ObjectChildTable = ({
dataSource={rollupDataSource} dataSource={rollupDataSource}
showHeader={false} showHeader={false}
columns={rollupColumns} columns={rollupColumns}
loading={{ spinning: loading, indicator: <></> }} loading={{ spinning: loading, indicator: null }}
pagination={false} pagination={false}
size={size} size={size}
rowKey={resolvedRowKey} rowKey={resolvedRowKey}
scroll={scrollConfig} scroll={scrollConfig}
locale={{ emptyText }} locale={{ emptyText }}
style={{ maxWidth, minWidth: 0 }} bordered={true}
style={{ maxWidth: minimal ? '100%' : maxWidth, minWidth: 0 }}
className='rollup-table' className='rollup-table'
/> />
</div> </div>
@ -430,13 +472,14 @@ const ObjectChildTable = ({
<Flex vertical> <Flex vertical>
<div ref={mainTableWrapperRef}> <div ref={mainTableWrapperRef}>
<Table <Table
style={{ maxWidth, minWidth: 0 }} style={{ maxWidth: minimal ? '100%' : maxWidth, minWidth: 0 }}
dataSource={dataSource} dataSource={dataSource}
columns={tableColumns} columns={tableColumns}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }} loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }}
size={size} size={size}
rowKey={resolvedRowKey} rowKey={resolvedRowKey}
scroll={scrollConfig} scroll={scrollConfig}
bordered={true}
locale={{ emptyText }} locale={{ emptyText }}
pagination={false} pagination={false}
className={hasRollups ? 'child-table-rollups' : 'child-table'} className={hasRollups ? 'child-table-rollups' : 'child-table'}
@ -469,6 +512,34 @@ const ObjectChildTable = ({
) )
} }
if (minimal == true) {
return (
<>
<Link
onClick={() => {
setMinimalModelOpen(true)
}}
>
{value?.length || 0} {value?.length == 1 ? 'item' : 'items'}
</Link>
<Modal
open={minimalModelOpen}
onCancel={() => setMinimalModelOpen(false)}
footer={null}
width='860px'
>
<Title
level={2}
style={{ marginTop: 0, lineHeight: '0.7', marginBottom: 20 }}
>
{label}
</Title>
{tableComponent}
</Modal>
</>
)
}
return tableComponent return tableComponent
} }
@ -490,13 +561,15 @@ ObjectChildTable.propTypes = {
skeletonRows: PropTypes.number, skeletonRows: PropTypes.number,
additionalColumns: PropTypes.arrayOf(PropTypes.object), additionalColumns: PropTypes.arrayOf(PropTypes.object),
emptyText: PropTypes.node, emptyText: PropTypes.node,
label: PropTypes.string,
isEditing: PropTypes.bool, isEditing: PropTypes.bool,
value: PropTypes.arrayOf(PropTypes.object), value: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func, onChange: PropTypes.func,
maxWidth: PropTypes.string, maxWidth: PropTypes.string,
rollups: PropTypes.arrayOf(PropTypes.object), rollups: PropTypes.arrayOf(PropTypes.object),
objectData: PropTypes.object, objectData: PropTypes.object,
canAddRemove: PropTypes.bool canAddRemove: PropTypes.bool,
minimal: PropTypes.bool
} }
export default ObjectChildTable export default ObjectChildTable

View File

@ -422,6 +422,8 @@ const ObjectProperty = ({
loading={loading} loading={loading}
rollups={rollups} rollups={rollups}
size={size} size={size}
minimal={minimal}
label={label}
canAddRemove={canAddRemove} canAddRemove={canAddRemove}
/> />
) )

View File

@ -19,7 +19,8 @@ import {
Input, Input,
Space, Space,
Tooltip, Tooltip,
Form Form,
Splitter
} from 'antd' } from 'antd'
import { LoadingOutlined } from '@ant-design/icons' import { LoadingOutlined } from '@ant-design/icons'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
@ -940,50 +941,49 @@ const ObjectTable = forwardRef(
const tableContent = ( const tableContent = (
<Flex gap={'middle'} vertical style={{ flex: 1, minWidth: 0 }}> <Flex gap={'middle'} vertical style={{ flex: 1, minWidth: 0 }}>
{cards ? ( <Splitter className={'farmcontrol-splitter'}>
<Spin indicator={<LoadingOutlined />} spinning={loading}> <Splitter.Panel>
{renderCards()} {cards ? (
</Spin> <Spin indicator={<LoadingOutlined />} spinning={loading}>
) : ( {renderCards()}
<Table </Spin>
ref={tableRef} ) : (
dataSource={tableData} <Table
columns={columnsWithSkeleton} ref={tableRef}
className='dashboard-table' dataSource={tableData}
pagination={false} columns={columnsWithSkeleton}
scroll={{ y: adjustedScrollHeight }} className='dashboard-table'
rowKey='_id' pagination={false}
loading={{ spinning: loading, indicator: <LoadingOutlined spin /> }} scroll={{ y: adjustedScrollHeight }}
onScroll={handleScroll} rowKey='_id'
onChange={handleTableChange} loading={{
showSorterTooltip={false} spinning: loading,
style={{ height: '100%' }} indicator: <LoadingOutlined spin />
size={size} }}
components={components} onScroll={handleScroll}
onRow={onRow} onChange={handleTableChange}
/> showSorterTooltip={false}
)} style={{ height: '100%' }}
size={size}
components={components}
onRow={onRow}
/>
)}
</Splitter.Panel>
{showFilterSidebar && (
<Splitter.Panel>
<FilterSidebar
type={type}
filter={sidebarFilter}
onFilterChange={handleSidebarFilterChange}
masterFilter={masterFilter}
/>
</Splitter.Panel>
)}
</Splitter>
</Flex> </Flex>
) )
if (showFilterSidebar) {
return (
<Flex
gap='middle'
align='flex-start'
style={{ width: '100%', height: '100%' }}
>
{tableContent}
<FilterSidebar
type={type}
filter={sidebarFilter}
onFilterChange={handleSidebarFilterChange}
masterFilter={masterFilter}
/>
</Flex>
)
}
return tableContent return tableContent
} }
) )

View File

@ -291,6 +291,9 @@ const AuthProvider = ({ children }) => {
}) })
}, [authenticated, token, expiresAt, userProfile, persistSession]) }, [authenticated, token, expiresAt, userProfile, persistSession])
const profileImageDependency =
userProfile?.profileImage?._id ?? userProfile?.profileImage
// Fetch and cache profile image when userProfile.profileImage changes // Fetch and cache profile image when userProfile.profileImage changes
useEffect(() => { useEffect(() => {
const profileImage = userProfile?.profileImage const profileImage = userProfile?.profileImage
@ -365,7 +368,7 @@ const AuthProvider = ({ children }) => {
} }
setProfileImageUrl(null) setProfileImageUrl(null)
} }
}, [userProfile?.profileImage?._id ?? userProfile?.profileImage, token]) }, [profileImageDependency, token])
useEffect(() => { useEffect(() => {
console.log('userProfile', userProfile) console.log('userProfile', userProfile)