diff --git a/cspell.json b/cspell.json
index 1ff07bf80063..8d5d275d003e 100644
--- a/cspell.json
+++ b/cspell.json
@@ -26,6 +26,7 @@
"Rewst",
"Sherweb",
"Syncro",
+ "TERRL",
"Yubikey"
],
"ignoreWords": [
diff --git a/package.json b/package.json
index 934681339eba..51a2591ae67b 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,7 @@
"react-redux": "9.2.0",
"react-syntax-highlighter": "^15.6.1",
"react-time-ago": "^7.3.3",
+ "react-virtuoso": "^4.12.8",
"react-window": "^1.8.10",
"redux": "5.0.1",
"redux-devtools-extension": "2.13.9",
diff --git a/public/version.json b/public/version.json
index d18f79dee972..f47e65940e3b 100644
--- a/public/version.json
+++ b/public/version.json
@@ -1,3 +1,3 @@
{
- "version": "8.0.1"
-}
+ "version": "8.0.2"
+}
\ No newline at end of file
diff --git a/src/components/CippCards/CippBannerListCard.jsx b/src/components/CippCards/CippBannerListCard.jsx
index 32e8aee05e8d..8a123e5064b2 100644
--- a/src/components/CippCards/CippBannerListCard.jsx
+++ b/src/components/CippCards/CippBannerListCard.jsx
@@ -39,8 +39,8 @@ export const CippBannerListCard = (props) => {
-
+
@@ -74,7 +74,16 @@ export const CippBannerListCard = (props) => {
direction="row"
flexWrap="wrap"
justifyContent="space-between"
- sx={{ p: 3 }}
+ sx={{
+ p: 3,
+ ...(isCollapsible && {
+ cursor: "pointer",
+ "&:hover": {
+ bgcolor: "action.hover",
+ },
+ }),
+ }}
+ onClick={isCollapsible ? () => handleExpand(item.id) : undefined}
>
{/* Left Side: cardLabelBox */}
@@ -127,8 +136,16 @@ export const CippBannerListCard = (props) => {
{item.statusText}
)}
+ {item?.cardLabelBoxActions && (
+ e.stopPropagation()}>{item.cardLabelBoxActions}
+ )}
{isCollapsible && (
- handleExpand(item.id)}>
+ {
+ e.stopPropagation();
+ handleExpand(item.id);
+ }}
+ >
{
+ const [newAlias, setNewAlias] = useState("");
+
+ // Initialize the form field if it doesn't exist
+ useEffect(() => {
+ // Set default empty array if AddedAliases doesn't exist in the form
+ if (!formHook.getValues("AddedAliases")) {
+ formHook.setValue("AddedAliases", []);
+ }
+ }, [formHook]);
+
+ // Use useWatch to subscribe to form field changes
+ const aliasList = useWatch({
+ control: formHook.control,
+ name: "AddedAliases",
+ defaultValue: [],
+ });
+
+ const isPending = formHook.formState.isSubmitting;
+
+ const handleAddAlias = () => {
+ if (newAlias.trim()) {
+ const currentAliases = formHook.getValues("AddedAliases") || [];
+ const newList = [...currentAliases, newAlias.trim()];
+ formHook.setValue("AddedAliases", newList, { shouldValidate: true });
+ setNewAlias("");
+ }
+ };
+
+ const handleDeleteAlias = (aliasToDelete) => {
+ const currentAliases = formHook.getValues("AddedAliases") || [];
+ const updatedList = currentAliases.filter((alias) => alias !== aliasToDelete);
+ formHook.setValue("AddedAliases", updatedList, { shouldValidate: true });
+ };
+
+ const handleKeyPress = (event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ handleAddAlias();
+ }
+ };
+
+ return (
+ <>
+
+
+ Add proxy addresses (aliases) for this user. Enter each alias and click Add or press
+ Enter.
+
+
+ setNewAlias(e.target.value)}
+ onKeyPress={handleKeyPress}
+ placeholder="Enter an alias"
+ variant="outlined"
+ disabled={isPending}
+ size="small"
+ sx={{
+ "& .MuiOutlinedInput-root": {
+ fontFamily: "monospace",
+ "& .MuiOutlinedInput-input": {
+ px: 2,
+ },
+ },
+ }}
+ />
+ }
+ size="small"
+ >
+ Add
+
+
+
+ {aliasList.length === 0 ? (
+
+ No aliases added yet
+
+ ) : (
+ aliasList.map((alias) => (
+ handleDeleteAlias(alias)}
+ color="primary"
+ variant="outlined"
+ />
+ ))
+ )}
+
+
+ >
+ );
+};
+
+export default CippAliasDialog;
diff --git a/src/components/CippComponents/CippApiDialog.jsx b/src/components/CippComponents/CippApiDialog.jsx
index 6ec2daaad67b..001d00d167b0 100644
--- a/src/components/CippComponents/CippApiDialog.jsx
+++ b/src/components/CippComponents/CippApiDialog.jsx
@@ -1,5 +1,6 @@
import { useRouter } from "next/router";
import {
+ Box,
Button,
Dialog,
DialogActions,
@@ -7,11 +8,11 @@ import {
DialogTitle,
useMediaQuery,
} from "@mui/material";
-import { Stack, Grid } from "@mui/system";
+import { Stack } from "@mui/system";
import { CippApiResults } from "./CippApiResults";
import { ApiGetCall, ApiPostCall } from "../../api/ApiCall";
import React, { useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
+import { useForm, useWatch } from "react-hook-form";
import { useSettings } from "../../hooks/use-settings";
import CippFormComponent from "./CippFormComponent";
@@ -25,6 +26,7 @@ export const CippApiDialog = (props) => {
relatedQueryKeys,
dialogAfterEffect,
allowResubmit = false,
+ children,
...other
} = props;
const router = useRouter();
@@ -36,7 +38,6 @@ export const CippApiDialog = (props) => {
if (mdDown) {
other.fullScreen = true;
}
-
useEffect(() => {
if (createDialog.open) {
setIsFormSubmitted(false);
@@ -74,13 +75,17 @@ export const CippApiDialog = (props) => {
});
const processActionData = (dataObject, row, replacementBehaviour) => {
- if (typeof api?.dataFunction === "function") return api.dataFunction(row);
+ if (typeof api?.dataFunction === "function") return api.dataFunction(row, dataObject);
let newData = {};
if (api?.postEntireRow) {
return row;
}
+ if (!dataObject) {
+ return dataObject;
+ }
+
Object.keys(dataObject).forEach((key) => {
const value = dataObject[key];
@@ -106,57 +111,65 @@ export const CippApiDialog = (props) => {
const tenantFilter = useSettings().currentTenant;
const handleActionClick = (row, action, formData) => {
setIsFormSubmitted(true);
- if (action.multiPost === undefined) action.multiPost = false;
+ let finalData = {};
+ if (typeof api?.customDataformatter === "function") {
+ finalData = api.customDataformatter(row, action, formData);
+ } else {
+ if (action.multiPost === undefined) action.multiPost = false;
- if (api.customFunction) {
- action.customFunction(row, action, formData);
- createDialog.handleClose();
- return;
- }
+ if (api.customFunction) {
+ action.customFunction(row, action, formData);
+ createDialog.handleClose();
+ return;
+ }
- const commonData = {
- tenantFilter,
- ...formData,
- ...addedFieldData,
- };
- const processedActionData = processActionData(action.data, row, action.replacementBehaviour);
+ const commonData = {
+ tenantFilter,
+ ...formData,
+ ...addedFieldData,
+ };
+ const processedActionData = processActionData(action.data, row, action.replacementBehaviour);
- // MULTI ROW CASES
- if (Array.isArray(row)) {
- const arrayData = row.map((singleRow) => {
- const itemData = { ...commonData };
- Object.keys(processedActionData).forEach((key) => {
- const rowValue = singleRow[processedActionData[key]];
- itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key];
- });
- return itemData;
- });
+ if (!processedActionData || Object.keys(processedActionData).length === 0) {
+ console.warn("No data to process for action:", action);
+ } else {
+ // MULTI ROW CASES
+ if (Array.isArray(row)) {
+ const arrayData = row.map((singleRow) => {
+ const itemData = { ...commonData };
+ Object.keys(processedActionData).forEach((key) => {
+ const rowValue = singleRow[processedActionData[key]];
+ itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key];
+ });
+ return itemData;
+ });
- const payload = {
- url: action.url,
- bulkRequest: !action.multiPost,
- data: arrayData,
- };
+ const payload = {
+ url: action.url,
+ bulkRequest: !action.multiPost,
+ data: arrayData,
+ };
- if (action.type === "POST") {
- actionPostRequest.mutate(payload);
- } else if (action.type === "GET") {
- setGetRequestInfo({
- ...payload,
- waiting: true,
- queryKey: Date.now(),
- });
- }
+ if (action.type === "POST") {
+ actionPostRequest.mutate(payload);
+ } else if (action.type === "GET") {
+ setGetRequestInfo({
+ ...payload,
+ waiting: true,
+ queryKey: Date.now(),
+ });
+ }
- return;
+ return;
+ }
+ }
+ // ✅ FIXED: DIRECT MERGE INSTEAD OF CORRUPT TRANSFORMATION
+ finalData = {
+ ...commonData,
+ ...processedActionData,
+ };
}
- // ✅ FIXED: DIRECT MERGE INSTEAD OF CORRUPT TRANSFORMATION
- const finalData = {
- ...commonData,
- ...processedActionData,
- };
-
if (action.type === "POST") {
actionPostRequest.mutate({
url: action.url,
@@ -303,18 +316,31 @@ export const CippApiDialog = (props) => {
{confirmText}
-
- {fields?.map((fieldProps, i) => (
-
-
-
- ))}
-
+
+ {children ? (
+ typeof children === "function" ? (
+ children({
+ formHook,
+ row,
+ })
+ ) : (
+ children
+ )
+ ) : (
+ <>
+ {fields?.map((fieldProps, i) => (
+
+
+
+ ))}
+ >
+ )}
+
diff --git a/src/components/CippComponents/CippApiResults.jsx b/src/components/CippComponents/CippApiResults.jsx
index edf5826eccba..3a9dd724b6f1 100644
--- a/src/components/CippComponents/CippApiResults.jsx
+++ b/src/components/CippComponents/CippApiResults.jsx
@@ -1,4 +1,4 @@
-import { Close, Download } from "@mui/icons-material";
+import { Close, Download, Help } from "@mui/icons-material";
import {
Alert,
CircularProgress,
@@ -9,10 +9,13 @@ import {
Box,
SvgIcon,
Tooltip,
+ Button,
+ keyframes,
} from "@mui/material";
import { useEffect, useState, useMemo, useCallback } from "react";
import { getCippError } from "../../utils/get-cipp-error";
import { CippCopyToClipBoard } from "./CippCopyToClipboard";
+import { CippDocsLookup } from "./CippDocsLookup";
import React from "react";
import { CippTableDialog } from "./CippTableDialog";
import { EyeIcon } from "@heroicons/react/24/outline";
@@ -275,7 +278,38 @@ export const CippApiResults = (props) => {
severity={resultObj.severity || "success"}
action={
<>
+ {resultObj.severity === "error" && (
+ }
+ onClick={() => {
+ const searchUrl = `https://docs.cipp.app/?q=Help+with:+${encodeURIComponent(
+ resultObj.copyField || resultObj.text
+ )}&ask=true`;
+ window.open(searchUrl, "_blank");
+ }}
+ sx={{
+ ml: 1,
+ mr: 1,
+ backgroundColor: "white",
+ color: "error.main",
+ "&:hover": {
+ backgroundColor: "grey.100",
+ },
+ py: 0.5,
+ px: 1,
+ minWidth: "auto",
+ fontSize: "0.875rem",
+ whiteSpace: "nowrap",
+ }}
+ >
+ Get Help
+
+ )}
+
{
+ const permissionLevel = useWatch({
+ control: formHook.control,
+ name: "Permissions",
+ });
+
+ const userSettingsDefaults = useSettings();
+
+ const usersList = ApiGetCall({
+ url: "/api/ListGraphRequest",
+ data: {
+ Endpoint: `users`,
+ tenantFilter: userSettingsDefaults.currentTenant,
+ $select: "id,displayName,userPrincipalName,mail",
+ noPagination: true,
+ $top: 999,
+ },
+ queryKey: `UserNames-${userSettingsDefaults.currentTenant}`,
+ });
+
+ const isEditor = permissionLevel?.value === "Editor";
+
+ useEffect(() => {
+ if (!isEditor) {
+ formHook.setValue("CanViewPrivateItems", false);
+ }
+ }, [isEditor, formHook]);
+
+ return (
+
+
+ ({
+ value: user.userPrincipalName,
+ label: `${user.displayName} (${user.userPrincipalName})`,
+ })) || []
+ }
+ required={true}
+ validators={{
+ validate: (value) => (value ? true : "Select a user to assign permissions to"),
+ }}
+ placeholder="Select a user to assign permissions to"
+ />
+
+
+ (value ? true : "Select the permission level for the calendar"),
+ }}
+ options={[
+ { value: "Author", label: "Author" },
+ { value: "Contributor", label: "Contributor" },
+ { value: "Editor", label: "Editor" },
+ { value: "Owner", label: "Owner" },
+ { value: "NonEditingAuthor", label: "Non Editing Author" },
+ { value: "PublishingAuthor", label: "Publishing Author" },
+ { value: "PublishingEditor", label: "Publishing Editor" },
+ { value: "Reviewer", label: "Reviewer" },
+ { value: "LimitedDetails", label: "Limited Details" },
+ { value: "AvailabilityOnly", label: "Availability Only" },
+ ]}
+ multiple={false}
+ formControl={formHook}
+ />
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CippCalendarPermissionsDialog;
diff --git a/src/components/CippComponents/CippDocsLookup.jsx b/src/components/CippComponents/CippDocsLookup.jsx
new file mode 100644
index 000000000000..987809b24f2c
--- /dev/null
+++ b/src/components/CippComponents/CippDocsLookup.jsx
@@ -0,0 +1,71 @@
+import { Search } from "@mui/icons-material";
+import { Chip, IconButton, SvgIcon, Tooltip } from "@mui/material";
+import { useState } from "react";
+
+export const CippDocsLookup = (props) => {
+ const { text, type = "button", visible = true, ...other } = props;
+ const [showPassword, setShowPassword] = useState(false);
+
+ const handleTogglePassword = () => {
+ setShowPassword((prev) => !prev);
+ };
+
+ const handleDocsLookup = () => {
+ const searchUrl = `https://docs.cipp.app/?q=Help+with:+${encodeURIComponent(text)}&ask=true`;
+ window.open(searchUrl, '_blank');
+ };
+
+ if (!visible) return null;
+
+ if (type === "button") {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ if (type === "chip") {
+ return (
+
+
+
+ );
+ }
+
+ if (type === "password") {
+ return (
+ <>
+
+
+ {showPassword ? : }
+
+
+
+
+
+ >
+ );
+ }
+
+ return null;
+};
\ No newline at end of file
diff --git a/src/components/CippComponents/CippMailboxPermissionsDialog.jsx b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx
new file mode 100644
index 000000000000..52b2f3cb7372
--- /dev/null
+++ b/src/components/CippComponents/CippMailboxPermissionsDialog.jsx
@@ -0,0 +1,87 @@
+import { Box, Stack } from "@mui/material";
+import CippFormComponent from "./CippFormComponent";
+import { useWatch } from "react-hook-form";
+import { ApiGetCall } from "../../api/ApiCall";
+import { useSettings } from "../../hooks/use-settings";
+
+const CippMailboxPermissionsDialog = ({ formHook }) => {
+ const fullAccess = useWatch({
+ control: formHook.control,
+ name: "permissions.AddFullAccess",
+ });
+
+ const userSettingsDefaults = useSettings();
+
+ const usersList = ApiGetCall({
+ url: "/api/ListGraphRequest",
+ data: {
+ Endpoint: `users`,
+ tenantFilter: userSettingsDefaults.currentTenant,
+ $select: "id,displayName,userPrincipalName,mail",
+ noPagination: true,
+ $top: 999,
+ },
+ queryKey: `UserNames-${userSettingsDefaults.currentTenant}`,
+ });
+
+ return (
+
+
+ ({
+ value: user.userPrincipalName,
+ label: `${user.displayName} (${user.userPrincipalName})`,
+ })) || []
+ }
+ />
+ {fullAccess && (
+
+ )}
+
+
+ ({
+ value: user.userPrincipalName,
+ label: `${user.displayName} (${user.userPrincipalName})`,
+ })) || []
+ }
+ />
+
+
+ ({
+ value: user.userPrincipalName,
+ label: `${user.displayName} (${user.userPrincipalName})`,
+ })) || []
+ }
+ />
+
+
+ );
+};
+
+export default CippMailboxPermissionsDialog;
diff --git a/src/components/CippComponents/CippTenantSelector.jsx b/src/components/CippComponents/CippTenantSelector.jsx
index 61d0c32e37fd..cb3db82c1645 100644
--- a/src/components/CippComponents/CippTenantSelector.jsx
+++ b/src/components/CippComponents/CippTenantSelector.jsx
@@ -43,6 +43,7 @@ export const CippTenantSelector = (props) => {
toast: true,
});
+ // This effect handles updates when the tenant is changed via dropdown selection
useEffect(() => {
if (!router.isReady) return;
if (currentTenant?.value) {
@@ -65,14 +66,62 @@ export const CippTenantSelector = (props) => {
}
}, [currentTenant?.value]);
+ // This effect handles when the URL parameter changes externally
+ useEffect(() => {
+ if (!router.isReady || !tenantList.isSuccess) return;
+
+ // Get the current tenant from URL or settings
+ const urlTenant = router.query.tenantFilter || settings.currentTenant;
+
+ // Only update if there's a URL tenant and it's different from our current state
+ if (urlTenant && (!currentTenant || urlTenant !== currentTenant.value)) {
+ // Find the tenant in our list
+ const matchingTenant = tenantList.data.find(
+ ({ defaultDomainName }) => defaultDomainName === urlTenant
+ );
+
+ if (matchingTenant) {
+ setSelectedTenant({
+ value: urlTenant,
+ label: `${matchingTenant.displayName} (${urlTenant})`,
+ addedFields: {
+ defaultDomainName: matchingTenant.defaultDomainName,
+ displayName: matchingTenant.displayName,
+ customerId: matchingTenant.customerId,
+ initialDomainName: matchingTenant.initialDomainName,
+ },
+ });
+ }
+ }
+ }, [router.isReady, router.query.tenantFilter, tenantList.isSuccess, settings.currentTenant]);
+
+ // This effect ensures the tenant filter parameter is included in the URL when missing
+ useEffect(() => {
+ if (!router.isReady || !settings.currentTenant) return;
+
+ // If the tenant parameter is missing from the URL but we have it in settings
+ if (!router.query.tenantFilter && settings.currentTenant) {
+ const query = { ...router.query, tenantFilter: settings.currentTenant };
+ router.replace(
+ {
+ pathname: router.pathname,
+ query: query,
+ },
+ undefined,
+ { shallow: true }
+ );
+ }
+ }, [router.isReady, router.query, settings.currentTenant]);
+
useEffect(() => {
if (tenant && currentTenant?.value && currentTenant?.value !== "AllTenants") {
tenantDetails.refetch();
}
}, [tenant, offcanvasVisible]);
+ // We can simplify this effect since we now have the new effect above to handle URL changes
useEffect(() => {
- if (tenant && tenantList.isSuccess) {
+ if (tenant && tenantList.isSuccess && !currentTenant) {
const matchingTenant = tenantList.data.find(
({ defaultDomainName }) => defaultDomainName === tenant
);
@@ -94,7 +143,8 @@ export const CippTenantSelector = (props) => {
}
);
}
- }, [tenant, tenantList.isSuccess]);
+ }, [tenant, tenantList.isSuccess, currentTenant]);
+
return (
<>
{
const userSettingsDefaults = useSettings();
@@ -28,6 +29,31 @@ const CippExchangeSettingsForm = (props) => {
const [expandedPanel, setExpandedPanel] = useState(null);
const [relatedQueryKeys, setRelatedQueryKeys] = useState([]);
+ // Watch the Auto Reply State value
+ const autoReplyState = useWatch({
+ control: formControl.control,
+ name: "ooo.AutoReplyState",
+ });
+
+ // Calculate if date fields should be disabled
+ const areDateFieldsDisabled = autoReplyState?.value !== "Scheduled";
+
+ useEffect(() => {
+ console.log('Auto Reply State changed:', {
+ autoReplyState,
+ areDateFieldsDisabled,
+ fullFormValues: formControl.getValues()
+ });
+ }, [autoReplyState]);
+
+ // Add debug logging for form values
+ useEffect(() => {
+ const subscription = formControl.watch((value, { name, type }) => {
+ console.log('Form value changed:', { name, type, value });
+ });
+ return () => subscription.unsubscribe();
+ }, [formControl]);
+
const handleExpand = (panel) => {
setExpandedPanel((prev) => (prev === panel ? null : panel));
};
@@ -50,9 +76,7 @@ const CippExchangeSettingsForm = (props) => {
});
const handleSubmit = (type) => {
- if (type === "permissions") {
- setRelatedQueryKeys([`Mailbox-${userId}`]);
- } else if (type === "calendar") {
+ if (type === "calendar") {
setRelatedQueryKeys([`CalendarPermissions-${userId}`]);
} else if (type === "forwarding") {
setRelatedQueryKeys([`Mailbox-${userId}`]);
@@ -83,7 +107,6 @@ const CippExchangeSettingsForm = (props) => {
}
});
const url = {
- permissions: "/api/ExecEditMailboxPermissions",
calendar: "/api/ExecEditCalendarPermissions",
forwarding: "/api/ExecEmailForward",
ooo: "/api/ExecSetOoO",
@@ -101,308 +124,6 @@ const CippExchangeSettingsForm = (props) => {
// Data for each section
const sections = [
- {
- id: "mailboxPermissions",
- cardLabelBox: "-",
- text: "Mailbox Permissions",
- subtext: "Manage mailbox permissions for users",
- formContent: (
-
- {/* Full Access Section */}
-
- Full Access
-
- Manage who has full access to this mailbox
-
-
-
- currentSettings?.Permissions?.some(
- (perm) =>
- perm.AccessRights === "FullAccess" && perm.User === user.userPrincipalName
- )
- ).map((user) => ({
- value: user.userPrincipalName,
- label: `${user.displayName} (${user.userPrincipalName})`,
- })) || []
- }
- formControl={formControl}
- />
- ({
- value: user.userPrincipalName,
- label: `${user.displayName} (${user.userPrincipalName})`,
- })) || []
- }
- formControl={formControl}
- />
- ({
- value: user.userPrincipalName,
- label: `${user.displayName} (${user.userPrincipalName})`,
- })) || []
- }
- formControl={formControl}
- />
-
-
-
- {/* Send As Section */}
-
- Send As
-
- Manage who can send emails as this user
-
-
-
- currentSettings?.Permissions?.some(
- (perm) => perm.AccessRights === "SendAs" && perm.User === user.userPrincipalName
- )
- ).map((user) => ({
- value: user.userPrincipalName,
- label: `${user.displayName} (${user.userPrincipalName})`,
- })) || []
- }
- formControl={formControl}
- />
- ({
- value: user.userPrincipalName,
- label: `${user.displayName} (${user.userPrincipalName})`,
- })) || []
- }
- formControl={formControl}
- />
-
-
-
- {/* Send On Behalf Section */}
-
- Send On Behalf
-
- Manage who can send emails on behalf of this user
-
-
-
- currentSettings?.Permissions?.some(
- (perm) =>
- perm.AccessRights === "SendOnBehalf" && perm.User === user.userPrincipalName
- )
- ).map((user) => ({
- value: user.userPrincipalName,
- label: `${user.displayName} (${user.userPrincipalName})`,
- })) || []
- }
- formControl={formControl}
- />
- ({
- value: user.userPrincipalName,
- label: `${user.displayName} (${user.userPrincipalName})`,
- })) || []
- }
- formControl={formControl}
- />
-
-
-
-
-
-
-
-
-
-
- ),
- },
- {
- id: "calendarPermissions",
- cardLabelBox: "-",
- text: "Calendar Permissions",
- subtext: "Adjust calendar sharing settings",
- formContent: (
-
-
- calPermissions?.some((perm) => perm.User === user.displayName)
- ).map((user) => ({
- value: user.userPrincipalName,
- label: `${user.displayName} (${user.userPrincipalName})`,
- })) || []
- }
- formControl={formControl}
- />
- ({
- value: user.userPrincipalName,
- label: `${user.displayName} (${user.userPrincipalName})`,
- })) || []),
- ]}
- multiple={false}
- formControl={formControl}
- />
-
-
-
- value ? true : "Select the permission level for the calendar",
- }}
- isFetching={isFetching || usersList.isFetching}
- options={[
- { value: "Author", label: "Author" },
- { value: "Contributor", label: "Contributor" },
- { value: "Editor", label: "Editor" },
- { value: "Owner", label: "Owner" },
- { value: "NonEditingAuthor", label: "Non Editing Author" },
- { value: "PublishingAuthor", label: "Publishing Author" },
- { value: "PublishingEditor", label: "Publishing Editor" },
- { value: "Reviewer", label: "Reviewer" },
- { value: "LimitedDetails", label: "Limited Details" },
- { value: "AvailabilityOnly", label: "Availability Only" },
- ]}
- multiple={false}
- formControl={formControl}
- />
-
- {(() => {
- const permissionLevel = useWatch({
- control: formControl.control,
- name: "calendar.Permissions"
- });
- const isEditor = permissionLevel?.value === "Editor";
-
- // Use useEffect to handle the switch value reset
- useEffect(() => {
- if (!isEditor) {
- formControl.setValue("calendar.CanViewPrivateItems", false);
- }
- }, [isEditor, formControl]);
-
- return (
-
-
-
-
-
- );
- })()}
-
-
-
-
-
-
-
-
-
-
-
- ),
- },
{
id: "mailboxForwarding",
cardLabelBox: currentSettings?.ForwardAndDeliver ? : "-",
@@ -503,20 +224,36 @@ const CippExchangeSettingsForm = (props) => {
/>
-
+
+
+
+
+
-
+
+
+
+
+
{
alignItems: "center",
display: "flex",
justifyContent: "space-between",
- p: 2,
+ py: 3,
+ pl: 2,
+ pr: 4,
+ cursor: "pointer",
+ "&:hover": {
+ bgcolor: "action.hover",
+ },
}}
+ onClick={() => handleExpand(section.id)}
>
{/* Left Side: cardLabelBox, text, subtext */}
@@ -631,18 +375,15 @@ const CippExchangeSettingsForm = (props) => {
- {/* Expand Icon */}
- handleExpand(section.id)}>
-
-
-
-
+
+
+
diff --git a/src/components/CippStandards/CippStandardAccordion.jsx b/src/components/CippStandards/CippStandardAccordion.jsx
index 4048981e6cd5..c07bc67c27d2 100644
--- a/src/components/CippStandards/CippStandardAccordion.jsx
+++ b/src/components/CippStandards/CippStandardAccordion.jsx
@@ -24,6 +24,9 @@ import {
Search,
Close,
FilterAlt,
+ NotificationImportant,
+ Assignment,
+ Construction,
} from "@mui/icons-material";
import { Grid } from "@mui/system";
import CippFormComponent from "/src/components/CippComponents/CippFormComponent";
@@ -94,81 +97,191 @@ const CippStandardAccordion = ({
const [configuredState, setConfiguredState] = useState({});
const [filter, setFilter] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
+ const [savedValues, setSavedValues] = useState({});
+ const [originalValues, setOriginalValues] = useState({});
const watchedValues = useWatch({
control: formControl.control,
});
+ // Check if a standard is configured based on its values
+ const isStandardConfigured = (standardName, standard, values) => {
+ if (!values) return false;
+
+ // ALWAYS require an action for any standard to be considered configured
+ // The action field should be an array with at least one element
+ const actionValue = _.get(values, "action");
+ if (!actionValue || (Array.isArray(actionValue) && actionValue.length === 0)) return false;
+
+ // Additional checks for required components
+ const hasRequiredComponents =
+ standard.addedComponent &&
+ standard.addedComponent.some((comp) => comp.type !== "switch" && comp.required !== false);
+ const actionRequired = standard.disabledFeatures !== undefined || hasRequiredComponents;
+
+ // Always require an action (should be an array with at least one element)
+ const actionFilled = actionValue && (!Array.isArray(actionValue) || actionValue.length > 0);
+
+ const addedComponentsFilled =
+ standard.addedComponent?.every((component) => {
+ // Always skip switches
+ if (component.type === "switch") return true;
+
+ // Handle conditional fields
+ if (component.condition) {
+ const conditionField = component.condition.field;
+ const conditionValue = _.get(values, conditionField);
+ const compareType = component.condition.compareType || "is";
+ const compareValue = component.condition.compareValue;
+ const propertyName = component.condition.propertyName || "value";
+
+ let conditionMet = false;
+ if (propertyName === "value") {
+ switch (compareType) {
+ case "is":
+ conditionMet = _.isEqual(conditionValue, compareValue);
+ break;
+ case "isNot":
+ conditionMet = !_.isEqual(conditionValue, compareValue);
+ break;
+ default:
+ conditionMet = false;
+ }
+ } else if (Array.isArray(conditionValue)) {
+ switch (compareType) {
+ case "valueEq":
+ conditionMet = conditionValue.some((item) => item?.[propertyName] === compareValue);
+ break;
+ default:
+ conditionMet = false;
+ }
+ }
+
+ // If condition is not met, skip validation for this field
+ if (!conditionMet) return true;
+ }
+
+ // Check if field is required
+ const isRequired = component.required !== false;
+ if (!isRequired) return true;
+
+ // Get field value using lodash's get to properly handle nested properties
+ const fieldValue = _.get(values, component.name);
+
+ // Check if field has a value based on its type and multiple property
+ if (component.type === "autoComplete" || component.type === "select") {
+ if (component.multiple) {
+ // For multiple selection, check if array exists and has items
+ return Array.isArray(fieldValue) && fieldValue.length > 0;
+ } else {
+ // For single selection, check if value exists
+ return !!fieldValue;
+ }
+ }
+
+ // For other field types
+ return !!fieldValue;
+ }) ?? true;
+
+ return actionFilled && addedComponentsFilled;
+ };
+
+ // Initialize when watchedValues are available
useEffect(() => {
- const newConfiguredState = { ...configuredState };
+ // Only run initialization if we have watchedValues and they contain data
+ if (!watchedValues || Object.keys(watchedValues).length === 0) {
+ return;
+ }
- Object.keys(selectedStandards).forEach((standardName) => {
- const standard = providedStandards.find((s) => s.name === standardName.split("[")[0]);
- if (standard) {
- const actionFilled = !!_.get(watchedValues, `${standardName}.action`, false);
-
- const addedComponentsFilled =
- standard.addedComponent?.every((component) => {
- // Skip validation for components with conditions
- if (component.condition) {
- const conditionField = `${standardName}.${component.condition.field}`;
- const conditionValue = _.get(watchedValues, conditionField);
- const compareType = component.condition.compareType || "is";
- const compareValue = component.condition.compareValue;
- const propertyName = component.condition.propertyName || "value";
-
- // Check if condition is met based on the compareType
- let conditionMet = false;
- if (propertyName === "value") {
- switch (compareType) {
- case "is":
- conditionMet = _.isEqual(conditionValue, compareValue);
- break;
- case "isNot":
- conditionMet = !_.isEqual(conditionValue, compareValue);
- break;
- // Add other compareType cases as needed
- default:
- conditionMet = false;
- }
- } else if (Array.isArray(conditionValue)) {
- // Handle array values with propertyName
- switch (compareType) {
- case "valueEq":
- conditionMet = conditionValue.some(
- (item) => item?.[propertyName] === compareValue
- );
- break;
- // Add other compareType cases for arrays as needed
- default:
- conditionMet = false;
- }
- }
-
- // If condition is not met, we don't need to validate this field
- if (!conditionMet) {
- return true;
- }
- }
+ // Prevent re-initialization if we already have configuration state
+ const hasConfigState = Object.keys(configuredState).length > 0;
+ if (hasConfigState) {
+ return;
+ }
- const isRequired = component.required !== false && component.type !== "switch";
- if (!isRequired) return true;
- return !!_.get(watchedValues, `${standardName}.${component.name}`);
- }) ?? true;
+ console.log("Initializing configuration state from template values");
+ const initial = {};
+ const initialConfigured = {};
- const isConfigured = actionFilled && addedComponentsFilled;
+ // For each standard, get its current values and determine if it's configured
+ Object.keys(selectedStandards).forEach((standardName) => {
+ const currentValues = _.get(watchedValues, standardName);
+ if (!currentValues) return;
- if (newConfiguredState[standardName] !== isConfigured) {
- newConfiguredState[standardName] = isConfigured;
- }
+ initial[standardName] = _.cloneDeep(currentValues);
+
+ const baseStandardName = standardName.split("[")[0];
+ const standard = providedStandards.find((s) => s.name === baseStandardName);
+ if (standard) {
+ initialConfigured[standardName] = isStandardConfigured(
+ standardName,
+ standard,
+ currentValues
+ );
}
});
- if (!_.isEqual(newConfiguredState, configuredState)) {
- setConfiguredState(newConfiguredState);
+ // Store both the initial values and set them as current saved values
+ setOriginalValues(initial);
+ setSavedValues(initial);
+ setConfiguredState(initialConfigured);
+ // Only depend on watchedValues and selectedStandards to avoid infinite loops
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [watchedValues, selectedStandards]);
+
+ // Save changes for a standard
+ const handleSave = (standardName, standard, current) => {
+ // Clone the current values to avoid reference issues
+ const newValues = _.cloneDeep(current);
+
+ // Update saved values
+ setSavedValues((prev) => ({
+ ...prev,
+ [standardName]: newValues,
+ }));
+
+ // Update configured state right away
+ const isConfigured = isStandardConfigured(standardName, standard, newValues);
+ console.log(`Saving standard ${standardName}, configured: ${isConfigured}`);
+
+ setConfiguredState((prev) => ({
+ ...prev,
+ [standardName]: isConfigured,
+ }));
+
+ // Collapse the accordion after saving
+ handleAccordionToggle(null);
+ };
+
+ // Cancel changes for a standard
+ const handleCancel = (standardName) => {
+ // Get the last saved values
+ const savedValue = _.get(savedValues, standardName);
+ if (!savedValue) return;
+
+ // Set the entire standard's value at once to ensure proper handling of nested objects and arrays
+ formControl.setValue(standardName, _.cloneDeep(savedValue));
+
+ // Find the original standard definition to get the base standard
+ const baseStandardName = standardName.split("[")[0];
+ const standard = providedStandards.find((s) => s.name === baseStandardName);
+
+ // Determine if the standard was configured with saved values
+ if (standard) {
+ const isConfigured = isStandardConfigured(standardName, standard, savedValue);
+
+ // Restore the previous configuration state
+ setConfiguredState((prev) => ({
+ ...prev,
+ [standardName]: isConfigured,
+ }));
}
- }, [watchedValues, providedStandards, selectedStandards]);
+ // Collapse the accordion after canceling
+ handleAccordionToggle(null);
+ };
+
+ // Group standards by category
const groupedStandards = useMemo(() => {
const result = {};
@@ -197,6 +310,7 @@ const CippStandardAccordion = ({
return result;
}, [selectedStandards, providedStandards]);
+ // Filter standards based on search and filter selection
const filteredGroupedStandards = useMemo(() => {
if (!searchQuery && filter === "all") {
return groupedStandards;
@@ -209,6 +323,11 @@ const CippStandardAccordion = ({
const categoryMatchesSearch = !searchQuery || category.toLowerCase().includes(searchLower);
const filteredStandards = groupedStandards[category].filter(({ standardName, standard }) => {
+ // If this is the currently expanded standard, always include it in the result
+ if (standardName === expanded) {
+ return true;
+ }
+
const matchesSearch =
!searchQuery ||
categoryMatchesSearch ||
@@ -219,7 +338,7 @@ const CippStandardAccordion = ({
Array.isArray(standard.tag) &&
standard.tag.some((tag) => tag.toLowerCase().includes(searchLower)));
- const isConfigured = configuredState[standardName];
+ const isConfigured = _.get(configuredState, standardName);
const matchesFilter =
filter === "all" ||
(filter === "configured" && isConfigured) ||
@@ -236,6 +355,7 @@ const CippStandardAccordion = ({
return result;
}, [groupedStandards, searchQuery, filter, configuredState]);
+ // Count standards by configuration state
const standardCounts = useMemo(() => {
let allCount = 0;
let configuredCount = 0;
@@ -278,7 +398,13 @@ const CippStandardAccordion = ({
sx={{ width: { xs: "100%", sm: 350 } }}
placeholder="Search..."
value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
+ onChange={(e) => {
+ // Close any expanded accordion when changing search query
+ if (expanded && e.target.value !== searchQuery) {
+ handleAccordionToggle(null);
+ }
+ setSearchQuery(e.target.value);
+ }}
slotProps={{
input: {
startAdornment: (
@@ -291,7 +417,13 @@ const CippStandardAccordion = ({
setSearchQuery("")}
+ onClick={() => {
+ // Close any expanded accordion when clearing search
+ if (expanded) {
+ handleAccordionToggle(null);
+ }
+ setSearchQuery("");
+ }}
aria-label="Clear search"
>
@@ -311,19 +443,37 @@ const CippStandardAccordion = ({
@@ -350,7 +500,7 @@ const CippStandardAccordion = ({
const isExpanded = expanded === standardName;
const hasAddedComponents =
standard.addedComponent && standard.addedComponent.length > 0;
- const isConfigured = configuredState[standardName];
+ const isConfigured = _.get(configuredState, standardName);
const disabledFeatures = standard.disabledFeatures || {};
let selectedActions = _.get(watchedValues, `${standardName}.action`);
@@ -361,9 +511,106 @@ const CippStandardAccordion = ({
const selectedTemplateName = standard.multiple
? _.get(watchedValues, `${standardName}.${standard.addedComponent?.[0]?.name}`)
: "";
- const accordionTitle = selectedTemplateName
- ? `${standard.label} - ${selectedTemplateName.label}`
- : standard.label;
+ const accordionTitle =
+ selectedTemplateName && _.get(selectedTemplateName, "label")
+ ? `${standard.label} - ${_.get(selectedTemplateName, "label")}`
+ : standard.label;
+
+ // Get current values and check if they differ from saved values
+ const current = _.get(watchedValues, standardName);
+ const saved = _.get(savedValues, standardName) || {};
+ const hasUnsaved = !_.isEqual(current, saved);
+
+ // Check if all required fields are filled
+ const requiredFieldsFilled = current
+ ? standard.addedComponent?.every((component) => {
+ // Always skip switches regardless of their required property
+ if (component.type === "switch") return true;
+
+ // Skip optional fields (not required)
+ const isRequired = component.required !== false;
+ if (!isRequired) return true;
+
+ // Handle conditional fields
+ if (component.condition) {
+ const conditionField = component.condition.field;
+ const conditionValue = _.get(current, conditionField);
+ const compareType = component.condition.compareType || "is";
+ const compareValue = component.condition.compareValue;
+ const propertyName = component.condition.propertyName || "value";
+
+ let conditionMet = false;
+ if (propertyName === "value") {
+ switch (compareType) {
+ case "is":
+ conditionMet = _.isEqual(conditionValue, compareValue);
+ break;
+ case "isNot":
+ conditionMet = !_.isEqual(conditionValue, compareValue);
+ break;
+ default:
+ conditionMet = false;
+ }
+ } else if (Array.isArray(conditionValue)) {
+ switch (compareType) {
+ case "valueEq":
+ conditionMet = conditionValue.some(
+ (item) => item?.[propertyName] === compareValue
+ );
+ break;
+ default:
+ conditionMet = false;
+ }
+ }
+
+ // If condition is not met, skip validation
+ if (!conditionMet) return true;
+ }
+
+ // Get field value for validation using lodash's get to properly handle nested properties
+ const fieldValue = _.get(current, component.name);
+ console.log(`Checking field: ${component.name}, value:`, fieldValue);
+ console.log(current);
+ // Check if required field has a value based on its type and multiple property
+ if (component.type === "autoComplete" || component.type === "select") {
+ if (component.multiple) {
+ // For multiple selection, check if array exists and has items
+ return Array.isArray(fieldValue) && fieldValue.length > 0;
+ } else {
+ // For single selection, check if value exists
+ return !!fieldValue;
+ }
+ }
+
+ // For other field types
+ return !!fieldValue;
+ }) ?? true
+ : false;
+
+ // ALWAYS require an action for all standards
+ const actionRequired = true;
+
+ // Check if there are required non-switch components for UI display purposes
+ const hasRequiredComponents =
+ standard.addedComponent &&
+ standard.addedComponent.some(
+ (comp) => comp.type !== "switch" && comp.required !== false
+ );
+
+ // Action is always required and must be an array with at least one element
+ const actionValue = _.get(current, "action");
+ const hasAction =
+ actionValue && (!Array.isArray(actionValue) || actionValue.length > 0);
+
+ // Allow saving if:
+ // 1. Action is selected if required
+ // 2. All required fields are filled
+ // 3. There are unsaved changes
+ const canSave = hasAction && requiredFieldsFilled && hasUnsaved;
+
+ console.log(
+ `Standard: ${standardName}, Action Required: ${actionRequired}, Has Action: ${hasAction}, Required Fields Filled: ${requiredFieldsFilled}, Can Save: ${canSave}`
+ );
return (
@@ -391,28 +638,37 @@ const CippStandardAccordion = ({
{accordionTitle}
- {selectedActions && selectedActions?.length > 0 && (
-
- {selectedActions?.map((action, index) => (
-
-
-
- ))}
-
-
- )}
+
+ {selectedActions && selectedActions?.length > 0 && (
+ <>
+ {selectedActions?.map((action, index) => (
+
+
+ {action.value === "Report" && }
+ {action.value === "warn" && }
+ {action.value === "Remediate" && }
+
+ }
+ />
+
+ ))}
+ >
+ )}
+
+
{standard.helpText}
@@ -456,6 +712,7 @@ const CippStandardAccordion = ({
+ {/* Always show action field as it's required */}
@@ -501,6 +759,27 @@ const CippStandardAccordion = ({
)}
+
+
+
+
+
+
+
);
diff --git a/src/components/CippStandards/CippStandardDialog.jsx b/src/components/CippStandards/CippStandardDialog.jsx
index bdf407da2bf4..73bf59cb04f9 100644
--- a/src/components/CippStandards/CippStandardDialog.jsx
+++ b/src/components/CippStandards/CippStandardDialog.jsx
@@ -1,4 +1,4 @@
-import { differenceInDays } from 'date-fns';
+import { differenceInDays } from "date-fns";
import {
Dialog,
DialogActions,
@@ -14,11 +14,252 @@ import {
Switch,
Button,
IconButton,
+ CircularProgress,
} from "@mui/material";
import { Grid } from "@mui/system";
import { Add } from "@mui/icons-material";
-import { useState, useCallback } from "react";
+import { useState, useCallback, useMemo, memo, useEffect, Suspense, lazy } from "react";
import { debounce } from "lodash";
+import { Virtuoso } from "react-virtuoso";
+
+// Memoized Standard Card component to prevent unnecessary re-renders
+const StandardCard = memo(
+ ({
+ standard,
+ category,
+ selectedStandards,
+ handleToggleSingleStandard,
+ handleAddClick,
+ isButtonDisabled,
+ }) => {
+ const isNewStandard = (dateAdded) => {
+ const currentDate = new Date();
+ const addedDate = new Date(dateAdded);
+ return differenceInDays(currentDate, addedDate) <= 30;
+ };
+
+ // Create a memoized handler for this specific standard to avoid recreation on each render
+ const handleToggle = useCallback(() => {
+ handleToggleSingleStandard(standard.name);
+ }, [handleToggleSingleStandard, standard.name]);
+
+ // Check if this standard is selected - memoize for better performance
+ const isSelected = useMemo(() => {
+ return !!selectedStandards[standard.name];
+ }, [selectedStandards, standard.name]);
+
+ // Lazily render complex parts of the card only when visible
+ const [expanded, setExpanded] = useState(false);
+
+ // Use intersection observer to detect when card is visible
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setExpanded(true);
+ observer.disconnect();
+ }
+ },
+ { threshold: 0.1 }
+ );
+
+ const currentRef = document.getElementById(`standard-card-${standard.name}`);
+ if (currentRef) {
+ observer.observe(currentRef);
+ }
+
+ return () => observer.disconnect();
+ }, [standard.name]);
+
+ return (
+
+
+ {isNewStandard(standard.addedDate) && (
+
+ )}
+
+
+ {standard.label}
+
+ {expanded && standard.helpText && (
+ <>
+
+ Description:
+
+
+ {standard.helpText}
+
+ >
+ )}
+
+ Category:
+
+
+ {expanded &&
+ standard.tag?.filter((tag) => !tag.toLowerCase().includes("impact")).length > 0 && (
+ <>
+
+ Tags:
+
+
+ {standard.tag
+ .filter((tag) => !tag.toLowerCase().includes("impact"))
+ .map((tag, idx) => (
+
+ ))}
+
+ >
+ )}
+
+ Impact:
+
+
+ {expanded && standard.recommendedBy?.length > 0 && (
+ <>
+
+ Recommended By:
+
+
+ {standard.recommendedBy.join(", ")}
+
+ >
+ )}
+ {expanded && standard.addedDate?.length > 0 && (
+ <>
+
+ Date Added:
+
+
+
+ {standard.addedDate}
+
+
+ >
+ )}
+
+
+
+ {standard.multiple ? (
+ handleAddClick(standard.name)}
+ >
+
+
+ ) : (
+
+ }
+ label="Add this standard to the template"
+ />
+ )}
+
+
+
+ );
+ },
+ // Custom equality function to prevent unnecessary re-renders
+ (prevProps, nextProps) => {
+ // Only re-render if one of these props changed
+ if (prevProps.isButtonDisabled !== nextProps.isButtonDisabled) return false;
+ if (prevProps.standard.name !== nextProps.standard.name) return false;
+
+ // Only check selected state for this specific standard
+ const prevSelected = !!prevProps.selectedStandards[prevProps.standard.name];
+ const nextSelected = !!nextProps.selectedStandards[nextProps.standard.name];
+ if (prevSelected !== nextSelected) return false;
+
+ // If we get here, nothing important changed, skip re-render
+ return true;
+ }
+);
+
+StandardCard.displayName = "StandardCard";
+
+// Virtualized grid to handle large numbers of standards efficiently
+const VirtualizedStandardGrid = memo(({ items, renderItem }) => {
+ const [itemsPerRow, setItemsPerRow] = useState(() =>
+ window.innerWidth > 960 ? 4 : window.innerWidth > 600 ? 2 : 1
+ );
+
+ // Handle window resize for responsive grid
+ useEffect(() => {
+ const handleResize = () => {
+ const newItemsPerRow = window.innerWidth > 960 ? 4 : window.innerWidth > 600 ? 2 : 1;
+ setItemsPerRow(newItemsPerRow);
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ const rows = useMemo(() => {
+ const rowCount = Math.ceil(items.length / itemsPerRow);
+ const rowsData = [];
+
+ for (let i = 0; i < rowCount; i++) {
+ const startIdx = i * itemsPerRow;
+ const rowItems = items.slice(startIdx, startIdx + itemsPerRow);
+ rowsData.push(rowItems);
+ }
+
+ return rowsData;
+ }, [items, itemsPerRow]);
+
+ return (
+ (
+
+
+ {rows[index].map(renderItem)}
+
+
+ )}
+ />
+ );
+});
+
+VirtualizedStandardGrid.displayName = "VirtualizedStandardGrid";
const CippStandardDialog = ({
dialogOpen,
@@ -31,35 +272,120 @@ const CippStandardDialog = ({
handleAddMultipleStandard,
}) => {
const [isButtonDisabled, setButtonDisabled] = useState(false);
+ const [localSearchQuery, setLocalSearchQuery] = useState("");
+ const [isInitialLoading, setIsInitialLoading] = useState(true);
- const handleAddClick = (standardName) => {
- setButtonDisabled(true);
- handleAddMultipleStandard(standardName);
-
- setTimeout(() => {
- setButtonDisabled(false);
- }, 100);
- };
+ // Optimize handleAddClick to be more performant
+ const handleAddClick = useCallback(
+ (standardName) => {
+ setButtonDisabled(true);
+ handleAddMultipleStandard(standardName);
+ // Use requestAnimationFrame for smoother UI updates
+ requestAnimationFrame(() => {
+ setTimeout(() => {
+ setButtonDisabled(false);
+ }, 100);
+ });
+ },
+ [handleAddMultipleStandard]
+ );
+ // Optimize search debounce with a higher timeout for better performance
const handleSearchQueryChange = useCallback(
debounce((query) => {
setSearchQuery(query.trim());
- }, 50),
- []
+ }, 350), // Increased debounce time for better performance
+ [setSearchQuery]
);
- const isNewStandard = (dateAdded) => {
- const currentDate = new Date();
- const addedDate = new Date(dateAdded);
- return differenceInDays(currentDate, addedDate) <= 30;
- };
+ // Only process visible categories on demand to improve performance
+ const [processedItems, setProcessedItems] = useState([]);
+
+ // Handle search input change locally
+ const handleLocalSearchChange = useCallback(
+ (e) => {
+ const value = e.target.value.toLowerCase();
+ setLocalSearchQuery(value);
+ handleSearchQueryChange(value);
+ },
+ [handleSearchQueryChange]
+ );
+
+ // Clear dialog state on close
+ const handleClose = useCallback(() => {
+ setLocalSearchQuery(""); // Clear local search state
+ handleSearchQueryChange(""); // Clear parent search state
+ handleCloseDialog();
+ }, [handleCloseDialog, handleSearchQueryChange]);
+
+ // Process standards data only when dialog is opened, to improve performance
+ useEffect(() => {
+ if (dialogOpen) {
+ // Use requestIdleCallback if available, or setTimeout as fallback
+ const processStandards = () => {
+ // Create a flattened list of all standards for virtualized rendering
+ const allItems = [];
+
+ Object.keys(categories).forEach((category) => {
+ const filteredStandards = filterStandards(categories[category]);
+ filteredStandards.forEach((standard) => {
+ allItems.push({
+ standard,
+ category,
+ });
+ });
+ });
+
+ setProcessedItems(allItems);
+ setIsInitialLoading(false);
+ };
+ if (window.requestIdleCallback) {
+ window.requestIdleCallback(processStandards, { timeout: 500 });
+ } else {
+ setTimeout(processStandards, 100);
+ }
+
+ return () => {
+ if (window.cancelIdleCallback) {
+ window.cancelIdleCallback(processStandards);
+ }
+ };
+ } else {
+ setIsInitialLoading(true);
+ }
+ }, [dialogOpen, categories, filterStandards, localSearchQuery]);
+
+ // Render individual standard card
+ const renderStandardCard = useCallback(
+ ({ standard, category }) => (
+
+ ),
+ [selectedStandards, handleToggleSingleStandard, handleAddClick, isButtonDisabled]
+ );
+
+ // Don't render dialog contents until it's actually open (improves performance)
return (