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 .codex/Agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Modal State Management Policy

- All modals in this project **must** use a centralized Zustand store for open/close state and for passing modal data.
- Modal data types are defined in `ModalDataMap` and should be strictly type-safe.
- All modals must be registered in a central `modalRegistry`, mapping modal names to their components.
- The app renders modals via a single `ModalManager` component that checks the registry and Zustand store.
- Modal data should always be accessed with `getModalData(name)` from the store, **not** passed via parent props.
- The `useModal` hook should be used everywhere for opening/closing/getting modal data.
- Any local or per-component modal state management is forbidden and should be migrated to the central store.
- Always clean up legacy/unused modal state code after refactoring.
- TypeScript must be enforced for all modal-related code.
- New modals should follow the above pattern, with new entries added to `ModalDataMap` and `modalRegistry`.

# General Coding Guidelines

- Use functional components and hooks.
- Prefer explicit, readable, and type-safe code.
- Remove unused code and keep files tidy.
- All code changes should maintain or improve type safety.
4 changes: 4 additions & 0 deletions .codex/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
model: o4-mini
approvalMode: suggest
fullAutoErrorMode: ask-user
notify: true
31 changes: 31 additions & 0 deletions src/components/ModalManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react";

import { modalRegistry, ModalConfig } from "./modalRegistry";
import { useModalStore } from "@store/useModalStore";
import { ModalName } from "@enums/components";

/**
* Renders all currently open modals based on the centralized modal registry and store.
*/
export const ModalManager: React.FC = () => {
const { modals, getModalData } = useModalStore();
return (
<>
{Object.entries(modals).map(([modalName, isOpen]) => {
if (!isOpen) return null;
const modalConfig = modalRegistry[modalName as ModalName];
if (!modalConfig) return null;
const { component: ModalComponent, requiresData } = modalConfig as ModalConfig;
const data = getModalData(modalName as ModalName);
if (requiresData && !data) return null;
return (
<ModalComponent
key={modalName}
data={data}
{...(data as object)}
/>
);
})}
</>
);
};
39 changes: 39 additions & 0 deletions src/components/modalRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ModalName } from "@enums/components";
import {
DeleteProjectModal,
ImportProjectModal,
NewProjectModal,
InvitedUserModal,
DeleteActiveDeploymentProjectModal,
DeleteDrainingDeploymentProjectModal,
RateLimitModal,
QuotaLimitModal,
FileViewerModal,
DiagramViewerModal,
} from "@components/organisms/modals";
import { ActiveDeploymentWarningModal } from "@components/organisms";
import { ContinueTourModal } from "@components/organisms/tour/continueTourModal";

/**
* Configuration for rendering modals: mapping modal names to components and data requirements.
*/
export interface ModalConfig {
component: React.ComponentType<any>;
requiresData?: boolean;
}

export const modalRegistry: Record<ModalName, ModalConfig> = {
[ModalName.deleteProject]: { component: DeleteProjectModal, requiresData: true },
[ModalName.importProject]: { component: ImportProjectModal },
[ModalName.newProject]: { component: NewProjectModal },
[ModalName.invitedUser]: { component: InvitedUserModal, requiresData: true },
[ModalName.deleteWithActiveDeploymentProject]: { component: DeleteActiveDeploymentProjectModal, requiresData: true },
[ModalName.deleteWithDrainingDeploymentProject]: { component: DeleteDrainingDeploymentProjectModal, requiresData: true },
[ModalName.rateLimit]: { component: RateLimitModal, requiresData: true },
[ModalName.quotaLimit]: { component: QuotaLimitModal, requiresData: true },
[ModalName.fileViewer]: { component: FileViewerModal, requiresData: true },
[ModalName.diagramViewer]: { component: DiagramViewerModal, requiresData: true },
[ModalName.warningDeploymentActive]: { component: ActiveDeploymentWarningModal, requiresData: true },
[ModalName.continueTour]: { component: ContinueTourModal, requiresData: true },
// Other modals can be added here as needed
};
17 changes: 6 additions & 11 deletions src/components/organisms/activeDeploymentWarningModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,18 @@ import React from "react";
import { useTranslation } from "react-i18next";

import { ModalName } from "@enums/components";
import { ActiveDeploymentWarningModalProps } from "@interfaces/components";
import { useModalStore } from "@src/store";
import { useModal } from "@hooks/useModal";

import { Button } from "@components/atoms";
import { Modal } from "@components/molecules";

export const ActiveDeploymentWarningModal = ({
action,
goToAdd,
goToEdit,
modifiedId,
}: ActiveDeploymentWarningModalProps) => {
export const ActiveDeploymentWarningModal: React.FC = () => {
const { t } = useTranslation("modals", { keyPrefix: "warningActiveDeployment" });
const { closeModal } = useModalStore();

if (!action) return null;
const { getModalData, closeModal } = useModal();
const data = getModalData(ModalName.warningDeploymentActive);
if (!data) return null;

const { action, modifiedId, goToAdd, goToEdit } = data;
const onOkClick = () => (action === "edit" ? goToEdit(modifiedId) : goToAdd());

return (
Expand Down
30 changes: 13 additions & 17 deletions src/components/organisms/modals/quotaLimitModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,20 @@ import React from "react";
import { useTranslation } from "react-i18next";

import { ModalName } from "@enums/components";
import { QuotaLimitModalProps } from "@interfaces/components";
import { useModalStore } from "@src/store";
import { useModal } from "@hooks/useModal";

import { Button, IconSvg } from "@components/atoms";
import { Modal } from "@components/molecules";

import { ErrorIcon } from "@assets/image/icons";

export const QuotaLimitModal = ({ onContactSupportClick }: QuotaLimitModalProps) => {
export const QuotaLimitModal: React.FC = () => {
const { t } = useTranslation("modals", { keyPrefix: "quotaLimit" });
const data = useModalStore((state) => state.data) as {
limit: string;
resource: string;
used: string;
};
const { getModalData, closeModal } = useModal();
const data = getModalData(ModalName.quotaLimit);
if (!data) return null;

const { used, limit, resource } = data;
const { used, limit, resource, onContactSupportClick } = data;

return (
<Modal name={ModalName.quotaLimit}>
Expand All @@ -34,14 +30,14 @@ export const QuotaLimitModal = ({ onContactSupportClick }: QuotaLimitModalProps)
<br />

<div className="mt-8 flex w-full justify-end">
<Button
ariaLabel={t("retryButton")}
className="min-w-20 justify-center bg-gray-1100 px-4 py-3 font-semibold hover:text-green-800"
onClick={onContactSupportClick}
variant="filled"
>
{t("contactSupport")}
</Button>
<Button
ariaLabel={t("retryButton")}
className="min-w-20 justify-center bg-gray-1100 px-4 py-3 font-semibold hover:text-green-800"
onClick={() => { onContactSupportClick(); closeModal(ModalName.quotaLimit); }}
variant="filled"
>
{t("contactSupport")}
</Button>
</div>
</div>
</Modal>
Expand Down
11 changes: 8 additions & 3 deletions src/components/organisms/modals/rateLimitModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ import React from "react";
import { useTranslation } from "react-i18next";

import { ModalName } from "@enums/components";
import { RateLimitModalProps } from "@interfaces/components";
import { useModal } from "@hooks/useModal";

import { Button, IconSvg, Loader } from "@components/atoms";
import { Modal } from "@components/molecules";

import { ErrorIcon } from "@assets/image/icons";

export const RateLimitModal = ({ isRetrying, onRetryClick }: RateLimitModalProps) => {

export const RateLimitModal: React.FC = () => {
const { t } = useTranslation("modals", { keyPrefix: "rateLimit" });
const { getModalData, closeModal } = useModal();
const data = getModalData(ModalName.rateLimit);
if (!data) return null;
const { isRetrying, onRetryClick } = data;

return (
<Modal name={ModalName.rateLimit}>
Expand All @@ -27,7 +32,7 @@ export const RateLimitModal = ({ isRetrying, onRetryClick }: RateLimitModalProps
<Button
ariaLabel={t("retryButton")}
className="min-w-20 justify-center bg-gray-1100 px-4 py-3 font-semibold hover:text-green-800"
onClick={onRetryClick}
onClick={() => { onRetryClick(); closeModal(ModalName.rateLimit); }}
variant="filled"
>
{isRetrying ? (
Expand Down
41 changes: 21 additions & 20 deletions src/components/organisms/tour/continueTourModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ import React from "react";
import { useTranslation } from "react-i18next";

import { ModalName } from "@enums/components";
import { ContinueTourModalProps } from "@interfaces/components";
import { useModalStore } from "@src/store";
import { useModal } from "@hooks/useModal";

import { Button } from "@components/atoms";
import { Modal } from "@components/molecules";

export const ContinueTourModal = ({ onContinue, onCancel }: ContinueTourModalProps) => {
export const ContinueTourModal: React.FC = () => {
const { t } = useTranslation("modals", { keyPrefix: "continueTour" });
const data = useModalStore((state) => state.data) as { name: string };
const { getModalData, closeModal } = useModal();
const data = getModalData(ModalName.continueTour);
if (!data) return null;

return (
<Modal name={ModalName.continueTour}>
Expand All @@ -21,23 +22,23 @@ export const ContinueTourModal = ({ onContinue, onCancel }: ContinueTourModalPro
</div>

<div className="mt-8 flex w-full justify-end gap-2">
<Button
ariaLabel={t("cancelButton")}
className="px-4 py-3 font-semibold hover:bg-gray-1100 hover:text-white"
onClick={onCancel}
variant="outline"
>
{t("cancelButton")}
</Button>
<Button
ariaLabel={t("cancelButton")}
className="px-4 py-3 font-semibold hover:bg-gray-1100 hover:text-white"
onClick={() => { data.onCancel(); closeModal(ModalName.continueTour); }}
variant="outline"
>
{t("cancelButton")}
</Button>

<Button
ariaLabel={t("okButton")}
className="min-w-20 justify-center bg-gray-1100 px-4 py-3 font-semibold hover:text-green-800"
onClick={onContinue}
variant="filled"
>
{t("continueButton")}
</Button>
<Button
ariaLabel={t("okButton")}
className="min-w-20 justify-center bg-gray-1100 px-4 py-3 font-semibold hover:text-green-800"
onClick={() => { data.onContinue(); closeModal(ModalName.continueTour); }}
variant="filled"
>
{t("continueButton")}
</Button>
</div>
</Modal>
);
Expand Down
44 changes: 4 additions & 40 deletions src/components/templates/appProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";

import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
Expand All @@ -8,15 +8,14 @@ import { supportEmail } from "@src/constants";
import { tours } from "@src/constants/tour.constants";
import { EventListenerName } from "@src/enums";
import { ModalName } from "@src/enums/components";
import { useEventListener, useRateLimitHandler } from "@src/hooks";
import { useEventListener } from "@src/hooks";
import { AppProviderProps } from "@src/interfaces/components";
import { useModalStore, useProjectStore, useTemplatesStore, useToastStore, useTourStore } from "@src/store";
import { shouldShowStepOnPath, validateAllRequiredToursExist, validateAllTemplatesExist } from "@src/utilities";

import { Toast } from "@components/molecules";
import { TourManager } from "@components/organisms";
import { QuotaLimitModal, RateLimitModal } from "@components/organisms/modals";
import { ContinueTourModal } from "@components/organisms/tour/continueTourModal";
import { ModalManager } from "@components/ModalManager";

export const AppProvider = ({ children }: AppProviderProps) => {
const {
Expand All @@ -39,9 +38,6 @@ export const AppProvider = ({ children }: AppProviderProps) => {
templateMap,
fetchTemplates,
} = useTemplatesStore();
const { isRetrying, onRetryClick } = useRateLimitHandler();
const [rateLimitModalDisplayed, setRateLimitModalDisplayed] = useState(false);
const [quotaLimitModalDisplayed, setQuotaLimitModalDisplayed] = useState(false);

useEffect(() => {
const checkAndFetchTemplates = async () => {
Expand Down Expand Up @@ -90,37 +86,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep, activeTour.tourId, location.pathname, tourProjectId]);

const displayRateLimitModal = (_: CustomEvent) => {
if (!rateLimitModalDisplayed) {
openModal(ModalName.rateLimit);
setRateLimitModalDisplayed(true);
}
};

const displayQuotaLimitModal = ({
detail: { limit, resourceName, used },
}: CustomEvent<{ limit: string; resourceName: string; used: string }>) => {
if (!quotaLimitModalDisplayed) {
closeAllModals();
openModal(ModalName.quotaLimit, { limit, resource: resourceName, used });
setQuotaLimitModalDisplayed(true);
}
};

const hideRateLimitModal = () => {
closeModal(ModalName.rateLimit);
setRateLimitModalDisplayed(false);
};

const hideQuotaLimitModal = () => {
closeModal(ModalName.quotaLimit);
setQuotaLimitModalDisplayed(false);
};

useEventListener(EventListenerName.displayRateLimitModal, displayRateLimitModal);
useEventListener(EventListenerName.displayQuotaLimitModal, displayQuotaLimitModal);
useEventListener(EventListenerName.hideRateLimitModal, hideRateLimitModal);
useEventListener(EventListenerName.hideQuotaLimitModal, hideQuotaLimitModal);

const onContactSupportClick = () => {
try {
Expand All @@ -138,9 +104,7 @@ export const AppProvider = ({ children }: AppProviderProps) => {
{children}
<Toast />
<TourManager />
<ContinueTourModal onCancel={cancelTour} onContinue={continueTour} />
<RateLimitModal isRetrying={isRetrying} onRetryClick={onRetryClick} />
<QuotaLimitModal onContactSupportClick={onContactSupportClick} />
<ModalManager />
</>
);
};
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export { useRateLimitHandler } from "./useRateLimitHandler";
export { useChatbotIframeConnection } from "./useChatbotIframe";
export { useHubspotSubmission } from "./useHubspotSubmission";
export { useLoginAttempt } from "./useLoginAttempt";
export { useModal } from "./useModal";
27 changes: 27 additions & 0 deletions src/hooks/useModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useCallback } from "react";

import { useModalStore, ModalDataMap } from "@store/useModalStore";
import { ModalName } from "@enums/components";

/**
* Custom hook to interact with the centralized modal store.
*/
export const useModal = () => {
const { openModal, closeModal, closeAllModals, getModalData } = useModalStore();

const open = useCallback(
<T extends keyof ModalDataMap>(name: T, data?: ModalDataMap[T]) => {
openModal(name, data);
},
[openModal]
);

const close = useCallback(
(name: ModalName) => {
closeModal(name);
},
[closeModal]
);

return { openModal: open, closeModal: close, closeAllModals, getModalData };
};
Loading
Loading