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
26 changes: 12 additions & 14 deletions src/components/events/partials/ModalTabsAndPages/NewAccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Notifications from "../../../shared/Notifications";
import {
Role,
checkAcls,
fetchAclActions,
fetchAclTemplates,
fetchRolesWithTarget,
} from "../../../../slices/aclSlice";
import { FormikProps } from "formik";
import { policiesFiltered, rolesFiltered } from "../../../../utils/aclUtils";
import { policiesFiltered } from "../../../../utils/aclUtils";
import { useAppDispatch, useAppSelector } from "../../../../store";
import { fetchSeriesDetailsAcls } from "../../../../slices/seriesDetailsSlice";
import { getSeriesDetailsAcl } from "../../../../selectors/seriesDetailsSelectors";
Expand Down Expand Up @@ -55,21 +54,23 @@ const NewAccessPage = <T extends RequiredFormProps>({
// States containing response from server concerning acl templates, actions and roles
const [aclTemplates, setAclTemplates] = useState<{ id: string, value: string}[]>([]);
const [aclActions, setAclActions] = useState<{ id: string, value: string}[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [isSanitize, setIsSanitize] = useState<boolean | undefined>(undefined);
const [loading, setLoading] = useState(false);

const seriesAcl = useAppSelector(state => getSeriesDetailsAcl(state));
const user = useAppSelector(state => getUserInformation(state));

useEffect(() => {
// fetch data about roles, acl templates and actions from backend
// fetch data about acl templates and actions from backend
async function fetchData() {
setLoading(true);
const [responseTemplates, responseActions, responseRoles] = await Promise.all([
fetchAclTemplates(), fetchAclActions(), fetchRolesWithTarget("ACL")]);
fetchAclTemplates(), fetchAclActions(), fetchRolesWithTarget("ACL", { limit: 1 })]);
setAclTemplates(responseTemplates);
setAclActions(responseActions);
setRoles(responseRoles);
if (responseRoles.length > 0) {
setIsSanitize(responseRoles[0].isSanitize);
}
setLoading(false);
}

Expand Down Expand Up @@ -118,21 +119,20 @@ const NewAccessPage = <T extends RequiredFormProps>({
defaultUser={user}
/>

{roles.length > 0 && !roles[0].isSanitize &&
{isSanitize === false &&
<>
{hasAccess(viewUsersAccessRole, user) &&
<AccessPolicyTable
isUserTable={true}
policiesFiltered={policiesFiltered(formik.values.policies, true)}
rolesFilteredbyPolicies={rolesFiltered(roles, true)}
hasUser={true}
header={"EVENTS.EVENTS.DETAILS.ACCESS.ACCESS_POLICY.USERS"}
firstColumnHeader={"EVENTS.EVENTS.DETAILS.ACCESS.ACCESS_POLICY.USER"}
createLabel={"EVENTS.EVENTS.DETAILS.ACCESS.ACCESS_POLICY.NEW_USER"}
formik={formik}
hasActions={aclActions.length > 0}
transactions={{ readOnly: false }}
aclActions={aclActions}
roles={roles}
editAccessRole={editAccessRole}
/>
}
Expand All @@ -141,34 +141,32 @@ const NewAccessPage = <T extends RequiredFormProps>({
<AccessPolicyTable
isUserTable={false}
policiesFiltered={policiesFiltered(formik.values.policies, false)}
rolesFilteredbyPolicies={rolesFiltered(roles, false)}
hasUser={false}
header={"USERS.ACLS.NEW.ACCESS.ACCESS_POLICY.NON_USER_ROLES"}
firstColumnHeader={"EVENTS.EVENTS.DETAILS.ACCESS.ACCESS_POLICY.ROLE"}
createLabel={"EVENTS.EVENTS.DETAILS.ACCESS.ACCESS_POLICY.NEW"}
formik={formik}
hasActions={aclActions.length > 0}
transactions={{ readOnly: false }}
aclActions={aclActions}
roles={roles}
editAccessRole={editAccessRole}
/>
}
</>
}

{roles.length > 0 && roles[0].isSanitize &&
{isSanitize === true &&
<>
<AccessPolicyTable
isUserTable={false}
policiesFiltered={formik.values.policies}
rolesFilteredbyPolicies={roles}
hasUser={undefined}
firstColumnHeader={"EVENTS.EVENTS.DETAILS.ACCESS.ACCESS_POLICY.ROLE"}
createLabel={"EVENTS.EVENTS.DETAILS.ACCESS.ACCESS_POLICY.NEW"}
formik={formik}
hasActions={aclActions.length > 0}
transactions={{ readOnly: false }}
aclActions={aclActions}
roles={roles}
editAccessRole={editAccessRole}
/>
<div className="obj-container">
Expand Down
285 changes: 285 additions & 0 deletions src/components/shared/PagedDropDown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Select, {
components as SelectComponents,
GroupBase,
MenuListProps,
OptionProps,
ValueContainerProps,
} from "react-select";
import CreatableSelect from "react-select/creatable";
import { dropDownSpacingTheme, dropDownStyle } from "../../utils/componentStyles";
import { DropDownOption } from "./DropDown";

// How long to wait after the user stops typing before searching.
const SEARCH_DEBOUNCE_MS = 300;

// A page may consist only of options that the caller filters out. Look at this many pages in a row for something to
// show before giving up, so that a menu that could be scrolled is not left empty.
const MAX_CONSECUTIVE_EMPTY_PAGES = 10;

export type DropDownPage = {
options: DropDownOption<string>[]
// Offset to pass to fetch the page after this one, undefined if this was the last page.
nextOffset: number | undefined
}

// How close to the end of the list, in pixels, counts as having reached it
const NEAR_BOTTOM_PX = 10;

/**
* The list of options. Tells when its end is in view, and shows that there is more to come while it is loading.
*
* react-select's own onMenuScrollToBottom only reacts to the mouse wheel and touch, not to the keyboard or to
* dragging the scrollbar, so look at the scroll position instead.
*/
const PagedMenuList = (
props: MenuListProps<DropDownOption<string>, boolean, GroupBase<DropDownOption<string>>>,
) => {
const { t } = useTranslation();
// onNearBottom is passed to the select by us, and react-select hands all props on to its components
const { onNearBottom, isLoading } = props.selectProps as typeof props.selectProps & { onNearBottom?: () => void };
const { innerRef } = props;
const element = useRef<HTMLDivElement | null>(null);

// react-select needs the list element too, so pass it on
const setRefs = useCallback((node: HTMLDivElement | null) => {
element.current = node;
if (typeof innerRef === "function") {
innerRef(node);
} else if (innerRef) {
innerRef.current = node;
}
}, [innerRef]);

const checkIfNearBottom = (list: HTMLDivElement | null) => {
if (list && list.scrollHeight - list.clientHeight - list.scrollTop <= NEAR_BOTTOM_PX) {
onNearBottom?.();
}
};

// A list that is not long enough to scroll never gets scrolled, so look again whenever it changes
useEffect(() => {
checkIfNearBottom(element.current);
});

return (
<SelectComponents.MenuList
{...props}
innerRef={setRefs}
innerProps={{ ...props.innerProps, onScroll: event => checkIfNearBottom(event.currentTarget) }}
>
{props.children}
{isLoading && props.options.length > 0 &&
<div style={{ padding: "2px 8px", textAlign: "left" }}>
{t("SELECT_LOADING")}
</div>
}
</SelectComponents.MenuList>
);
};

/**
* The page of options that is being asked for. Everything that is loaded follows from this.
*/
type Request = {
query: string
offset: number
}

/**
* What was loaded in answer to a request. The options are those of all pages loaded so far for the query.
*/
type Loaded = Request & {
options: DropDownOption<string>[]
nextOffset: number | undefined
}

/**
* A dropdown that loads its options from the server, a page at a time, as the user scrolls down the list or
* searches. The order of the options is the order in which the server returned them.
*
* Intended for lists that are too long to fetch at once. Other dropdowns should use DropDown.
*/
const PagedDropDown = ({
value,
text,
fetchPage,
handleChange,
placeholder,
tabIndex = 0,
creatable = false,
disabled = false,
customCSS,
}: {
value: string
text: string
// Fetches the page of options that starts at the given offset, for the given search text.
fetchPage: (inputValue: string, offset: number) => Promise<DropDownPage>
handleChange: (option: {value: string, label: string} | null) => void
placeholder: string
tabIndex?: number
creatable?: boolean
disabled?: boolean
customCSS?: Parameters<typeof dropDownStyle>[0]
}) => {
const { t } = useTranslation();

// Always call the latest fetchPage, without having to start over whenever the caller passes a new function
const fetchPageRef = useRef(fetchPage);
useEffect(() => {
fetchPageRef.current = fetchPage;
}, [fetchPage]);

const [menuIsOpen, setMenuIsOpen] = useState(false);
// What the user typed, and what is currently being asked for (which follows the input after a pause)
const [inputValue, setInputValue] = useState("");
const [request, setRequest] = useState<Request>({ query: "", offset: 0 });
const [loaded, setLoaded] = useState<Loaded | undefined>(undefined);

const isLoading = menuIsOpen
&& (loaded === undefined || loaded.query !== request.query || loaded.offset !== request.offset);

// Search for what was typed, once the user pauses
useEffect(() => {
if (!menuIsOpen) {
return;
}
const timeout = setTimeout(() => {
setRequest(current => current.query === inputValue ? current : { query: inputValue, offset: 0 });
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timeout);
}, [inputValue, menuIsOpen]);

// Load what was asked for. If the request changes before the answer arrives, the answer is not wanted any more.
useEffect(() => {
if (!menuIsOpen) {
return;
}

let isOutdated = false;
const load = async () => {
let options: DropDownOption<string>[] = [];
let nextOffset: number | undefined = request.offset;
try {
for (let i = 0; i < MAX_CONSECUTIVE_EMPTY_PAGES && nextOffset !== undefined; i++) {
const page = await fetchPageRef.current(request.query, nextOffset);
options = options.concat(page.options);
nextOffset = page.nextOffset;
if (isOutdated || options.length > 0) {
break;
}
}
} catch {
// Show what there is, and do not try again
nextOffset = undefined;
}
if (isOutdated) {
return;
}

setLoaded(previous => {
if (previous === undefined || previous.query !== request.query || request.offset === 0) {
return { ...request, options, nextOffset };
}
const known = new Set(previous.options.map(option => option.value));
return {
...request,
options: previous.options.concat(options.filter(option => !known.has(option.value))),
nextOffset,
};
});
};
void load();

return () => {
isOutdated = true;
};
}, [menuIsOpen, request]);

const onMenuOpen = () => {
// Fetch lazily: there can be a lot of these dropdowns on one page, so do not fetch before one is opened.
setLoaded(undefined);
setRequest({ query: "", offset: 0 });
setMenuIsOpen(true);
};

const onInputChange = (newValue: string, meta: { action: string }) => {
// The input is reset when the menu closes, or an option is chosen
setInputValue(meta.action === "input-change" ? newValue : "");
};

const onNearBottom = () => {
if (!isLoading && loaded?.nextOffset !== undefined) {
setRequest({ query: loaded.query, offset: loaded.nextOffset });
}
};

const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// Moving down from the last option wraps around to the first one. Do not do that while the list is still
// being filled, so that the user ends up at the new options instead.
if (event.key === "ArrowDown" && isLoading) {
event.preventDefault();
}
};

/**
* Wrapper that adds the title attribute to options, which should result
* in a native tooltip that is intended to help with very long labels.
*/
const OptionWithTitle = useCallback((
props: OptionProps<DropDownOption<string>, boolean, GroupBase<DropDownOption<string>>>,
) => (
<SelectComponents.Option {...props} innerProps={{ ...props.innerProps, title: props.data.label }} />
), []);

/**
* Same wrapper as above, but for the input field.
*/
const ValueContainerWithTitle = useCallback((
props: ValueContainerProps<DropDownOption<string>, boolean, GroupBase<DropDownOption<string>>>,
) => (
<SelectComponents.ValueContainer
{...props}
innerProps={{ ...props.innerProps, title: props.getValue()[0]?.label }}
/>
), []);

const commonProps = {
tabIndex: tabIndex,
theme: dropDownSpacingTheme,
styles: dropDownStyle<string>(customCSS ?? {}),
isSearchable: true,
value: { value: value, label: text === "" ? placeholder : text },
options: loaded?.options ?? [],
// The server does the searching
filterOption: null,
inputValue: inputValue,
onInputChange: onInputChange,
isLoading: isLoading,
placeholder: placeholder,
onChange: (element: unknown) => handleChange(element as { value: string, label: string } | null),
menuIsOpen: menuIsOpen,
onMenuOpen: onMenuOpen,
onMenuClose: () => setMenuIsOpen(false),
onNearBottom: onNearBottom,
onKeyDown: onKeyDown,
isDisabled: disabled,
menuPlacement: "auto" as const,
components: {
MenuList: PagedMenuList,
Option: OptionWithTitle,
ValueContainer: ValueContainerWithTitle,
},
noOptionsMessage: () => t("SELECT_NO_MATCHING_RESULTS"),
loadingMessage: () => t("SELECT_LOADING"),
};

return creatable ? (
<CreatableSelect {...commonProps} />
) : (
<Select {...commonProps} />
);
};

export default PagedDropDown;
Loading
Loading