Skip to content
Open
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
19 changes: 19 additions & 0 deletions doc/designs/sub-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ See [00-best-practices.md](sub-pages/00-best-practices.md) for the complete guid

See [03-tabs-component.md](sub-pages/03-tabs-component.md) for Tabs details.

## Bulk Selection (BulkSelectorPrep)

Any sub-page that is **not** Settings, "Is a member of", Members, or ManagedBy
must include a `BulkSelectorPrep` component in its toolbar to allow bulk
operations (select page / select all / unselect) on the table.

| Sub-Page Type | BulkSelectorPrep Required? |
|---------------|---------------------------|
| **Settings** | No |
| **Members** | No |
| **Is a member of** | No |
| **ManagedBy** | No |
| **Table tabs** (e.g., DNS Records) | **Yes** — see [05-table-tab.md](sub-pages/05-table-tab.md) |
| **Independent** (e.g., Privileges) | **Yes** — see [17-independent-sub-pages.md](sub-pages/17-independent-sub-pages.md) |

For independent sub-pages that use `MemberOfToolbar`, pass the `BulkSelectorPrep`
via the `bulkSelector` prop. For table tabs that use `ToolbarLayout`, add it as
the first toolbar item.

## Navigation Bar Highlighting

**Every sub-page** must call `useUpdateRoute`:
Expand Down
80 changes: 77 additions & 3 deletions doc/designs/sub-pages/17-independent-sub-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,60 @@ addPrivilegeToRole: build.mutation({
}),
```

## BulkSelectorPrep (Required)

Independent sub-pages **must** include a `BulkSelectorPrep` component to allow
bulk operations (select page / select all / unselect) on the table. Pass it to
`MemberOfToolbar` via the `bulkSelector` prop.

This requires tracking selected items as entity objects (not plain strings) so
`BulkSelectorPrep` can manage them. Derive a `string[]` for `MemberTable`
compatibility via `useMemo`.

```tsx
import BulkSelectorPrep from "src/components/BulkSelectorPrep";
import { getSelectedPerPageData } from "src/utils/selectedPerPage";

// Entity-based selection state
const [selectedItems, setSelectedItems] = React.useState<ItemType[]>([]);

// Derive string[] for MemberTable
const selectedNames = useMemo(() => selectedItems.map((i) => i.cn), [selectedItems]);

// Delete button disabled state (managed by BulkSelectorPrep and selection helpers)
const [isDeleteButtonDisabled, setIsDeleteButtonDisabled] = React.useState(true);

// Update handler for BulkSelectorPrep
const updateSelected = (items: ItemType[], isSelected: boolean) => {
let newSelected: ItemType[];
if (isSelected) {
const currentNames = new Set(selectedNames);
const toAdd = items.filter((item) => !currentNames.has(item.cn));
newSelected = [...selectedItems, ...toAdd];
} else {
const removeNames = new Set(items.map((item) => item.cn));
newSelected = selectedItems.filter((p) => !removeNames.has(p.cn));
}
setSelectedItems(newSelected);
setIsDeleteButtonDisabled(newSelected.length === 0);
};

// Adapter for MemberTable's string-based onCheckItemsChange
const onCheckItemsChange = (checkedNames: string[]) => {
setSelectedItems(checkedNames.map((name) => ({ cn: name })));
setIsDeleteButtonDisabled(checkedNames.length === 0);
};

// BulkSelectorPrep data
const selectedPerPageData = getSelectedPerPageData(items, selectedNames, (i) => i.cn);
const bulkSelectorData = {
selected: selectedItems,
updateSelected,
selectableTable: items,
nameAttr: "cn",
};
```

## Component Structure

```tsx
Expand All @@ -100,8 +154,27 @@ const <Entity><SubPage> = (props) => {

return (
<TabLayout id="subpage">
<MemberOfToolbar ... />
<MemberTable entityList={data} idKey="cn" from="privileges" ... />
<MemberOfToolbar
bulkSelector={
<BulkSelectorPrep
list={data}
shownElementsList={data}
elementData={bulkSelectorData}
buttonsData={{ updateIsDeleteButtonDisabled: setIsDeleteButtonDisabled }}
selectedPerPageData={selectedPerPageData}
/>
}
deleteButtonEnabled={!isDeleteButtonDisabled && isRefreshButtonEnabled}
...
/>
<MemberTable
entityList={data}
idKey="cn"
from="privileges"
checkedItems={selectedNames}
onCheckItemsChange={onCheckItemsChange}
...
/>
<Pagination ... />
{showAddModal && <MemberOfAddModal ... />}
{showDeleteModal && <MemberOfDeleteModal ... />}
Expand All @@ -119,5 +192,6 @@ const <Entity><SubPage> = (props) => {

## Reference Implementations

- `src/pages/Privileges/PrivilegesPermissions.tsx` — uses `MemberOfToolbar` with `bulkSelector` prop
- `src/pages/Roles/RolesPrivileges.tsx`
- `src/pages/DNSZones/DnsResourceRecords.tsx`
- `src/pages/DNSZones/DnsResourceRecords.tsx` — uses `ToolbarLayout` with `BulkSelectorPrep` as first item
5 changes: 4 additions & 1 deletion src/components/MemberOf/MemberOfAddModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ interface PropsToAdd {
onCloseModal: () => void;
availableItems: AvailableItems[];
onAdd: (items: AvailableItems[]) => void;
onSearchTextChange: (searchText: string) => void;
onSearchTextChange?: (searchText: string) => void;
title: string;
ariaLabel: string;
spinning: boolean;
isSearchable?: boolean;
}

const MemberOfAddModal = (props: PropsToAdd) => {
Expand Down Expand Up @@ -69,6 +70,8 @@ const MemberOfAddModal = (props: PropsToAdd) => {
setAvailableOptions={setAvailableOptions}
chosenOptions={chosenOptions}
setChosenOptions={setChosenOptions}
isSearchable={props.isSearchable}
onSearchTextChange={props.onSearchTextChange}
/>
),
},
Expand Down
6 changes: 6 additions & 0 deletions src/components/MemberOf/MemberOfToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import PaginationLayout from "../layouts/PaginationLayout";
export type MembershipDirection = "direct" | "indirect";

interface MemberOfToolbarProps {
// bulk selector (optional, rendered before search)
bulkSelector?: React.ReactNode;

// search
searchPlaceholder: string;
searchAriaLabel: string;
Expand Down Expand Up @@ -56,6 +59,9 @@ const MemberOfToolbar = (props: MemberOfToolbarProps) => {
return (
<Toolbar>
<ToolbarContent>
{props.bulkSelector && (
<ToolbarItem id="bulk-selector">{props.bulkSelector}</ToolbarItem>
)}
<ToolbarItem id="search-input" gap={{ default: "gapMd" }}>
<SearchInputLayout
dataCy="search"
Expand Down
80 changes: 62 additions & 18 deletions src/components/layouts/DualListSelectorGeneric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DualListSelectorListItem,
DualListSelectorControlsWrapper,
DualListSelectorControl,
SearchInput,
} from "@patternfly/react-core";
import {
AngleDoubleLeftIcon,
Expand All @@ -17,6 +18,7 @@ import {
export interface DualListOption {
text: string;
selected: boolean;
isVisible: boolean;
dataCy: string;
}

Expand All @@ -29,6 +31,8 @@ interface DualListGenericProps {
availableOptionsTitle?: string;
chosenOptionsTitle?: string;
ariaLabel?: string;
isSearchable?: boolean;
onSearchTextChange?: (searchText: string) => void;
}

// Helper function: Parse data to 'DualListOption'
Expand All @@ -38,6 +42,7 @@ export const optionsToDualListOptions = (
return options.map((option) => ({
text: option,
selected: false,
isVisible: true,
dataCy: `item-${option}`,
}));
};
Expand All @@ -51,6 +56,21 @@ const DualListSelectorGeneric = (props: DualListGenericProps) => {
setChosenOptions,
} = props;

const [availableFilter, setAvailableFilter] = React.useState("");

const onFilterChange = (value: string) => {
setAvailableFilter(value);
const toFilter = [...availableOptions];
toFilter.forEach((option) => {
option.isVisible =
value === "" || option.text.toLowerCase().includes(value.toLowerCase());
});
setAvailableOptions(toFilter);
if (props.onSearchTextChange) {
props.onSearchTextChange(value);
}
};

// callback for moving selected options between lists
const moveSelected = (fromAvailable: boolean) => {
const sourceOptions = fromAvailable
Expand All @@ -61,7 +81,7 @@ const DualListSelectorGeneric = (props: DualListGenericProps) => {
: props.availableOptions;
for (let i = 0; i < sourceOptions.length; i++) {
const option = sourceOptions[i];
if (option.selected) {
if (option.selected && option.isVisible) {
sourceOptions.splice(i, 1);
destinationOptions.push(option);
option.selected = false;
Expand All @@ -80,8 +100,13 @@ const DualListSelectorGeneric = (props: DualListGenericProps) => {
// callback for moving all options between lists
const moveAll = (fromAvailable: boolean) => {
if (fromAvailable) {
setChosenOptions([...availableOptions, ...chosenOptions]);
setAvailableOptions([]);
setChosenOptions([
...availableOptions.filter((option) => option.isVisible),
...chosenOptions,
]);
setAvailableOptions([
...availableOptions.filter((option) => !option.isVisible),
]);
} else {
setAvailableOptions([...chosenOptions, ...availableOptions]);
setChosenOptions([]);
Expand Down Expand Up @@ -113,35 +138,54 @@ const DualListSelectorGeneric = (props: DualListGenericProps) => {
>
<DualListSelectorPane
title={props.availableOptionsTitle || "Available options"}
status={`${availableOptions.filter((option) => option.selected).length} of ${
availableOptions.length
status={`${availableOptions.filter((option) => option.selected && option.isVisible).length} of ${
availableOptions.filter((option) => option.isVisible).length
} options selected`}
searchInput={
props.isSearchable ? (
<SearchInput
value={availableFilter}
onChange={(_event, value) => onFilterChange(value)}
onClear={() => onFilterChange("")}
aria-label="Search available options"
Comment thread
veronnicka marked this conversation as resolved.
data-cy="dual-list-available-search"
/>
) : undefined
}
data-cy="dual-list-left"
>
<DualListSelectorList>
{availableOptions.map((option, index) => (
<DualListSelectorListItem
key={index}
isSelected={option.selected}
id={`basic-available-option-${index}`}
onOptionSelect={(e) => onOptionSelect(e, index, false)}
data-cy={option.dataCy}
>
{option.text}
</DualListSelectorListItem>
))}
{availableOptions.map((option, index) =>
option.isVisible ? (
<DualListSelectorListItem
key={index}
isSelected={option.selected}
id={`basic-available-option-${index}`}
onOptionSelect={(e) => onOptionSelect(e, index, false)}
data-cy={option.dataCy}
>
{option.text}
</DualListSelectorListItem>
) : null
)}
</DualListSelectorList>
</DualListSelectorPane>
<DualListSelectorControlsWrapper>
<DualListSelectorControl
isDisabled={!availableOptions.some((option) => option.selected)}
isDisabled={
!availableOptions.some(
(option) => option.selected && option.isVisible
)
}
onClick={() => moveSelected(true)}
aria-label="Add selected"
data-cy="dual-list-add-selected"
icon={<AngleRightIcon />}
/>
<DualListSelectorControl
isDisabled={availableOptions.length === 0}
isDisabled={
availableOptions.filter((option) => option.isVisible).length === 0
}
onClick={() => moveAll(true)}
aria-label="Add all"
data-cy="dual-list-add-all"
Expand Down
14 changes: 11 additions & 3 deletions src/components/tables/MembershipTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type FromTypes =
| "host-groups"
| "idoverrideuser"
| "netgroups"
| "permissions"
| "privileges"
| "roles"
| "services"
Expand All @@ -77,13 +78,14 @@ interface MemberTableProps {
checkedItems?: string[];
onCheckItemsChange?: (checkedItems: string[]) => void;
showTableRows: boolean;
showLink?: boolean;
}

// Types that use string arrays instead of objects
const STRING_ARRAY_TYPES = ["external", "sysaccount", "idoverrideuser"];

// Track those types that don't have links
const NO_LINK_TYPES: string[] = ["roles", "privileges"];
const NO_LINK_TYPES: string[] = ["roles", "privileges", "permissions"];

// Body
const TableBody = (props: {
Expand All @@ -95,14 +97,19 @@ const TableBody = (props: {
showCheckboxColumn: boolean;
checkedItems: string[];
onCheckboxChange: (checked: boolean, entityName: string) => void;
showLink?: boolean;
}) => {
const { list, idKey, propertiesToShow } = props;

// Check if this is a string array type (external, sysaccount, idoverrideuser)
const isStringArray = STRING_ARRAY_TYPES.includes(props.from);

const shouldRenderLink = (from: string, isStringArray: boolean) =>
!isStringArray && !NO_LINK_TYPES.includes(from);
const shouldRenderLink = (from: string, isStringArray: boolean) => {
if (props.showLink === false) return false;
if (isStringArray) return false;
if (props.showLink === true) return true;
return !NO_LINK_TYPES.includes(from);
};

const getItemLink = (from: string, itemId: string) =>
from === "services"
Expand Down Expand Up @@ -255,6 +262,7 @@ export default function MemberTable(props: MemberTableProps) {
showCheckboxColumn={showCheckboxColumn}
onCheckboxChange={onCheckboxChange}
checkedItems={props.checkedItems || []}
showLink={props.showLink}
/>
)}
</Tbody>
Expand Down
4 changes: 4 additions & 0 deletions src/navigation/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,10 @@ export const AppRoutes = ({ isInitialDataLoaded }): React.ReactElement => {
path=""
element={<PrivilegesTabs section="settings" />}
/>
<Route
path="permissions"
element={<PrivilegesTabs section="permissions" />}
/>
</Route>
</Route>
<Route path="configuration" element={<Configuration />} />
Expand Down
Loading
Loading