Last updated on September 27, 2024 pm
本节实现在cabin、booking界面的过滤、排序、分页的功能,并实现修改booking状态。
一、在客户端实现过滤cabin(可重用,排序功能)
添加一个按钮,实现查看全部/有折扣的/没有折扣的cabin。并且在选择后需要相应地改变url。
新建了CabinTableOperation组件,把它代替之前的文本,放在标题的右边。这个组件包含Filter组件,这就是我们要实现的过滤功能。
(1)创建Filter
🔥《10.28》修改bug:每次过滤后要把page设置为1,否则会访问不存在的页数的数据
1、Filter.jsx
在这个组件里,我们要用到useParams Hook。(刚好之前不太懂)
Filter接收两个prop,一个是filterField,一个是options数组。
- filterField负责被设置成URL中查询参数的键,并将其值设置为value,随后
setSearchParams
函数更新查询参数对象,使得URL反映出修改后的查询参数
- options是一个对象数组,含有value和label两个属性。value就是传递给handleClick参数的键值,label是过滤器显示在屏幕上的文本。
根据传递的options数组来map出FilterButton,使得代码更具有重用性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| function Filter({ filterField, options }) { const [searchParams, setSearchParams] = useSearchParams(); const currentFilter = searchParams.get(filterField) || options.at(0).value;
function handleClick(value) { searchParams.set(filterField, value); setSearchParams(searchParams); }
return ( <StyledFilter> {options.map((option) => ( <FilterButton onClick={() => handleClick(option.value)} key={option.value} active={option.value === currentFilter} disabled={option.value === currentFilter} > {option.label} </FilterButton> ))} </StyledFilter> ); }
|
2、CabinTableOperations.jsx
这个组件的目的就是给Filter一个造型上的作用,并且把相应的参数传递
3、CabinTable.jsx
这个组件之前的作用就是呈现出cabin table的header和下面的列表。但是由于我们想要加入过滤的功能,所以显示的cabin列表就要随着过滤器而改变。而方法就是通过读取URL上的查询参数的值,来条件更新应该展示的cabins。
1 2 3 4 5 6 7 8 9 10
| const [searchParams] = useSearchParams();
const filterValue = searchParams.get('discount') || 'all';
let filteredCabins; if (filterValue === 'all') filteredCabins = cabins; if (filterValue === 'no-discount') filteredCabins = cabins.filter((cabin) => cabin.discount === 0); if (filterValue === 'with-discount') filteredCabins = cabins.filter((cabin) => cabin.discount > 0);
|
这里的要点有一个是:当初次进入cabin界面的时候,我们想要展示所有的cabin(all),但是此时查询参数是空的,所以就可以通过||短路运算符,使得默认值是“all”。
(2)实现排序功能
1、CabinTableOperations传递给sortBy的参数
1 2 3 4 5 6 7 8
| options={[ { value: 'name-asc', label: 'Sort by name (A-Z)' }, { value: 'name-desc', label: 'Sort by name (Z-A)' }, { value: 'regularPrice-asc', label: 'Sort by Price (low first)' }, { value: 'regularPrice-desc', label: 'Sort by Price (high first)' }, { value: 'maxCapacity-asc', label: 'Sort by capacity (low first)' }, { value: 'maxCapacity-desc', label: 'Sort by capacity (high first)' }, ]}
|
这就是排序选项中的value和label数组,注意value的前值需要和cabin的属性保持一致。asc表示正序,desc表示倒序。
2、sortBy(可重用)
这个组件的功能和刚刚的Filter很像,也是把相应的value设置到查询参数中,使得CabinTable可以读取并获得相应的参数。这个不用传递键的名字,因为不管谁用,键的名字都是“sortBy”。在这个函数中得到键值并传递给Select组件,是为了传递正在被选中的value的参数。然后把onChange函数传递过去,使得改变被选元素时改变查询参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| function SortBy({ options }) { const [searchParams, setSearchParams] = useSearchParams(); const sortBy = searchParams.get('sortBy') || '';
function handleChange(e) { searchParams.set('sortBy', e.target.value); setSearchParams(searchParams); }
return ( <Select options={options} type="white" value={sortBy} onChange={handleChange} /> ); }
|
3、Select.jsx
⭐"...props"
接收所有剩余prop。
1 2 3 4 5 6 7 8 9 10 11
| function Select({ options, value, onChange, ...props }) { return ( <StyledSelect value={value} onChange={onChange} {...props}> {options.map((option) => ( <option value={option.value} key={option.value}> {option.label} </option> ))} </StyledSelect> ); }
|
4、CabinTable
第一步当然是得到查询参数的值。
但是由于它是由“-”连接,且两部分值都需要,所以可以用split函数分开。
sort函数复习:a-b是正序,b-a是倒序。因此这里用了一个巧妙的小技巧,就是使用modifier符号,如果是asc就是1,否则为-1,乘以相应逻辑,实现不同排序功能。
这里name是string,它的排序需要换一种方式,否则无法实现。
1 2 3 4 5 6 7 8 9 10 11 12
| const sortBy = searchParams.get('sortBy') || 'name-asc'; const [field, direction] = sortBy.split('-');
const modifier = direction === 'asc' ? 1 : -1;
const SortedCabins = filteredCabins.sort((a, b) => { if (typeof a[field] === 'string') { return a[field].localeCompare(b[field]) * modifier; } else { return (a[field] - b[field]) * modifier; } });
|
(怎么现在每个视频都是20分钟了😨😨😨😨😨😨要疯了我)
此处穿插一条喜报,我发现了一条Jonas都没发现的bug,就是regularPrice 和 discount的值为300 80的时候竟然会报错说discount应该小于regularPrice。百思不得其解后,感觉应该是它们俩被当作字符串比较了。但是我还是很疑惑,于是GPT这样说:
即使在表单输入中将 type
设置为 number
,在 JavaScript 中获取到的值仍然是字符串。这是因为 HTML 表单元素在提交时会将所有输入的值以字符串的形式传递给 JavaScript。
二、建立Booking Table
✅fetching data的时候,和getCabins不同,因为bookings连接了cabins和guests,所以也可以获取这两个表单的信息:(括号内可以写进任何想要获取的信息,此处挑选需要的)
1 2 3
| const { data, error } = await supabase .from('bookings') .select('*,cabins(name),guests(fullName,email)');
|
把data文件夹中的UpLoader组件包含在SideBar中就会出现这样一个可以上传样本信息到表单中,这样就不用手动创建。(记得修改Policy)(浅浅看了一下,这个函数逻辑算是比较简单)
(1)实现过滤booking
1、实现
换一种方式:不在客户端过滤,而是在服务端
operation组件已经写好,过滤是通过status的状态,而排序是根据预定的开始日期和总价格。
既然要在接收数据的时候就过滤,那么难道应该在apiBookings的函数中实现吗?不尽然。因为既然要过滤,我们就要从URL读取数据,要使用useParams,在异步fetching data阶段,是不允许使用这种钩子的。( Hooks 只能在函数组件或自定义 Hooks 中被调用)
因此我们可以在useBookings组件,也就是调用getBookings函数的地方得到查询参数的键和值,再传递给getBookings函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const filterValue = searchParams.get('status'); const filter = !filterValue || filterValue === 'all' ? null : { field: 'status', value: filterValue, method: 'eq' };
const { isLoading, data: bookings, error, } = useQuery({ queryKey: ['bookings', filter], queryFn: () => getBookings({ filter }), });
|
然后getBookings函数中在接收数据的时候根据键和值进行过滤。
1 2 3 4 5
| let query = supabase .from('bookings') .select('*,cabins(name),guests(fullName,email)'); if (filter !== null) query = query[filter.method || 'eq'](filter.field, filter.value);
|
2、要点
传递的filter是一个object,除开键和值以外还有一个method,因为query是根据method、键、值来进行过滤处理的。eq是等于,还有gte是大于等于的意思。
这里queryKey变成了一个对象,除开关键字“bookings”以外还有filter。这是因为React Query并不知道会在改变过滤的内容时就重新获取数据,于是我们切换过滤选项,页面不变,但URL会相应改变,刷新后才会显示过滤后的项目。于是把filter填进queryKey,类似于依赖数组的作用,每当filter发生变化的时候,都会重新fetch一次data。
(React Query的作用)
(2)实现Sort Booking
1、useBookings
1 2 3
| const sortByRaw = searchParams.get('sortBy') || 'startDate-desc'; const [field, direction] = sortByRaw.split('-'); const sortBy = { field, direction };
|
比较简单
2、getBookings
1 2 3 4
| if (sortBy) query = query.order(sortBy.field, { ascending: sortBy.direction === 'asc', });
|
order函数是作用于特定库来排序的,包括supabase,因此很方便。第一个参数传递根据什么来排序,第二个参数传递是否是根据升序排列。asc就是ascending的简称。
需要先看下一大节:如何建立Pagination
1、从api中获取count
我们可以直接在bookingtable
中得到bookings.length
,但是有一个更好的办法,是在获取数据的时候加上count。,然后将它返回,这样就可以容易得到获得的结果数了。
1
| .select('*,cabins(name),guests(fullName,email)', { count: 'exact' });
|
2、useBookings
1
| const page = !searchParams.get('page') ? 1 : Number(searchParams.get('page'));
|
跟分页组件中的逻辑一样,不多言。记得传递page并将其加入queryKey。
3、getBookings
1 2 3 4 5
| if (page) { const from = (page - 1) * PAGE_SIZE; const to = from + PAGE_SIZE - 1; query = query.range(from, to); }
|
range也是supabase的特殊函数,它会自动接收从from到to的数据。由于数组第一位是0位,所以from不必加1.这里的小技巧是to可以直接用from+PAGE_SIZE(记得-1),就算超过了count,也不会有错误,只会返回范围内的结果。
组件接收count prop,表示结果数。
(1)组件部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <StyledPagination> <P> Showing <span>{(currentPage - 1) * PAGE_SIZE + 1}</span> to{' '} <span> {currentPage === pageCount ? count : currentPage * PAGE_SIZE} </span>{' '} of <span>{count}</span> results </P>
<Buttons> <PaginationButton onClick={prevPage} disabled={currentPage === 1}> <HiChevronLeft /> <span>Previous</span> </PaginationButton> <PaginationButton onClick={nextPage} disabled={currentPage === pageCount} > <span>Next</span> <HiChevronRight /> </PaginationButton> </Buttons> </StyledPagination>
|
如果在最后一页的话,to后的数字就应该是count,防止出错。向前翻页和向后翻页的按钮条件禁用,原理比较易懂。
(2)变量和函数部分
首先定义在函数体外一个常量:每页的结果数。const PAGE_SIZE = 10;
✅根据上一大节的内容,我们知道这个常量还需要在apiBookings
文件中使用,因此我们可以在utils文件夹中创建一个文件:Constants.jsx
来存储这样的常量,每次使用只需导入即可。
然后是其他代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| const [searchParams, setSearchParams] = useSearchParams();
if (count <= PAGE_SIZE) return null;
const currentPage = !searchParams.get('page') ? 1 : Number(searchParams.get('page'));
const pageCount = Math.ceil(count / PAGE_SIZE);
function nextPage() { const next = currentPage === pageCount ? currentPage : currentPage + 1;
searchParams.set('page', next); setSearchParams(searchParams); }
function prevPage() { const prev = currentPage === 1 ? currentPage : currentPage - 1;
searchParams.set('page', prev); setSearchParams(searchParams); }
|
仍旧是通过强大的Param来得到当前的页数。当然刚进入这个界面的时候不存在那个查询参数,于是把当前页数设置为1,否则可以根据查询参数得到。
如果总结果数还没一页的结果多,那么直接不返回这个组件。
pageCount保留最大的页数。
每次翻页的时候都相应地更新查询参数。
(3)Prefetching
Prefetching
是一种优化技术,用于在用户需要数据之前提前获取和加载数据,以加速应用程序的响应速度。它常用于提高用户体验,减少等待时间,特别是在网页应用和数据驱动的应用中。
现在要做的很简单,就是我们再切换页面的时候,不管是前翻还是后翻,总会出现一个加载页面。但是我们想要数据可以丝滑地出现在界面上,于是,在useBookings组件中:
首先获得:const queryClient = useQueryClient();
然后在useQuery查询数据之后,返回所有数据之前:(有点简单不多说了)
1 2 3 4 5 6 7 8 9 10 11 12 13
| const pageCount = Math.ceil(count / PAGE_SIZE);
if (page < pageCount) queryClient.prefetchQuery({ queryKey: ['bookings', filter, sortBy, page + 1], queryFn: () => getBookings({ filter, sortBy, page: page + 1 }), });
if (page > 1) queryClient.prefetchQuery({ queryKey: ['bookings', filter, sortBy, page - 1], queryFn: () => getBookings({ filter, sortBy, page: page - 1 }), });
|
四、建立Booking的Detail界面
首先在App.js的Router中加上这样一条:
<Route path="bookings/:bookingId" element={<Booking />} />
我们新创建了一个界面:Booking,里面直接返回BookingDetial
组件,而这个组件需要接收相应的booking数据,这个数据是由我们新创建的钩子中获得。
(1)useBooking
要点有二:
- 通过URL获得bookingId,使用了
useParams
钩子可直接获得
- 把id传递给getBooking函数中
(2)getBooking
这个函数位于api中,作用是返回相应id的Booking的所有详细信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| export async function getBooking(id) { const { data, error } = await supabase .from('bookings') .select('*, cabins(*), guests(*)') .eq('id', id) .single();
if (error) { console.error(error); throw new Error('Booking not found'); }
return data; }
|
(3)BookingRow
用到了先前创建的Menu复合组件。点击按钮会导航界面到相应的detail界面,因此把相应的id放入URL中方便被提取。
1 2 3 4 5 6 7 8 9 10 11
| <Menus.Menu> <Menus.Toggle id={bookingId}></Menus.Toggle> <Menus.List id={bookingId}> <Menus.Button icon={<HiEye />} onClick={() => navigate(`/bookings/${bookingId}`)} > See details </Menus.Button> </Menus.List> </Menus.Menu>
|
(4)BookingDetail
以下几行有一个要点:
1 2 3 4 5 6
| const { booking, isLoading } = useBooking(); const moveBack = useMoveBack();
if (isLoading) return <Spinner />;
const { status, id: bookingId } = booking;
|
第一useMoveBack
钩子不能放在if
语句之后;
第二读取{ status, id: bookingId }
必须放在判断是否在加载语句之后,否则我们会在数据还没有加载完成的时候就试图读取,就会报错。(还让人百思不得其解)
五、实现Checkin、Checkout、Delete的功能
(1)实现基本checkin功能
新建了checkin/:bookingId的界面。
在BookingRow和BookingDetial中都加入了“checkin”按钮,可以导航到checkin界面。这个界面中还有一个确认checkin的按钮,作用是需要修改booking的statu变成checkin,是否付钱变为Paid。(只有状态为unconfirmed的booking才能被checkin)
useCheckin:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| export function useCheckin() { const queryClient = useQueryClient(); const navigate = useNavigate();
const { mutate: checkin, isLoading: isCheckingIn } = useMutation({ mutationFn: (bookingId) => updateBooking(bookingId, { status: 'checked-in', isPaid: true }),
onSuccess: (data) => { toast.success(`Booking #${data.id} successfully checked in`); queryClient.invalidateQueries({ active: true, }); navigate('/'); },
onError: () => toast.error('There was an error while checking in'), });
return { checkin, isCheckingIn }; }
|
通过代码可以看到,onSuccess函数是可以接收到返回的data数据的。
queryClient.invalidateQueries({});
之前是放置queryId,现在放置active设置为true
⭐queryClient.invalidateQueries()
是 React Query 中用于使缓存失效并触发重新获取数据的方法。在这里使用了一个配置对象 { active: true }
,这表示使所有当前 “active”(即正在被观察或使用)的查询失效。
(2)实现添加早饭的功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| {!hasBreakfast && ( <Box> <Checkbox checked={addBreakfast} onChange={() => { setAddBreakfast((addBreakfast) => !addBreakfast); setConfirmPaid(false); }} id="Breakfast" > Want to add breakfast for {formatCurrency(optionalBreakfastPrice)}? </Checkbox> </Box> )}
|
除此之外确定付钱的checkbox也要把总钱数修改一下。
这个时候,在checkin的时候就要分情况讨论是否有添加早餐,传递给checkin函数的就不仅仅是bookingId了,而且需要是一个对象,包含:
1 2 3 4 5 6 7
| checkin({ bookingId, breakfast: { hasBreakfast: true, extrasPrice: optionalBreakfastPrice, totalPrice: totalPrice + optionalBreakfastPrice, },
|
useCheckin中也需要发生改变:
1 2 3 4 5 6
| mutationFn: ({ bookingId, breakfast }) => updateBooking(bookingId, { status: 'checked-in', isPaid: true, ...breakfast, }),
|
(3)checkout和delete
delete中只有一个地方,就是在detail界面想要删掉后就返回booking界面。所以在这里传递一个对象,包含想要发生的事情,这里传递的onSettled意思是不管成功或失败均执行。
1 2 3 4 5 6 7 8 9
| <Modal.Window name="delete"> <ConfirmDelete resourceName="booking" disabled={isDeletingBooking} onConfirm={() => deleteBooking(bookingId, { onSettled: () => navigate(-1) }) } /> </Modal.Window>
|