Skip to content
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

Add pagination to manage automations #26414

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1cb4da0
initial commit
sgress454 Feb 13, 2025
8e9dd5f
test integrating with modal
sgress454 Feb 13, 2025
6d90627
more implementation tests
sgress454 Feb 13, 2025
5a715ba
stylish!
sgress454 Feb 13, 2025
ed218e9
add software dropdown
sgress454 Feb 13, 2025
7b21097
working!
sgress454 Feb 13, 2025
686ec42
cleanup
sgress454 Feb 13, 2025
0a7fa8c
loading state
sgress454 Feb 13, 2025
717e247
abstraction
sgress454 Feb 17, 2025
dabb946
cleanup, start calendar intg
sgress454 Feb 17, 2025
8804c73
more calendar work
sgress454 Feb 17, 2025
a9131dc
run scripts modal
sgress454 Feb 18, 2025
50a8fe1
style and tweaks
sgress454 Feb 18, 2025
03c0b6c
workflows modal
sgress454 Feb 18, 2025
913782d
send changed policies on save
sgress454 Feb 18, 2025
c1d6dbe
comments
sgress454 Feb 18, 2025
2e8b494
cleanup
sgress454 Feb 18, 2025
6bda323
Hide main calendar modal when preview is open
sgress454 Feb 19, 2025
f825eb0
handle global policies
sgress454 Feb 19, 2025
fe73a69
remove "total" for now
sgress454 Feb 19, 2025
4e558b7
Use tooltip truncated text
sgress454 Feb 19, 2025
bec4141
allow disabling save button
sgress454 Feb 19, 2025
2ef5011
update disable logic
sgress454 Feb 19, 2025
cd348e0
Add disable logic to script modal
sgress454 Feb 19, 2025
61afb5f
lint
sgress454 Feb 19, 2025
105da85
update query test to account for controlled checkbox
sgress454 Feb 19, 2025
d66dea9
calendar preview style
sgress454 Feb 19, 2025
a30c9f4
better escape handling
sgress454 Feb 19, 2025
d351e74
move style
sgress454 Feb 19, 2025
80417db
update test
sgress454 Feb 19, 2025
7fd1cf8
add comment re: forwardref
sgress454 Feb 20, 2025
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
204 changes: 204 additions & 0 deletions frontend/components/PaginatedList/PaginatedList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import React, {
useState,
useEffect,
useImperativeHandle,
forwardRef,
Ref,
} from "react";
import { ReactElement } from "react-markdown/lib/react-markdown";
import Checkbox from "components/forms/fields/Checkbox";
import Spinner from "components/Spinner";
import TooltipTruncatedText from "components/TooltipTruncatedText";
// @ts-ignore
import Pagination from "components/Pagination";

const baseClass = "paginated-list";

// Create an interface for the Ref, so that when parents use `useRef` to provide
// a reference to this list, they can call `getDirtyItems()` on it to retrieve
// the list of dirty items.
export interface IPaginatedListHandle<TItem> {
getDirtyItems: () => TItem[];
}
interface IPaginatedListProps<TItem> {
// Function to fetch one page of data.
fetchPage: (pageNumber: number) => Promise<TItem[]>;
// UID property in an item. Defaults to `id`.
idKey?: string;
// Property to use as an item's label. Defaults to `name`.
labelKey?: string;
// How to determine whether to check an item's checkbox.
// If string, a key in an item whose truthiness will be checked.
// if function, a function that given an item, returns a boolean.
isSelected: string | ((item: TItem) => boolean);
// Custom function to render the label for an item.
renderItemLabel?: (item: TItem) => ReactElement | null;
// Custom function to render extra markup (besides the label) in an item row.
renderItemRow?: (
item: TItem,
// A callback function that the extra markup logic can call to indicate a change
// to the item, for example if a dropdown is changed.
onChange: (item: TItem) => void
) => ReactElement | false | null | undefined;
// A function to call when an item's checkbox is toggled.
// Parents can use this to change whatever item metadata is needed to toggle
// the value indicated by `isSelected`.
onToggleItem: (item: TItem) => TItem;
// The size of the page to fetch and show.
pageSize?: number;
onUpdate?: (changedItems: TItem[]) => void;
}

function PaginatedListInner<TItem extends Record<string, any>>(
{
fetchPage,
idKey: _idKey,
labelKey: _labelKey,
pageSize: _pageSize,
renderItemLabel,
renderItemRow,
onToggleItem,
onUpdate,
isSelected,
}: IPaginatedListProps<TItem>,
ref: Ref<IPaginatedListHandle<TItem>>
) {
// The # of the page to display.
const [currentPage, setCurrentPage] = useState(0);
// The set of items fetched via `fetchPage`.
const [items, setItems] = useState<TItem[]>([]);
// The set of items that have been changed in some way.
const [dirtyItems, setDirtyItems] = useState<Record<string | number, TItem>>(
{}
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const idKey = _idKey ?? "id";
const labelKey = _labelKey ?? "name";
const pageSize = _pageSize ?? 20;

// When the current page # changes, fetch a new page of data.
useEffect(() => {
let isCancelled = false;

async function loadPage() {
try {
setIsLoading(true);
setError(null);
const result = await fetchPage(currentPage);
if (!isCancelled) {
setItems(result);
}
} catch (err) {
if (!isCancelled) {
setError(err as Error);
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
}

loadPage();

return () => {
isCancelled = true;
};
}, [currentPage, fetchPage]);

// Whenever the dirty items list changes, notify the parent.
useEffect(() => {
if (onUpdate) {
onUpdate(Object.values(dirtyItems));
}
}, [onUpdate, dirtyItems]);

// Create an imperative handle for this component so that parents
// can call `ref.current.getDirtyItems()` to get the changed set.
useImperativeHandle(ref, () => ({
getDirtyItems() {
return Object.values(dirtyItems);
},
}));

// TODO -- better error state?
Copy link
Contributor

Choose a reason for hiding this comment

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

DataError 👍

if (error) return <p>Error: {error.message}</p>;

// Redner the list.
return (
<div className={baseClass}>
{isLoading && (
<div className="loading-overlay">
<Spinner />
</div>
)}
<ul className={`${baseClass}__list`}>
{items.map((_item) => {
// If an item has been marked as changed, use the changed version
// of the item rather than the one from the page fetch. This allows
// us to render an item correctly even after we've navigated away
// from its page and then back again.
const item = dirtyItems[_item[idKey]] ?? _item;
return (
<li className={`${baseClass}__row`} key={item[idKey]}>
<Checkbox
value={
typeof isSelected === "function"
? isSelected(item)
: item[isSelected]
}
name={`item_${item[idKey]}_checkbox`}
onChange={() => {
// When checkbox is toggled, set item as dirty.
// The parent is responsible for actually updating item properties via onToggleItem().
setDirtyItems({
...dirtyItems,
[item[idKey]]: onToggleItem(item),
});
}}
>
{renderItemLabel ? (
renderItemLabel(item)
) : (
<TooltipTruncatedText
value={
<span className={`${baseClass}__item-label`}>
{item[labelKey]}
</span>
}
/>
)}
</Checkbox>
{renderItemRow &&
// If a custom row renderer was supplied, call it with the item value
// as well as the callback the parent can use to indicate changes to an item.
renderItemRow(item, (changedItem) => {
setDirtyItems({
...dirtyItems,
[changedItem[idKey]]: changedItem,
});
})}
</li>
);
})}
</ul>
<Pagination
resultsOnCurrentPage={items.length}
currentPage={currentPage}
resultsPerPage={pageSize}
onPaginationChange={setCurrentPage}
/>
</div>
);
}

// Wrap with forwardRef to expose the imperative handle.
// TODO -- can remove this after upgrading to React 19.
const PaginatedList = forwardRef(PaginatedListInner) as <TItem>(
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like forwardRef will be deprecated soon,
is there a way to do this with the ref passed in as a prop instead?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I see...until we're on v19, you need to use forwardRef for the useImperativeHandle above. Can
we just leave a note then to change this whenever we upgrade to v19?

props: IPaginatedListProps<TItem> & {
ref?: Ref<IPaginatedListHandle<TItem>>;
}
) => JSX.Element;

export default PaginatedList;
93 changes: 93 additions & 0 deletions frontend/components/PaginatedList/_styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
.paginated-list {

.loading-overlay {
display: flex;
flex-grow: 1;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(255, 255, 255, 0.8);
z-index: 1;

.loading-spinner {
position: sticky;
top: 0px;
left: 0px
}
}

&__list {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
border-radius: 4px;
border: 1px solid $ui-fleet-black-10;
// negate ul padding
padding-left: 0;
margin: 0;
}

&__row {
display: flex;
max-width: 100%;
padding: 8px 12px;
justify-content: space-between;
align-items: center;
align-self: stretch;
border-bottom: 1px solid $ui-fleet-black-10;
gap: 20px;

.form-field--checkbox {
flex: 1 1 0%;
/* This allows growing and shrinking */
min-width: 0;
/* This is crucial for proper shrinking */
}

.fleet-checkbox__tick {
flex: 0 0 auto;
/* This prevents growing and shrinking */
width: fit-content;
/* This ensures button isn't cut off */
}

.fleet-checkbox__label {
display: flex;
white-space: nowrap;
flex: 1 1 0%;
/* This allows growing and shrinking */
min-width: 0;
/* This is crucial for proper shrinking */
}

&:hover {
background: $ui-off-white;
cursor: pointer;

label {
cursor: pointer;
}

.policy-row__preview-button {
visibility: visible;
}
}

&:first-child {
border-radius: 4px 4px 0 0;
}

&:last-child {
border-radius: 0 0 4px 4px;
border-bottom: none;
}
}

// For TooltipTruncatedText
.fleet-checkbox__label {
display: flex;
}
}
2 changes: 2 additions & 0 deletions frontend/components/PaginatedList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./PaginatedList";
export type { IPaginatedListHandle } from "./PaginatedList";
2 changes: 1 addition & 1 deletion frontend/components/forms/fields/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const Checkbox = (props: ICheckboxProps) => {
type="checkbox"
ref={inputRef}
name={name}
checked={value || undefined}
checked={!!value}
onChange={noop} // Empty onChange to avoid React warning
disabled={disabled || readOnly}
style={{ display: "none" }} // Hide the input
Expand Down
Loading
Loading