Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
649 changes: 649 additions & 0 deletions apps/www/src/app/examples/dataview/page.tsx

Large diffs are not rendered by default.

428 changes: 428 additions & 0 deletions docs/rfcs/002-unified-dataview-component.md

Large diffs are not rendered by default.

407 changes: 407 additions & 0 deletions packages/raystack/components/data-view-beta/components/content.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import { ReactNode } from 'react';
import { useDataView } from '../hooks/useDataView';

export interface DataViewDisplayAccessProps {
/** Field (column) accessor key. Gates rendering on the column's current visibility state. */
accessorKey: string;
children: ReactNode;
/** Rendered when the referenced field is currently hidden. Defaults to null. */
fallback?: ReactNode;
}

/**
* Gates children on the current column visibility state from DataView context.
* Use inside free-form renderers (Timeline bars, custom renderers, cell overrides)
* so the single DisplayControls toggle reaches the same visibility story that
* Table/List rows get through their column specs.
*/
export function DisplayAccess({
accessorKey,
children,
fallback = null
}: DataViewDisplayAccessProps) {
const { table } = useDataView();
const column = table?.getColumn(accessorKey);
// If the column doesn't exist, default to visible so consumers can wrap JSX
// in DisplayAccess without worrying about typos silently breaking the render.
const isVisible = column ? column.getIsVisible() : true;
return <>{isVisible ? children : fallback}</>;
}

DisplayAccess.displayName = 'DataView.DisplayAccess';
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client';

import { Chip } from '../../chip';
import { Flex } from '../../flex';
import { Text } from '../../text';
import { DataViewField } from '../data-view.types';
import { useDataView } from '../hooks/useDataView';

export function DisplayProperties<TData>({
fields
}: {
fields: DataViewField<TData>[];
}) {
const { table } = useDataView<TData>();
const hidableFields = fields?.filter(f => f.hideable) ?? [];

return (
<Flex direction='column' gap={3}>
<Text>Display Properties</Text>
<Flex gap={3} wrap='wrap'>
{hidableFields.map(field => {
const column = table.getColumn(field.accessorKey);
const isVisible = column ? column.getIsVisible() : true;
return (
<Chip
key={field.accessorKey}
variant='outline'
size='small'
color={isVisible ? 'accent' : 'neutral'}
onClick={() => column?.toggleVisibility()}
>
{field.label}
</Chip>
);
})}
</Flex>
</Flex>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use client';

import { MixerHorizontalIcon } from '@radix-ui/react-icons';

import { isValidElement, ReactNode } from 'react';
import { Button } from '../../button';
import { Flex } from '../../flex';
import { Popover } from '../../popover';
import styles from '../data-view.module.css';
import { defaultGroupOption, SortOrdersValues } from '../data-view.types';
import { useDataView } from '../hooks/useDataView';
import { DisplayProperties } from './display-properties';
import { Grouping } from './grouping';
import { Ordering } from './ordering';

interface DisplaySettingsProps {
trigger?: ReactNode;
}

export function DisplaySettings<TData>({
trigger = (
<Button
variant='outline'
color='neutral'
size='small'
leadingIcon={<MixerHorizontalIcon />}
>
Display
</Button>
)
}: DisplaySettingsProps) {
const {
fields,
updateTableQuery,
tableQuery,
defaultSort,
onDisplaySettingsReset
} = useDataView<TData>();

const sortableColumns = (fields ?? [])
.filter(f => f.sortable)
.map(f => ({
label: f.label,
id: f.accessorKey
}));

function onSortChange(columnId: string, order: SortOrdersValues) {
updateTableQuery(query => {
return {
...query,
sort: [{ name: columnId, order }]
};
});
}

function onGroupChange(columnId: string) {
updateTableQuery(query => {
return {
...query,
group_by: [columnId]
};
});
}

function onGroupRemove() {
updateTableQuery(query => {
return {
...query,
group_by: []
};
});
}

function onReset() {
onDisplaySettingsReset();
}

return (
<Popover>
<Popover.Trigger
render={isValidElement(trigger) ? trigger : <button>{trigger}</button>}
/>
<Popover.Content
className={styles['display-popover-content']}
align='end'
>
<Flex direction='column'>
<Flex
direction='column'
className={styles['display-popover-properties-container']}
gap={5}
>
<Ordering
columnList={sortableColumns}
onChange={onSortChange}
value={tableQuery?.sort?.[0] || defaultSort}
/>
<Grouping
fields={fields ?? []}
onRemove={onGroupRemove}
onChange={onGroupChange}
value={tableQuery?.group_by?.[0] || defaultGroupOption.id}
/>
Comment on lines +98 to +103
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Filter grouping options by groupable.

Grouping currently receives every field, so non-groupable fields can still be selected. Mirror the sortable filtering and pass only fields.filter(f => f.groupable).

Suggested fix
+  const groupableFields = (fields ?? []).filter(f => f.groupable);
+
   return (
@@
             <Grouping
-              fields={fields ?? []}
+              fields={groupableFields}
               onRemove={onGroupRemove}
               onChange={onGroupChange}
               value={tableQuery?.group_by?.[0] || defaultGroupOption.id}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Grouping
fields={fields ?? []}
onRemove={onGroupRemove}
onChange={onGroupChange}
value={tableQuery?.group_by?.[0] || defaultGroupOption.id}
/>
const groupableFields = (fields ?? []).filter(f => f.groupable);
<Grouping
fields={groupableFields}
onRemove={onGroupRemove}
onChange={onGroupChange}
value={tableQuery?.group_by?.[0] || defaultGroupOption.id}
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/raystack/components/data-view/components/display-settings.tsx`
around lines 98 - 103, Grouping is being passed all fields so non-groupable
options appear; update the props to pass only groupable fields by replacing the
fields prop on the Grouping component with fields?.filter(f => f.groupable) (use
the same null-safe pattern used elsewhere), keeping value
(tableQuery?.group_by?.[0] || defaultGroupOption.id) and handlers
onRemove/onChange unchanged so Grouping only receives groupable fields.

</Flex>
<Flex className={styles['display-popover-properties-container']}>
<DisplayProperties fields={fields ?? []} />
</Flex>
<Flex
justify='end'
className={styles['display-popover-reset-container']}
>
<Button variant='text' onClick={onReset} color='neutral'>
Reset to default
</Button>
</Flex>
</Flex>
</Popover.Content>
</Popover>
);
}

DisplaySettings.displayName = 'DataView.DisplayControls';
159 changes: 159 additions & 0 deletions packages/raystack/components/data-view-beta/components/filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'use client';

import { isValidElement, ReactNode, useMemo } from 'react';
import { FilterIcon } from '~/icons';
import { FilterOperatorTypes, FilterType } from '~/types/filters';
import { Button } from '../../button';
import { FilterChip } from '../../filter-chip';
import { Flex } from '../../flex';
import { IconButton } from '../../icon-button';
import { Menu } from '../../menu';
import { DataViewField } from '../data-view.types';
import { useDataView } from '../hooks/useDataView';
import { useFilters } from '../hooks/useFilters';

type Trigger<TData> =
| ReactNode
| (({
availableFilters,
appliedFilters
}: {
availableFilters: DataViewField<TData>[];
appliedFilters: Set<string>;
}) => ReactNode);

interface AddFilterProps<TData> {
fieldList: DataViewField<TData>[];
appliedFiltersSet: Set<string>;
onAddFilter: (field: DataViewField<TData>) => void;
children?: Trigger<TData>;
}

function AddFilter<TData>({
fieldList = [],
appliedFiltersSet,
onAddFilter,
children
}: AddFilterProps<TData>) {
const availableFilters = fieldList?.filter(
f => !appliedFiltersSet.has(f.accessorKey)
);

const trigger = useMemo(() => {
if (typeof children === 'function')
return children({ availableFilters, appliedFilters: appliedFiltersSet });
else if (children) return children;
else if (appliedFiltersSet.size > 0)
return (
<IconButton size={4}>
<FilterIcon />
</IconButton>
);
Comment on lines +46 to +51
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add an accessible label to the icon-only filter trigger.

After filters are applied, the default trigger becomes an icon-only button without an accessible name.

♿ Proposed fix
     else if (appliedFiltersSet.size > 0)
       return (
-        <IconButton size={4}>
+        <IconButton size={4} aria-label='Add filter'>
           <FilterIcon />
         </IconButton>
       );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
else if (appliedFiltersSet.size > 0)
return (
<IconButton size={4}>
<FilterIcon />
</IconButton>
);
else if (appliedFiltersSet.size > 0)
return (
<IconButton size={4} aria-label='Add filter'>
<FilterIcon />
</IconButton>
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/raystack/components/data-view/components/filters.tsx` around lines
46 - 51, The icon-only filter trigger rendered when appliedFiltersSet.size > 0
lacks an accessible name; update the IconButton (the one that wraps FilterIcon
in filters.tsx) to include an accessible label (e.g., add aria-label or title)
so screen readers can announce it, e.g., a descriptive string like "Filters
applied" or "Open filters"; ensure the change is applied to the IconButton usage
that appears in the conditional branch referencing appliedFiltersSet and
FilterIcon.

else
return (
<Button
variant='text'
size='small'
leadingIcon={<FilterIcon />}
color='neutral'
>
Filter
</Button>
);
}, [children, appliedFiltersSet, availableFilters]);

return availableFilters.length > 0 ? (
<Menu>
<Menu.Trigger
render={isValidElement(trigger) ? trigger : <button>{trigger}</button>}
/>
<Menu.Content>
{availableFilters?.map(field => (
<Menu.Item key={field.accessorKey} onClick={() => onAddFilter(field)}>
{field.label}
</Menu.Item>
))}
</Menu.Content>
</Menu>
) : null;
}

export function Filters<TData>({
classNames,
className,
trigger
}: {
classNames?: {
container?: string;
filterChips?: string;
addFilter?: string;
};
className?: string;
trigger?: Trigger<TData>;
}) {
const { fields, tableQuery } = useDataView<TData>();

const {
onAddFilter,
handleRemoveFilter,
handleFilterValueChange,
handleFilterOperationChange
} = useFilters<TData>();

const filterableFields = fields?.filter(f => f.filterable) ?? [];

const appliedFiltersSet = new Set(
tableQuery?.filters?.map(filter => filter.name)
);

const appliedFilters =
tableQuery?.filters?.map(filter => {
const field = fields?.find(f => f.accessorKey === filter.name);
return {
filterType: field?.filterType || FilterType.string,
label: field?.label || '',
options: field?.filterOptions || [],
selectProps: field?.filterProps?.select,
...filter
};
}) || [];

return (
<Flex gap={3} className={className}>
{appliedFilters.length > 0 && (
<Flex gap={3} className={classNames?.container}>
{appliedFilters.map(filter => (
<FilterChip
key={filter.name}
label={filter.label}
value={filter.value}
onRemove={() => handleRemoveFilter(filter.name)}
onValueChange={value =>
handleFilterValueChange(filter.name, value)
}
onOperationChange={operator =>
handleFilterOperationChange(
filter.name,
operator as FilterOperatorTypes
)
}
columnType={filter.filterType}
options={filter.options}
selectProps={filter.selectProps}
className={classNames?.filterChips}
/>
))}
</Flex>
)}
<AddFilter
fieldList={filterableFields}
appliedFiltersSet={appliedFiltersSet}
onAddFilter={onAddFilter}
>
{trigger}
</AddFilter>
</Flex>
);
}

Filters.displayName = 'DataView.Filters';
Loading
Loading