Skip to content
Draft
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
41 changes: 39 additions & 2 deletions browser/data-browser/src/chunks/TableEditor/Cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export interface CellProps {

interface IndexCellProps extends CellProps {
onExpand: (rowIndex: number) => void;
/** Optional selection control (a checkbox) shown to the left of the
* open-resource button. Rendered by the table when bulk selection is on. */
selector?: React.ReactNode;
}

export function Cell({
Expand Down Expand Up @@ -283,14 +286,21 @@ export function Cell({
export function IndexCell({
children,
onExpand,
selector,
...props
}: React.PropsWithChildren<IndexCellProps>): JSX.Element {
const { markings } = useTableEditorContext();

const marking = markings.get(props.rowIndex);

return (
<StyledIndexCell role='rowheader' {...props} hasMarking={!!marking}>
<StyledIndexCell
role='rowheader'
{...props}
hasMarking={!!marking}
hasSelector={!!selector}
>
{selector && <SelectorSlot>{selector}</SelectorSlot>}
<IconButton
title='Open resource'
onClick={() => onExpand(props.rowIndex)}
Expand All @@ -304,14 +314,41 @@ export function IndexCell({

const IndexNumber = styled.span``;

const StyledIndexCell = styled(Cell)<{ hasMarking: boolean }>`
/** Wraps the selection checkbox. Clicks here toggle the row, and must not
* bubble to the cell's mouse handlers (which would start a cell selection). */
const SelectorSlot = styled.span`
display: flex;
align-items: center;
`;

const StyledIndexCell = styled(Cell)<{
hasMarking: boolean;
hasSelector: boolean;
}>`
justify-content: flex-end !important;
gap: 0.35rem;
color: ${p => p.theme.colors.textLight};

& button {
display: none;
}

/* The checkbox is hidden at rest, but stays visible once its row is checked
* (via :has) or while the row is hovered/focused, so a selection is always
* legible without cluttering every idle row. */
& ${SelectorSlot} {
display: none;
}

&:hover
${SelectorSlot},
&:focus-within
${SelectorSlot},
&:has(input:checked)
${SelectorSlot} {
display: flex;
}

&:hover ${IndexNumber}, &:focus-within ${IndexNumber} {
display: none;
}
Expand Down
19 changes: 17 additions & 2 deletions browser/data-browser/src/chunks/TableEditor/TableEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ interface FancyTableProps<T> {
* about how many cells a row has.
*/
FooterComponent?: React.ComponentType<{ columns: T[] }>;
/** Renders the per-row selection control (a checkbox) inside the index
* column. When provided, the index column widens to fit it and the grid is
* treated as selectable. */
renderRowSelector?: (rowIndex: number) => React.ReactNode;
/** Select-all control shown in the index column header. */
headerSelector?: React.ReactNode;
ref?: React.RefObject<HTMLDivElement | null>;
}

Expand Down Expand Up @@ -126,6 +132,8 @@ function FancyTableInner<T>({
HeadingComponent,
NewColumnButtonComponent,
FooterComponent,
renderRowSelector,
headerSelector,
}: FancyTableProps<T>): JSX.Element {
const ariaUsageId = useId();
const scrollerRef = useRef<HTMLDivElement>(null);
Expand All @@ -148,6 +156,7 @@ function FancyTableInner<T>({
columnSizes,
columns,
onCellResize,
!!renderRowSelector,
);

const handleClickOutside = useCallback(() => {
Expand Down Expand Up @@ -251,15 +260,20 @@ function FancyTableInner<T>({
role='row'
aria-rowindex={index + 2}
>
<IndexCell rowIndex={index} columnIndex={0} onExpand={onRowExpand}>
<IndexCell
rowIndex={index}
columnIndex={0}
onExpand={onRowExpand}
selector={renderRowSelector?.(index)}
>
{index + 1}
</IndexCell>
{children({ index })}
<Cell rowIndex={Infinity} columnIndex={Infinity} disabled />
</TableRow>
);
},
[children, onRowExpand],
[children, onRowExpand, renderRowSelector],
);

const rowProps = useMemo(() => ({}), []);
Expand Down Expand Up @@ -336,6 +350,7 @@ function FancyTableInner<T>({
onColumnReorder={onColumnReorder}
HeadingComponent={HeadingComponent}
NewColumnButtonComponent={NewColumnButtonComponent}
headerSelector={headerSelector}
/>
<AutoSizeTamer role='rowgroup'>
<AutoSizer renderProp={renderList} />
Expand Down
6 changes: 5 additions & 1 deletion browser/data-browser/src/chunks/TableEditor/TableHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export interface TableHeaderProps<T> {
HeadingComponent: TableHeadingComponent<T>;
NewColumnButtonComponent: React.ComponentType;
headerRef: React.Ref<HTMLDivElement>;
/** Optional control (a select-all checkbox) shown in the index column
* header instead of the `#` label. */
headerSelector?: React.ReactNode;
}

/** The entire first row of an Editable Table. */
Expand All @@ -47,6 +50,7 @@ export function TableHeader<T>({
HeadingComponent,
NewColumnButtonComponent,
headerRef,
headerSelector,
}: TableHeaderProps<T>): JSX.Element {
const [activeIndex, setActiveIndex] = useState<number | undefined>();

Expand Down Expand Up @@ -96,7 +100,7 @@ export function TableHeader<T>({
<div role='rowgroup'>
<StyledTableRow ref={headerRef} aria-rowindex={1}>
<TableHeadingWrapper align='end' aria-colindex={1} role='columnheader'>
#
{headerSelector ?? '#'}
</TableHeadingWrapper>
{columns.map((column, index) => (
<TableHeading
Expand Down
14 changes: 12 additions & 2 deletions browser/data-browser/src/chunks/TableEditor/hooks/useCellSizes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from 'react';

const INDEX_CELL_WIDTH = '6ch';
/** Wider first track when the index column also holds a selection checkbox
* (checkbox + the open-resource button need to sit side by side). */
const SELECTABLE_INDEX_CELL_WIDTH = '4.5rem';

const parseSize = (size: string) => {
try {
Expand All @@ -21,6 +24,9 @@ export function useCellSizes<T>(
externalSizes: number[] | undefined,
columns: T[],
onSizesChange: (sizes: number[]) => void,
/** Widen the index column to fit a row-selection checkbox next to the
* open-resource button. */
selectable = false,
) {
// CSS values for column sizes
const [sizes, setSizes] = useState<string[]>(
Expand Down Expand Up @@ -87,10 +93,14 @@ export function useCellSizes<T>(
? toPixels(externalSizes)
: Array(columns.length).fill(DEFAULT_SIZE_STR);

const templateColumns = `${INDEX_CELL_WIDTH} ${effectiveSizes.join(
const indexCellWidth = selectable
? SELECTABLE_INDEX_CELL_WIDTH
: INDEX_CELL_WIDTH;

const templateColumns = `${indexCellWidth} ${effectiveSizes.join(
' ',
)} minmax(50px, 1fr)`;
const contentRowWidth = `calc(${INDEX_CELL_WIDTH} + ${effectiveSizes.join(
const contentRowWidth = `calc(${indexCellWidth} + ${effectiveSizes.join(
' + ',
)})`;

Expand Down
48 changes: 48 additions & 0 deletions browser/data-browser/src/chunks/TablePage/RowSelectCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
Collection,
unknownSubject,
useMemberFromCollection,
} from '@tomic/react';
import { type JSX } from 'react';
import { Checkbox } from '@components/forms/Checkbox';

interface RowSelectCheckboxProps {
collection: Collection;
index: number;
isSelected: (subject: string) => boolean;
onToggle: (subject: string) => void;
}

/**
* The per-row selection checkbox shown in the index column. Resolves its own
* subject from the collection (indices are all the grid hands down) so the
* checkbox reflects and toggles selection by stable subject.
*/
export function RowSelectCheckbox({
collection,
index,
isSelected,
onToggle,
}: RowSelectCheckboxProps): JSX.Element | null {
const resource = useMemberFromCollection(collection, index);
const subject = resource.subject;

// Until the row's subject resolves there's nothing to select.
if (subject === unknownSubject) {
return null;
}

return (
<Checkbox
title='Select row'
aria-label='Select row'
data-testid='row-select-checkbox'
checked={isSelected(subject)}
onChange={() => onToggle(subject)}
// Keep the click from reaching the cell, which would otherwise start a
// cell selection / active-cell move.
onMouseDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
/>
);
}
122 changes: 122 additions & 0 deletions browser/data-browser/src/chunks/TablePage/TableBulkActionsBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { JSONValue, Property } from '@tomic/react';
import { useState, type JSX } from 'react';
import { styled } from 'styled-components';
import { FaPen, FaTrash, FaXmark } from 'react-icons/fa6';
import { Button } from '@components/Button';
import {
ConfirmationDialog,
ConfirmationDialogTheme,
} from '@components/ConfirmationDialog';
import { TableSetPropertyDialog } from './TableSetPropertyDialog';

interface TableBulkActionsBarProps {
count: number;
/** Properties (columns) that can be bulk-set. */
properties: Property[];
onSetProperty: (
propertySubject: string,
value: JSONValue | undefined,
) => void;
onDelete: () => void;
onClear: () => void;
}

/**
* Appears above the grid whenever one or more rows are selected. Offers the
* bulk actions — set a property on every selected row, or delete them all —
* plus a way to clear the selection.
*/
export function TableBulkActionsBar({
count,
properties,
onSetProperty,
onDelete,
onClear,
}: TableBulkActionsBarProps): JSX.Element | null {
const [showSetProperty, setShowSetProperty] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);

if (count === 0) {
return null;
}

return (
<Bar role='toolbar' aria-label='Bulk actions' data-testid='table-bulk-actions'>
<Count data-testid='bulk-selected-count'>
{count} {count === 1 ? 'row' : 'rows'} selected
</Count>
<Button
subtle
onClick={() => setShowSetProperty(true)}
disabled={properties.length === 0}
data-testid='bulk-set-property-button'
title={
properties.length === 0
? 'No columns available to set'
: 'Set a property on all selected rows'
}
>
<FaPen /> Set property
</Button>
<Button
alert
onClick={() => setShowDeleteConfirm(true)}
data-testid='bulk-delete-button'
title='Delete all selected rows'
>
<FaTrash /> Delete
</Button>
<Spacer />
<Button
subtle
onClick={onClear}
data-testid='bulk-clear-button'
title='Clear selection'
>
<FaXmark /> Clear
</Button>

<TableSetPropertyDialog
properties={properties}
count={count}
show={showSetProperty}
bindShow={setShowSetProperty}
onApply={onSetProperty}
/>
<ConfirmationDialog
title={`Delete ${count} ${count === 1 ? 'row' : 'rows'}?`}
confirmLabel='Delete'
theme={ConfirmationDialogTheme.Alert}
show={showDeleteConfirm}
bindShow={setShowDeleteConfirm}
onConfirm={onDelete}
>
<p>
This permanently deletes the {count} selected{' '}
{count === 1 ? 'row' : 'rows'}. This cannot be undone from here.
</p>
</ConfirmationDialog>
</Bar>
);
}

const Bar = styled.div`
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
border-radius: ${p => p.theme.radius};
background-color: ${p => p.theme.colors.bg1};
border: 1px solid ${p => p.theme.colors.bg2};
`;

const Count = styled.span`
font-weight: bold;
margin-right: 0.5rem;
`;

const Spacer = styled.span`
flex: 1;
`;
Loading
Loading