-
Notifications
You must be signed in to change notification settings - Fork 2
Admin bulk action pattern #157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
c1699f1
394d4d5
c06c77c
7c6b27f
634246f
a277b1c
482a2cb
c2e8d39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| @use "../../base"; | ||
|
|
||
| .bookingBaseTable { | ||
| @include base.table; | ||
| cursor: pointer; | ||
|
|
||
| div { | ||
| box-shadow: none; | ||
| } | ||
|
|
||
| .extraContentContainer { | ||
| width: 100%; | ||
| } | ||
|
|
||
| .detailsContainer { | ||
| :global(.row) { | ||
| padding: 0.5rem 0; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| import { useCallback, useState, useRef, useEffect } from "react"; | ||
| import { AutoResizer, Column, ColumnShape, RowKey } from "react-base-table"; | ||
| import { Segment, Checkbox } from "semantic-ui-react"; | ||
|
|
||
| import { | ||
| ACTION, | ||
| CREATED_AT_STRING, | ||
| EVENT_TIME_RANGE, | ||
| ID, | ||
| START_DATE_TIME_STRING, | ||
| START_TIME_MINS, | ||
| STATUS, | ||
| } from "../../constants"; | ||
| import { useGetSingleBooking } from "../../custom-hooks/api/bookings-api"; | ||
| import { useAppDispatch } from "../../redux/hooks"; | ||
| import { updateBookingsAction } from "../../redux/slices/bookings-slice"; | ||
| import { BookingData } from "../../types/bookings"; | ||
| import BookingDetailsView from "../booking-details-view"; | ||
| import BookingStatusButton from "../booking-status-button"; | ||
| import Table, { TableProps } from "../table"; | ||
| import styles from "./admin-booking-base-table.module.scss"; | ||
|
|
||
| export type BookingViewProps = BookingData & { | ||
| [START_TIME_MINS]: number; | ||
| [START_DATE_TIME_STRING]: string; | ||
| [EVENT_TIME_RANGE]: string; | ||
| [CREATED_AT_STRING]: string; | ||
| booking?: BookingData; | ||
| children: { [ID]: string; booking: BookingData }[]; | ||
| }; | ||
|
|
||
| type Props = Partial<TableProps<BookingViewProps>> & { | ||
| adminView?: boolean; | ||
| defaultStatusColumnWidth?: number; | ||
| defaultActionColumnWidth?: number; | ||
| selectedBookingIds?: Set<number>; | ||
| onSelectionChange?: (selectedIds: Set<number>) => void; | ||
| }; | ||
|
|
||
| const RowRenderer: TableProps<BookingViewProps>["rowRenderer"] = ({ | ||
| rowData: { booking }, | ||
| cells, | ||
| columns, | ||
| }: { | ||
| rowData: BookingViewProps; | ||
| cells: React.ReactNode[]; | ||
| columns: ColumnShape<BookingViewProps>; | ||
| }) => | ||
| // Only render details if there are booking details | ||
| // and the column is not the frozen column | ||
| booking && columns.length > 1 ? ( | ||
| <Segment className={styles.extraContentContainer} basic> | ||
| <BookingDetailsView | ||
| className={styles.detailsContainer} | ||
| booking={booking} | ||
| /> | ||
| </Segment> | ||
| ) : ( | ||
| cells | ||
| ); | ||
|
|
||
| function BookingBaseTable({ | ||
| adminView = false, | ||
| defaultStatusColumnWidth = 100, | ||
| defaultActionColumnWidth = 100, | ||
| selectedBookingIds = new Set(), | ||
| onSelectionChange, | ||
|
|
||
| children, | ||
| ...props | ||
| }: Props) { | ||
| const { getSingleBooking } = useGetSingleBooking(); | ||
| const dispatch = useAppDispatch(); | ||
|
|
||
| const StatusButtonRenderer: ColumnShape<BookingViewProps>["cellRenderer"] = | ||
| useCallback( | ||
| ({ rowData: { status, id } }: { rowData: BookingViewProps }) => | ||
| status && | ||
| id !== undefined && ( | ||
| <BookingStatusButton | ||
| bookingId={id} | ||
| status={status} | ||
| adminView={adminView} | ||
| /> | ||
| ), | ||
| [adminView], | ||
| ); | ||
|
|
||
| const selectedIdsRef = useRef(selectedBookingIds); | ||
|
|
||
| useEffect(() => { | ||
| selectedIdsRef.current = selectedBookingIds; | ||
| }, [selectedBookingIds]); | ||
|
|
||
| const CheckboxRenderer: ColumnShape<BookingViewProps>["cellRenderer"] = | ||
| useCallback( | ||
| ({ rowData: { id } }: { rowData: BookingViewProps }) => { | ||
| if (id === undefined) return null; | ||
| const isChecked = selectedBookingIds.has(id); | ||
| return ( | ||
| <div onClick={(e) => e.stopPropagation()}> | ||
| <Checkbox | ||
| checked={isChecked} | ||
| onChange={() => { | ||
| const currentSet = selectedIdsRef.current; | ||
| const newSelected = new Set(currentSet); | ||
| if (currentSet.has(id)) { | ||
| newSelected.delete(id); | ||
| } else { | ||
| newSelected.add(id); | ||
| } | ||
| if (onSelectionChange) { | ||
| onSelectionChange(newSelected); | ||
| } | ||
| }} | ||
| /> | ||
| </div> | ||
| ); | ||
| }, | ||
| [selectedBookingIds, onSelectionChange], | ||
| ); | ||
|
Comment on lines
+90
to
+122
|
||
|
|
||
| const [expandedRowKeys, setExpandedRowKeys] = useState<RowKey[]>([]); | ||
| const onRowExpand: TableProps<BookingViewProps>["onRowExpand"] = ({ | ||
| expanded, | ||
| rowData: { id, formResponseData }, | ||
| }) => { | ||
| if (!expanded || formResponseData) { | ||
| return; | ||
| } | ||
| getSingleBooking(id) | ||
| .then((booking) => { | ||
| if (booking) { | ||
| dispatch(updateBookingsAction({ bookings: [booking] })); | ||
| } else { | ||
| console.error("Failed to update booking details for booking ID", id); | ||
| } | ||
| }) | ||
| .catch((error) => console.error(error)); | ||
| }; | ||
|
|
||
| return ( | ||
| <Segment className={styles.bookingBaseTable}> | ||
| <AutoResizer> | ||
| {({ width, height }) => ( | ||
| <Table<BookingViewProps> | ||
| width={width} | ||
| height={height} | ||
| ignoreFunctionInColumnCompare={false} | ||
| rowRenderer={RowRenderer} | ||
| estimatedRowHeight={50} | ||
| fixed | ||
| expandColumnKey={ACTION} | ||
| onRowExpand={onRowExpand} | ||
| expandedRowKeys={expandedRowKeys} | ||
| rowEventHandlers={{ | ||
| onClick: ({ rowData, rowKey, rowIndex }) => { | ||
| if (!rowData.children || rowData.children.length === 0) return; | ||
| if (expandedRowKeys.includes(rowKey)) | ||
| setExpandedRowKeys( | ||
| expandedRowKeys.filter((x) => x !== rowKey), | ||
| ); | ||
| else { | ||
| setExpandedRowKeys([...expandedRowKeys, rowKey]); | ||
| onRowExpand({ expanded: true, rowData, rowIndex, rowKey }); | ||
| } | ||
| }, | ||
| }} | ||
| {...props} | ||
| > | ||
| <Column<BookingViewProps> | ||
| key="checkbox" // hmm change to a constant? | ||
|
||
| title="" | ||
| width={50} | ||
| align="center" | ||
| cellRenderer={CheckboxRenderer} | ||
| resizable={false} | ||
| frozen="left" | ||
| /> | ||
|
Comment on lines
+172
to
+180
|
||
| {children} | ||
| <Column<BookingViewProps> | ||
| key={STATUS} | ||
| title="Status" | ||
| width={defaultStatusColumnWidth} | ||
| sortable | ||
| align="center" | ||
| cellRenderer={StatusButtonRenderer} | ||
| resizable | ||
| /> | ||
| <Column<BookingViewProps> | ||
| key={ACTION} | ||
| title="" | ||
| width={defaultActionColumnWidth} | ||
| align="center" | ||
| frozen="right" | ||
| /> | ||
| </Table> | ||
| )} | ||
| </AutoResizer> | ||
| </Segment> | ||
| ); | ||
| } | ||
|
|
||
| export default BookingBaseTable; | ||
|
Comment on lines
+1
to
+205
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export type { BookingViewProps } from "./admin-booking-base-table"; | ||
| export { default } from "./admin-booking-base-table"; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,99 @@ | ||||||
| .footerContainer { | ||||||
| /* Positioning */ | ||||||
| position: fixed !important; | ||||||
| bottom: 40px !important; | ||||||
| left: 50% !important; | ||||||
| transform: translateX(-50%) !important; | ||||||
|
|
||||||
| /* Layout constraints */ | ||||||
| top: auto !important; | ||||||
| height: auto !important; | ||||||
| width: auto !important; | ||||||
| min-width: 0 !important; | ||||||
|
|
||||||
| /* Visuals */ | ||||||
| background-color: #ffffff; | ||||||
| border: 1px solid #dfe1e6; | ||||||
| box-shadow: 0 4px 12px rgba(9, 30, 66, 0.15), 0 0 1px rgba(9, 30, 66, 0.31); | ||||||
| border-radius: 6px; | ||||||
|
|
||||||
| /* Flexbox settings */ | ||||||
| display: flex; | ||||||
| align-items: center; | ||||||
| justify-content: center; | ||||||
|
|
||||||
| gap: 20px; | ||||||
| padding: 14px 24px; | ||||||
|
|
||||||
| z-index: 9999; | ||||||
| animation: slideUp 0.3s ease-out; | ||||||
| } | ||||||
|
|
||||||
| .selectionGroup { | ||||||
| display: flex; | ||||||
| align-items: center; | ||||||
| gap: 6px; | ||||||
| padding-right: 20px; | ||||||
| border-right: 1px solid #dfe1e6; | ||||||
| } | ||||||
|
|
||||||
| .countBadge { | ||||||
| color: #172b4d; | ||||||
| font-weight: 700; | ||||||
| font-size: 14px; | ||||||
| display: flex; | ||||||
| align-items: center; | ||||||
| justify-content: center; | ||||||
| } | ||||||
|
|
||||||
| .selectionText { | ||||||
| color: #172b4d; | ||||||
| font-size: 14px; | ||||||
| font-weight: 500; | ||||||
| } | ||||||
|
|
||||||
| /* --- NEW STYLES --- */ | ||||||
|
|
||||||
| .separator { | ||||||
| color: #dfe1e6; /* Subtle grey divider */ | ||||||
| margin: 0 8px; | ||||||
| font-size: 18px; /* Slightly taller than text */ | ||||||
| line-height: 1; | ||||||
| } | ||||||
|
|
||||||
| .selectAllBtn { | ||||||
| /* Override default Button styles to look like a link */ | ||||||
| background: none !important; | ||||||
| border: none !important; | ||||||
| padding: 0 !important; | ||||||
| margin: 0 !important; | ||||||
| box-shadow: none !important; | ||||||
|
|
||||||
| color: #0052cc !important; /* Standard "Link Blue" */ | ||||||
| font-weight: 600 !important; | ||||||
| font-size: 14px !important; | ||||||
| cursor: pointer; | ||||||
|
|
||||||
| &:hover { | ||||||
| text-decoration: underline; | ||||||
| background: none !important; | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /* ------------------ */ | ||||||
|
|
||||||
| .buttonGroup { | ||||||
| display: flex; | ||||||
| gap: 12px; | ||||||
| } | ||||||
|
|
||||||
| .actionBtn { | ||||||
| margin: 0 !important; | ||||||
| box-shadow: none !important; | ||||||
| font-weight: 600 !important; | ||||||
| } | ||||||
|
|
||||||
| @keyframes slideUp { | ||||||
| from { transform: translate(-50%, 100%); opacity: 0; } | ||||||
|
||||||
| from { transform: translate(-50%, 100%); opacity: 0; } | |
| from { transform: translate(-50%, calc(100% + 40px)); opacity: 0; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The checkbox is wrapped in a div with onClick stopPropagation, which prevents the row click event from firing when the checkbox is clicked. However, this only stops mouse click propagation. Keyboard users using Space or Enter to toggle the checkbox may still trigger the row expansion. Consider also adding onKeyDown handler to stop keyboard event propagation, or ensure the Checkbox component itself handles keyboard events appropriately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Shum-ster verify this