-
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 all 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 |
|---|---|---|
|
|
@@ -56,3 +56,10 @@ class Meta: | |
|
|
||
| class PatchSingleBookingSerializer(serializers.Serializer): | ||
| action = serializers.ChoiceField(choices=BookingStatusAction.choices) | ||
|
|
||
| class PatchBulkBookingSerializer(serializers.Serializer): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LGTM |
||
| booking_ids = serializers.ListField( | ||
| child=serializers.IntegerField(), | ||
| allow_empty=False | ||
| ) | ||
| action = serializers.ChoiceField(choices=BookingStatusAction.choices) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ | |
| GetBookingSerializer, | ||
| PostBookingSerializer, | ||
| PatchSingleBookingSerializer, | ||
| PatchBulkBookingSerializer, | ||
| ) | ||
| from .models import Booking, BookingStatus | ||
| from .middlewares import check_requester_is_booker_or_admin | ||
|
|
@@ -30,6 +31,7 @@ | |
| create_bookings, | ||
| DateTimeInterval, | ||
| update_booking_status, | ||
| bulk_update_booking_status, | ||
| ) | ||
| from .middlewares import check_requester_booking_same_organization | ||
|
|
||
|
|
@@ -366,3 +368,37 @@ def delete(self, request, requester: User, booking: Booking): | |
| booking.delete() | ||
|
|
||
| return Response(data, status=status.HTTP_200_OK) | ||
|
|
||
| class BulkBookingView(APIView): | ||
| """ | ||
| Bulk booking management endpoint. | ||
|
|
||
| PATCH: Update status for multiple bookings | ||
| - Actions: APPROVE, REJECT | ||
| - Runs in a single atomic transaction | ||
| - Returns updated booking(s) information | ||
| """ | ||
|
|
||
| @check_access(Role.ADMIN) | ||
| def patch(self, request, requester: User): | ||
| serializer = PatchBulkBookingSerializer(data=request.data) | ||
| serializer.is_valid(raise_exception=True) | ||
|
|
||
| booking_ids = serializer.validated_data.get("booking_ids") | ||
| action = serializer.validated_data.get("action") | ||
|
|
||
| ( | ||
| updated_bookings, | ||
| master_status_mapping, | ||
| ) = bulk_update_booking_status( | ||
| booking_ids=booking_ids, action=action, user=requester | ||
| ) | ||
|
|
||
| send_updated_booking_emails( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Shum-ster help to test this after deployed to beta, thanks. LGTM. |
||
| bookings=updated_bookings, | ||
| id_to_previous_booking_status_mapping=master_status_mapping, | ||
| ) | ||
|
|
||
| data = [booking_to_json(booking) for booking in updated_bookings] | ||
|
|
||
| return Response(data, status=status.HTTP_200_OK) | ||
| 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,205 @@ | ||
| 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, | ||
| CHECKBOX, | ||
| 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> | ||
|
Comment on lines
+102
to
+118
|
||
| ); | ||
| }, | ||
| [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} | ||
| 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; | ||
| 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"; |
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.
LGTM