diff --git a/platform-api/src/internal/service/devportal_service.go b/platform-api/src/internal/service/devportal_service.go index b60c07917..a8176a19d 100644 --- a/platform-api/src/internal/service/devportal_service.go +++ b/platform-api/src/internal/service/devportal_service.go @@ -341,7 +341,7 @@ func (s *DevPortalService) UpdateDevPortal(uuid, orgUUID string, req *dto.Update if req.Hostname != nil { devPortal.Hostname = *req.Hostname } - if req.APIKey != nil { + if req.APIKey != nil && strings.Trim(*req.APIKey, "*") != "" { devPortal.APIKey = *req.APIKey } if req.HeaderKeyName != nil { diff --git a/portals/management-portal/src/components/ErrorBoundary.tsx b/portals/management-portal/src/components/ErrorBoundary.tsx index 415aa93e4..74d20c600 100644 --- a/portals/management-portal/src/components/ErrorBoundary.tsx +++ b/portals/management-portal/src/components/ErrorBoundary.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { Box, Button, Typography } from "@mui/material"; +import React from 'react'; +import { Box, Button, Typography } from '@mui/material'; type Props = { children: React.ReactNode; @@ -34,21 +34,28 @@ class ErrorBoundary extends React.Component { }; renderFallback() { - const { fallbackTitle = "Something went wrong", fallbackMessage = "An unexpected error occurred. You can retry or navigate away." } = this.props; + const { + fallbackTitle = 'Something went wrong', + fallbackMessage = 'An unexpected error occurred. You can retry or navigate away.', + } = this.props; return ( - + {fallbackTitle} {fallbackMessage} - - - diff --git a/portals/management-portal/src/constants/portal.ts b/portals/management-portal/src/constants/portal.ts index 80c861f3d..0dbacc2e2 100644 --- a/portals/management-portal/src/constants/portal.ts +++ b/portals/management-portal/src/constants/portal.ts @@ -7,6 +7,7 @@ export const PORTAL_CONSTANTS = { DEFAULT_VISIBILITY_LABEL: 'Private', DEFAULT_HEADER_KEY_NAME: 'x-wso2-api-key', DEFAULT_LOGO_ALT: 'Portal logo', + API_KEY_MASK: '**********', // Portal types PORTAL_TYPES: { @@ -36,8 +37,23 @@ export const PORTAL_CONSTANTS = { PORTAL_ACTIVATED: 'Developer Portal activated successfully.', ACTIVATION_FAILED: 'Failed to activate Developer Portal.', CREATION_FAILED: 'Failed to create Developer Portal.', + UPDATE_FAILED: 'Failed to update Developer Portal.', + FETCH_DEVPORTALS_FAILED: 'Failed to fetch devportals', + CREATE_DEVPORTAL_FAILED: 'Failed to create devportal', + UPDATE_DEVPORTAL_FAILED: 'Failed to update devportal', + DELETE_DEVPORTAL_FAILED: 'Failed to delete devportal', + FETCH_PORTAL_DETAILS_FAILED: 'Failed to fetch portal details', + ACTIVATE_DEVPORTAL_FAILED: 'Failed to activate devportal', + PUBLISH_FAILED: 'Failed to publish', + NO_PORTAL_SELECTED: 'No portal selected', + PROVIDE_API_NAME_AND_URL: 'Please provide API Name and Production URL', + PUBLISH_THEME_FAILED: 'Failed to publish theme', + PROMO_ACTION_FAILED: 'Promo action failed', + REFRESH_PUBLICATIONS_FAILED: 'Failed to refresh publications after publish', + API_PUBLISH_CONTEXT_ERROR: 'useApiPublishing must be used within an ApiPublishProvider', LOADING_ERROR: 'An error occurred while loading developer portals.', - URL_NOT_AVAILABLE: 'Portal URL is not available until the portal is activated', + URL_NOT_AVAILABLE: + 'Portal URL is not available until the portal is activated', OPEN_PORTAL_URL: 'Open portal URL', } as const, @@ -52,5 +68,7 @@ export const PORTAL_CONSTANTS = { } as const; // Type helpers -export type PortalType = typeof PORTAL_CONSTANTS.PORTAL_TYPES[keyof typeof PORTAL_CONSTANTS.PORTAL_TYPES]; -export type PortalMode = typeof PORTAL_CONSTANTS.MODES[keyof typeof PORTAL_CONSTANTS.MODES]; \ No newline at end of file +export type PortalType = + (typeof PORTAL_CONSTANTS.PORTAL_TYPES)[keyof typeof PORTAL_CONSTANTS.PORTAL_TYPES]; +export type PortalMode = + (typeof PORTAL_CONSTANTS.MODES)[keyof typeof PORTAL_CONSTANTS.MODES]; diff --git a/portals/management-portal/src/context/ApiPublishContext.tsx b/portals/management-portal/src/context/ApiPublishContext.tsx index 73e940683..cfacb0754 100644 --- a/portals/management-portal/src/context/ApiPublishContext.tsx +++ b/portals/management-portal/src/context/ApiPublishContext.tsx @@ -5,8 +5,10 @@ import { useEffect, useMemo, useState, + useRef, type ReactNode, -} from "react"; +} from 'react'; + import { useApiPublishApi, type UnpublishResponse, @@ -14,53 +16,73 @@ import { type ApiPublishPayload, type PublishResponse, } from "../hooks/apiPublish"; -import { useOrganization } from "./OrganizationContext"; -type ApiPublishContextValue = { - publishedApis: Record; // keyed by apiId +import { useOrganization } from './OrganizationContext'; + +/* -------------------------------------------------------------------------- */ +/* Type Definitions */ +/* -------------------------------------------------------------------------- */ + +export type ApiPublishContextValue = { + publishedApis: ApiPublicationWithPortal[]; loading: boolean; - error: string | null; + refreshPublishedApis: (apiId: string) => Promise; - publishApiToDevPortal: (apiId: string, payload: ApiPublishPayload) => Promise; + publishApiToDevPortal: ( + apiId: string, + payload: ApiPublishPayload + ) => Promise; unpublishApiFromDevPortal: ( apiId: string, devPortalId: string ) => Promise; - getPublishStatus: (apiId: string, devPortalId: string) => ApiPublicationWithPortal | undefined; + getPublication: ( + devPortalId: string + ) => ApiPublicationWithPortal | undefined; + clearPublishedApis: () => void; }; -// The publish hook returns a PublishResponse type; expose that as the context result -type PublishResult = PublishResponse; +type ApiPublishProviderProps = { children: ReactNode }; -const ApiPublishContext = createContext(undefined); +/* -------------------------------------------------------------------------- */ +/* Context */ +/* -------------------------------------------------------------------------- */ -type ApiPublishProviderProps = { - children: ReactNode; -}; +const ApiPublishContext = createContext( + undefined +); + +/* -------------------------------------------------------------------------- */ +/* Provider */ +/* -------------------------------------------------------------------------- */ export const ApiPublishProvider = ({ children }: ApiPublishProviderProps) => { - const { organization, loading: organizationLoading } = useOrganization(); + const { organization, loading: orgLoading } = useOrganization(); + const { + fetchPublications, + publishApiToDevPortal: publishRequest, + unpublishApiFromDevPortal: unpublishRequest, + } = useApiPublishApi(); + + const [publishedApis, setPublishedApis] = useState< + ApiPublicationWithPortal[] + >([]); + const [loading, setLoading] = useState(false); - const { fetchPublications, publishApiToDevPortal: publishRequest, unpublishApiFromDevPortal: unpublishRequest } = - useApiPublishApi(); + // Ref to track previous organization to avoid unnecessary clears + const prevOrgRef = useRef(undefined); - const [publishedApis, setPublishedApis] = useState>({}); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + /* ------------------------------- API Actions ------------------------------ */ + /** Refresh publications for a specific API */ const refreshPublishedApis = useCallback( async (apiId: string) => { setLoading(true); - setError(null); try { - const publishedList = await fetchPublications(apiId); - setPublishedApis((prev) => ({ ...prev, [apiId]: publishedList })); - return publishedList; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to fetch published APIs"; - setError(message); - throw err; + const list = await fetchPublications(apiId); + setPublishedApis(list); + return list; } finally { setLoading(false); } @@ -68,26 +90,23 @@ export const ApiPublishProvider = ({ children }: ApiPublishProviderProps) => { [fetchPublications] ); + /** Publish API to devportal */ const publishApiToDevPortal = useCallback( - async (apiId: string, payload: ApiPublishPayload): Promise => { - setError(null); + async (apiId: string, payload: ApiPublishPayload) => { setLoading(true); try { - const res = await publishRequest(apiId, payload); + const response = await publishRequest(apiId, payload); - // If backend returned a publication or reference, attempt to refresh/merge + // Refresh state after a successful publish try { await refreshPublishedApis(apiId); } catch { - // swallow — refresh failure will be exposed via error state already + // Publish succeeded but refresh failed - user may need to manually refresh + console.warn('Failed to refresh publications after publish'); } - return res; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to publish API to devportal"; - setError(message); - throw err; + return response; } finally { setLoading(false); } @@ -95,35 +114,18 @@ export const ApiPublishProvider = ({ children }: ApiPublishProviderProps) => { [publishRequest, refreshPublishedApis] ); - - const getPublishStatus = useCallback( - (apiId: string, devPortalId: string) => { - const apiPublished = publishedApis[apiId] || []; - return apiPublished.find(p => p.uuid === devPortalId); - }, - [publishedApis] - ); - + /** Unpublish API from devportal */ const unpublishApiFromDevPortal = useCallback( async (apiId: string, devPortalId: string) => { - setError(null); setLoading(true); try { const result = await unpublishRequest(apiId, devPortalId); - // Update local state: remove the devPortal entry from publishedApis[apiId] - setPublishedApis((prev) => { - const list = prev[apiId] ?? []; - const nextList = list.filter((p) => p.uuid !== devPortalId); - return { ...prev, [apiId]: nextList }; - }); + // Update local state + setPublishedApis((prev) => prev.filter((p) => p.uuid !== devPortalId)); return result; - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to unpublish API from devportal"; - setError(message); - throw err; } finally { setLoading(false); } @@ -131,33 +133,55 @@ export const ApiPublishProvider = ({ children }: ApiPublishProviderProps) => { [unpublishRequest] ); - // Clear published APIs when organization changes (including when switching between orgs) + /** Get publish status for specific (apiId, devPortalId) */ + const getPublication = useCallback( + (devPortalId: string) => { + return publishedApis.find((p) => p.uuid === devPortalId); + }, + [publishedApis] + ); + + /** Clear published APIs */ + const clearPublishedApis = useCallback(() => { + setPublishedApis([]); + }, []); + + /* ----------------------------- Organization Switch ----------------------------- */ + useEffect(() => { - if (organizationLoading) return; - // Always clear when org changes, not just when it becomes null - // This prevents data leakage when switching between organizations - setPublishedApis({}); - setLoading(false); - }, [organization, organizationLoading]); + if (orgLoading) return; + + const currentOrgId = organization?.id; + const prevOrgId = prevOrgRef.current; + + // Only clear data if organization actually changed + if (currentOrgId !== prevOrgId) { + setPublishedApis([]); + setLoading(false); + prevOrgRef.current = currentOrgId; + } + }, [organization, orgLoading]); + + /* -------------------------------- Context Value ------------------------------- */ const value = useMemo( () => ({ publishedApis, loading, - error, refreshPublishedApis, publishApiToDevPortal, unpublishApiFromDevPortal, - getPublishStatus, + getPublication, + clearPublishedApis, }), [ publishedApis, loading, - error, refreshPublishedApis, publishApiToDevPortal, unpublishApiFromDevPortal, - getPublishStatus, + getPublication, + clearPublishedApis, ] ); @@ -168,12 +192,14 @@ export const ApiPublishProvider = ({ children }: ApiPublishProviderProps) => { ); }; -export const useApiPublishing = () => { - const context = useContext(ApiPublishContext); +/* -------------------------------------------------------------------------- */ +/* Consumer Hook */ +/* -------------------------------------------------------------------------- */ - if (!context) { - throw new Error("useApiPublishing must be used within an ApiPublishProvider"); +export const useApiPublishing = () => { + const ctx = useContext(ApiPublishContext); + if (!ctx) { + throw new Error('useApiPublishing must be used within an ApiPublishProvider'); } - - return context; -}; \ No newline at end of file + return ctx; +}; diff --git a/portals/management-portal/src/context/DevPortalContext.tsx b/portals/management-portal/src/context/DevPortalContext.tsx index f65ff741b..c0592643a 100644 --- a/portals/management-portal/src/context/DevPortalContext.tsx +++ b/portals/management-portal/src/context/DevPortalContext.tsx @@ -5,202 +5,197 @@ import { useEffect, useMemo, useState, + useRef, type ReactNode, -} from "react"; +} from 'react'; + import { useDevPortalsApi, - type CreatePortalData, type Portal, + type CreatePortalPayload, type UpdatePortalPayload, -} from "../hooks/devportals"; -import { useOrganization } from "./OrganizationContext"; +} from '../hooks/devportals'; +import { useOrganization } from './OrganizationContext'; +import { useNotifications } from './NotificationContext'; +import { PORTAL_CONSTANTS } from '../constants/portal'; + +/* -------------------------------------------------------------------------- */ +/* Type Definitions */ +/* -------------------------------------------------------------------------- */ -type DevPortalContextValue = { +export interface DevPortalContextValue { devportals: Portal[]; loading: boolean; - error: string | null; + refreshDevPortals: () => Promise; - createDevPortal: (payload: CreatePortalData) => Promise; + createDevPortal: (payload: CreatePortalPayload) => Promise; updateDevPortal: ( - portalId: string, + uuid: string, updates: UpdatePortalPayload ) => Promise; - deleteDevPortal: (portalId: string) => Promise; - fetchDevPortalById: (portalId: string) => Promise; - activateDevPortal: (portalId: string) => Promise; -}; + deleteDevPortal: (uuid: string) => Promise; + fetchDevPortalById: (uuid: string) => Promise; + activateDevPortal: (uuid: string) => Promise; +} -export const DevPortalContext = createContext(undefined); +/* -------------------------------------------------------------------------- */ +/* Context Init */ +/* -------------------------------------------------------------------------- */ -type DevPortalProviderProps = { - children: ReactNode; -}; +const DevPortalContext = createContext( + undefined +); -export const DevPortalProvider = ({ children }: DevPortalProviderProps) => { +export const DevPortalProvider = ({ children }: { children: ReactNode }) => { const { - createDevPortal: createDevPortalRequest, fetchDevPortals, fetchDevPortal, - updateDevPortal: updateDevPortalRequest, - deleteDevPortal: deleteDevPortalRequest, - activateDevPortal: activateDevPortalRequest, + createDevPortal: createRequest, + updateDevPortal: updateRequest, + deleteDevPortal: deleteRequest, + activateDevPortal: activateRequest, } = useDevPortalsApi(); - const { organization, loading: organizationLoading } = useOrganization(); + const { organization, loading: orgLoading } = useOrganization(); + const { showNotification } = useNotifications(); const [devportals, setDevportals] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const lastFetchedOrgRef = useRef(null); + + /* --------------------------- Error Helper --------------------------- */ + + const handleError = useCallback( + (err: unknown, fallback: string) => { + const msg = err instanceof Error ? err.message : fallback; + showNotification(msg, 'error'); + }, + [showNotification] + ); + + /* --------------------------- Core Actions --------------------------- */ const refreshDevPortals = useCallback(async () => { setLoading(true); - setError(null); try { const result = await fetchDevPortals(); setDevportals(result); return result; } catch (err) { - const message = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(message); - throw err; + handleError(err, PORTAL_CONSTANTS.MESSAGES.FETCH_DEVPORTALS_FAILED); + return []; // Return empty array on error } finally { setLoading(false); } - }, [fetchDevPortals]); + }, [fetchDevPortals, handleError]); const createDevPortal = useCallback( - async (payload: CreatePortalData) => { - setError(null); - + async (payload: CreatePortalPayload) => { try { - const portal = await createDevPortalRequest(payload); + const portal = await createRequest(payload); setDevportals((prev) => [portal, ...prev]); return portal; } catch (err) { - const message = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(message); + handleError(err, PORTAL_CONSTANTS.MESSAGES.CREATE_DEVPORTAL_FAILED); throw err; } }, - [createDevPortalRequest] + [createRequest, handleError] ); const updateDevPortal = useCallback( - async (portalId: string, updates: UpdatePortalPayload) => { - setError(null); - + async (uuid: string, updates: UpdatePortalPayload) => { try { - const portal = await updateDevPortalRequest(portalId, updates as any); + const portal = await updateRequest(uuid, updates); setDevportals((prev) => - prev.map((p) => (p.uuid === portalId ? portal : p)) + prev.map((p) => (p.uuid === uuid ? portal : p)) ); return portal; } catch (err) { - const message = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(message); + handleError(err, PORTAL_CONSTANTS.MESSAGES.UPDATE_DEVPORTAL_FAILED); throw err; } }, - [updateDevPortalRequest] + [updateRequest, handleError] ); const deleteDevPortal = useCallback( - async (portalId: string) => { - setError(null); - + async (uuid: string) => { try { - await deleteDevPortalRequest(portalId); - setDevportals((prev) => - prev.filter((portal) => portal.uuid !== portalId) - ); + await deleteRequest(uuid); + setDevportals((prev) => prev.filter((portal) => portal.uuid !== uuid)); } catch (err) { - const message = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(message); + handleError(err, PORTAL_CONSTANTS.MESSAGES.DELETE_DEVPORTAL_FAILED); throw err; } }, - [deleteDevPortalRequest] + [deleteRequest, handleError] ); const fetchDevPortalById = useCallback( - async (portalId: string) => { - setError(null); - + async (uuid: string) => { try { - const portal = await fetchDevPortal(portalId); - let normalized: Portal | undefined; + const portal = await fetchDevPortal(uuid); setDevportals((prev) => { - const existing = prev.find((item) => item.uuid === portal.uuid); - normalized = existing ? { ...existing, ...portal } : portal; - const others = prev.filter((item) => item.uuid !== portal.uuid); - return normalized ? [normalized, ...others] : prev; + const existing = prev.find((p) => p.uuid === portal.uuid); + return existing + ? prev.map((p) => (p.uuid === uuid ? { ...p, ...portal } : p)) + : [portal, ...prev]; }); - - if (!normalized) { - normalized = portal; - } - - return normalized; + return portal; } catch (err) { - const message = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(message); + handleError(err, PORTAL_CONSTANTS.MESSAGES.FETCH_PORTAL_DETAILS_FAILED); throw err; } }, - [fetchDevPortal] + [fetchDevPortal, handleError] ); const activateDevPortal = useCallback( - async (portalId: string) => { - setError(null); - + async (uuid: string) => { try { - await activateDevPortalRequest(portalId); + await activateRequest(uuid); setDevportals((prev) => prev.map((portal) => - portal.uuid === portalId - ? { ...portal, isActive: true } - : portal + portal.uuid === uuid ? { ...portal, isEnabled: true } : portal ) ); } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to activate devportal"; - setError(message); + handleError(err, PORTAL_CONSTANTS.MESSAGES.ACTIVATE_DEVPORTAL_FAILED); throw err; } }, - [activateDevPortalRequest] + [activateRequest, handleError] ); - // When org changes + /* ------------------------- Handle Org Change ------------------------ */ + useEffect(() => { - if (organizationLoading) { - return; - } + if (orgLoading) return; if (!organization) { setDevportals([]); setLoading(false); + lastFetchedOrgRef.current = null; return; } - refreshDevPortals().catch(() => { + if (lastFetchedOrgRef.current === organization.id) return; // Already fetched + + refreshDevPortals().then(() => { + lastFetchedOrgRef.current = organization.id; + }).catch(() => { /* errors captured in state */ }); - }, [organization, organizationLoading, refreshDevPortals]); + }, [organization, orgLoading, refreshDevPortals]); + + /* ---------------------------- Context Value ---------------------------- */ const value = useMemo( () => ({ devportals, loading, - error, refreshDevPortals, createDevPortal, updateDevPortal, @@ -211,7 +206,6 @@ export const DevPortalProvider = ({ children }: DevPortalProviderProps) => { [ devportals, loading, - error, refreshDevPortals, createDevPortal, updateDevPortal, @@ -228,14 +222,14 @@ export const DevPortalProvider = ({ children }: DevPortalProviderProps) => { ); }; +/* -------------------------------------------------------------------------- */ +/* Hook */ +/* -------------------------------------------------------------------------- */ + export const useDevPortals = () => { const context = useContext(DevPortalContext); - if (!context) { - throw new Error("useDevPortals must be used within a DevPortalProvider"); + throw new Error('useDevPortals must be used within a DevPortalProvider'); } - return context; }; - -export type { DevPortalContextValue }; \ No newline at end of file diff --git a/portals/management-portal/src/context/NotificationContext.tsx b/portals/management-portal/src/context/NotificationContext.tsx index 45aedcdf6..157648c79 100644 --- a/portals/management-portal/src/context/NotificationContext.tsx +++ b/portals/management-portal/src/context/NotificationContext.tsx @@ -8,13 +8,17 @@ interface NotificationContextValue { showNotification: (message: string, severity?: AlertColor) => void; } -const NotificationContext = createContext(undefined); +const NotificationContext = createContext( + undefined +); interface NotificationProviderProps { children: ReactNode; } -export const NotificationProvider: React.FC = ({ children }) => { +export const NotificationProvider: React.FC = ({ + children, +}) => { const [notification, setNotification] = React.useState<{ open: boolean; message: string; @@ -25,16 +29,19 @@ export const NotificationProvider: React.FC = ({ chil severity: 'info', }); - const showNotification = React.useCallback((message: string, severity: AlertColor = 'info') => { - setNotification({ - open: true, - message, - severity, - }); - }, []); + const showNotification = React.useCallback( + (message: string, severity: AlertColor = 'info') => { + setNotification({ + open: true, + message, + severity, + }); + }, + [] + ); const handleClose = React.useCallback(() => { - setNotification(prev => ({ ...prev, open: false })); + setNotification((prev) => ({ ...prev, open: false })); }, []); return ( @@ -46,7 +53,11 @@ export const NotificationProvider: React.FC = ({ chil onClose={handleClose} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > - + {notification.message} @@ -57,7 +68,9 @@ export const NotificationProvider: React.FC = ({ chil export const useNotifications = (): NotificationContextValue => { const context = useContext(NotificationContext); if (!context) { - throw new Error('useNotifications must be used within a NotificationProvider'); + throw new Error( + 'useNotifications must be used within a NotificationProvider' + ); } return context; }; diff --git a/portals/management-portal/src/hooks/apiPublish.ts b/portals/management-portal/src/hooks/apiPublish.ts index 71b2765cf..b4389569e 100644 --- a/portals/management-portal/src/hooks/apiPublish.ts +++ b/portals/management-portal/src/hooks/apiPublish.ts @@ -1,19 +1,21 @@ -import { useCallback } from "react"; -import { getApiConfig } from "./apiConfig"; +import { useCallback } from 'react'; +import { getApiConfig } from './apiConfig'; +import { parseApiError } from '../utils/apiErrorUtils'; -/* ---------- Response types ---------- */ +// --------------------------------------------------------- +// Types +// --------------------------------------------------------- export type PublishResponse = { - message: string; // "API published successfully to API portal" - apiId: string; - apiPortalRefId: string; - publishedAt: string; // ISO string + success: boolean; + message: string; + timestamp: string; }; export type UnpublishResponse = { - message: string; // "API unpublished successfully from API portal" - apiId: string; - unpublishedAt: string; // ISO string + success: boolean; + message: string; + timestamp: string; }; export type Publication = { @@ -33,7 +35,7 @@ export type ApiPublicationWithPortal = { portalUrl: string; apiUrl: string; hostname: string; - isActive: boolean; + isEnabled: boolean; createdAt: string; updatedAt: string; associatedAt: string; @@ -63,77 +65,110 @@ export interface ApiPublishPayload { subscriptionPolicies: string[]; } -type PublicationsListResponse = { +export type PublicationsListResponse = { count: number; - list: any[]; - pagination?: { total: number; offset: number; limit: number } | null; + list: ApiPublicationWithPortal[]; + pagination?: { + total: number; + offset: number; + limit: number; + } | null; }; +// --------------------------------------------------------- +// Helpers +// --------------------------------------------------------- + const normalizePublication = (item: any): ApiPublicationWithPortal => ({ ...item, portalUrl: item.uiUrl ?? item.portalUrl, }); -// Hook that centralizes API calls related to publishing +// --------------------------------------------------------- +// API Hook: useApiPublishApi +// --------------------------------------------------------- + export const useApiPublishApi = () => { - const fetchPublications = useCallback(async (apiId: string): Promise => { - const { token, baseUrl } = getApiConfig(); - const response = await fetch(`${baseUrl}/api/v1/apis/${apiId}/publications`, { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`Failed to fetch publications: ${response.status} ${response.statusText} ${errorBody}`); - } - - const data = (await response.json()) as PublicationsListResponse; - return (data.list ?? []).map(normalizePublication); - }, []); - - const publishApiToDevPortal = useCallback(async (apiId: string, payload: ApiPublishPayload): Promise => { - const { token, baseUrl } = getApiConfig(); - - const response = await fetch(`${baseUrl}/api/v1/apis/${apiId}/devportals/publish`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`Failed to publish API: ${response.status} ${response.statusText} ${errorBody}`); - } - - const data = (await response.json()) as PublishResponse; - return data; - }, []); - - const unpublishApiFromDevPortal = useCallback(async (apiId: string, devPortalId: string): Promise => { - const { token, baseUrl } = getApiConfig(); - - // Assume unpublish endpoint follows this shape; adjust if backend differs - const response = await fetch(`${baseUrl}/api/v1/apis/${apiId}/devportals/${devPortalId}/unpublish`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ devPortalId }), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`Failed to unpublish API: ${response.status} ${response.statusText} ${errorBody}`); - } - - const data = (await response.json()) as UnpublishResponse; - return data; - }, []); + const fetchPublications = useCallback( + async (apiId: string): Promise => { + const { token, baseUrl } = getApiConfig(); + + const response = await fetch( + `${baseUrl}/api/v1/apis/${apiId}/publications`, + { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (!response.ok) { + const errorMessage = await parseApiError( + response, + 'fetch publications' + ); + throw new Error(errorMessage); + } + + const data = (await response.json()) as PublicationsListResponse; + return (data.list ?? []).map(normalizePublication); + }, + [] + ); + + const publishApiToDevPortal = useCallback( + async ( + apiId: string, + payload: ApiPublishPayload + ): Promise => { + const { token, baseUrl } = getApiConfig(); + + const response = await fetch( + `${baseUrl}/api/v1/apis/${apiId}/devportals/publish`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + } + ); + + if (!response.ok) { + const errorMessage = await parseApiError(response, 'publish API'); + throw new Error(errorMessage); + } + + return (await response.json()) as PublishResponse; + }, + [] + ); + + const unpublishApiFromDevPortal = useCallback( + async (apiId: string, devPortalId: string): Promise => { + const { token, baseUrl } = getApiConfig(); + + const response = await fetch( + `${baseUrl}/api/v1/apis/${apiId}/devportals/${devPortalId}/unpublish`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ devPortalId }), + } + ); + + if (!response.ok) { + const errorMessage = await parseApiError(response, 'unpublish API'); + throw new Error(errorMessage); + } + + return (await response.json()) as UnpublishResponse; + }, + [] + ); return { fetchPublications, @@ -141,4 +176,3 @@ export const useApiPublishApi = () => { unpublishApiFromDevPortal, }; }; - diff --git a/portals/management-portal/src/hooks/devportals.ts b/portals/management-portal/src/hooks/devportals.ts index c25176df6..50a3fa1ce 100644 --- a/portals/management-portal/src/hooks/devportals.ts +++ b/portals/management-portal/src/hooks/devportals.ts @@ -1,29 +1,49 @@ -import { useCallback } from "react"; -import { getApiConfig } from "./apiConfig"; +import { useCallback } from 'react'; +import { getApiConfig } from './apiConfig'; +import { parseApiError } from '../utils/apiErrorUtils'; -export type Portal = { - logoSrc: string; - logoAlt: string; +/* -------------------------------------------------------------------------- */ +/* Type Definitions */ +/* -------------------------------------------------------------------------- */ + +export interface Portal { uuid: string; organizationUuid: string; name: string; identifier: string; - uiUrl: string; apiUrl: string; hostname: string; - isActive: boolean; - visibility: "public" | "private"; - description: string; + uiUrl: string; + logoSrc: string; + logoAlt: string; headerKeyName: string; + description: string; isDefault: boolean; + isEnabled: boolean; + visibility: 'public' | 'private'; createdAt: string; updatedAt: string; -}; - -export type DevPortalAPIModel = Portal; - +} -export type CreatePortalPayload = { +export interface PortalUIModel { + uuid: string; + name: string; + identifier: string; + description: string; + apiUrl: string; + hostname: string; + portalUrl: string; + logoSrc?: string; + logoAlt?: string; + userAuthLabel: string; + authStrategyLabel: string; + visibilityLabel: string; + isEnabled: boolean; + createdAt?: string; + updatedAt?: string; +} + +export interface CreatePortalPayload { name: string; identifier: string; description: string; @@ -31,15 +51,20 @@ export type CreatePortalPayload = { hostname: string; apiKey: string; headerKeyName: string; -}; - -export type CreatePortalData = CreatePortalPayload; + visibility: 'public' | 'private'; +} +export type CreatePortalRequest = CreatePortalPayload; export type UpdatePortalPayload = Partial; +export type PortalApiResponse = PortalUIModel; -export type UpdatePortalData = CreatePortalPayload; +export type DevPortalResponse = { + success: boolean; + message: string; + timestamp: string; +}; -type PortalListResponse = { +export interface PortalListResponse { count: number; list: Portal[]; pagination: { @@ -47,74 +72,66 @@ type PortalListResponse = { offset: number; limit: number; }; -}; +} + +/* -------------------------------------------------------------------------- */ +/* API Hook - Service */ +/* -------------------------------------------------------------------------- */ export const useDevPortalsApi = () => { - // Fetch all dev portals + /** Fetch all dev portals */ const fetchDevPortals = useCallback(async (): Promise => { const { token, baseUrl } = getApiConfig(); const response = await fetch(`${baseUrl}/api/v1/devportals`, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - }, + headers: { Authorization: `Bearer ${token}` }, }); if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to fetch devportals: ${response.status} ${response.statusText} ${errorBody}` - ); + const errorMessage = await parseApiError(response, 'fetch devportals'); + throw new Error(errorMessage); } const data: PortalListResponse = await response.json(); return data.list ?? []; }, []); - // Fetch single portal by ID - const fetchDevPortal = useCallback( - async (uuid: string): Promise => { - const { token, baseUrl } = getApiConfig(); + /** Fetch single portal */ + const fetchDevPortal = useCallback(async (uuid: string): Promise => { + const { token, baseUrl } = getApiConfig(); - const response = await fetch(`${baseUrl}/api/v1/devportals/${uuid}`, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - }, - }); + const response = await fetch(`${baseUrl}/api/v1/devportals/${uuid}`, { + headers: { Authorization: `Bearer ${token}` }, + }); - if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to fetch devportal ${uuid}: ${response.status} ${response.statusText} ${errorBody}` - ); - } + if (!response.ok) { + const errorMessage = await parseApiError( + response, + `fetch devportal (${uuid})` + ); + throw new Error(errorMessage); + } - return await response.json(); - }, - [] - ); + return await response.json(); + }, []); - // Create portal + /** Create portal */ const createDevPortal = useCallback( async (payload: CreatePortalPayload): Promise => { const { token, baseUrl } = getApiConfig(); const response = await fetch(`${baseUrl}/api/v1/devportals`, { - method: "POST", + method: 'POST', headers: { Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to create devportal: ${response.status} ${response.statusText} ${errorBody}` - ); + const errorMessage = await parseApiError(response, 'create devportal'); + throw new Error(errorMessage); } return await response.json(); @@ -122,28 +139,26 @@ export const useDevPortalsApi = () => { [] ); - // Update portal + /** Update portal */ const updateDevPortal = useCallback( - async ( - uuid: string, - portalData: UpdatePortalData - ): Promise => { + async (uuid: string, updates: UpdatePortalPayload): Promise => { const { token, baseUrl } = getApiConfig(); const response = await fetch(`${baseUrl}/api/v1/devportals/${uuid}`, { - method: "PUT", + method: 'PUT', headers: { Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, - body: JSON.stringify(portalData), + body: JSON.stringify(updates), }); if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to update devportal ${uuid}: ${response.status} ${response.statusText} ${errorBody}` + const errorMessage = await parseApiError( + response, + `update devportal (${uuid})` ); + throw new Error(errorMessage); } return await response.json(); @@ -151,54 +166,45 @@ export const useDevPortalsApi = () => { [] ); - // Delete portal - const deleteDevPortal = useCallback( - async (uuid: string): Promise => { - const { token, baseUrl } = getApiConfig(); - - const response = await fetch(`${baseUrl}/api/v1/devportals/${uuid}`, { - method: "DELETE", - headers: { - Authorization: `Bearer ${token}`, - }, - }); + /** Delete portal */ + const deleteDevPortal = useCallback(async (uuid: string): Promise => { + const { token, baseUrl } = getApiConfig(); - if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to delete devportal ${uuid}: ${response.status} ${response.statusText} ${errorBody}` - ); - } - }, - [] - ); + const response = await fetch(`${baseUrl}/api/v1/devportals/${uuid}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); - // ACTION: Activate portal + if (!response.ok) { + const errorMessage = await parseApiError(response, 'delete devportal'); + throw new Error(errorMessage); + } + }, []); + /** Activate portal */ const activateDevPortal = useCallback(async (uuid: string): Promise => { const { token, baseUrl } = getApiConfig(); - const response = await fetch(`${baseUrl}/api/v1/devportals/${uuid}/activate`, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - }, - }); + const response = await fetch( + `${baseUrl}/api/v1/devportals/${uuid}/activate`, + { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + } + ); if (!response.ok) { - const errorBody = await response.text(); - throw new Error( - `Failed to activate devportal ${uuid}: ${response.status} ${response.statusText} ${errorBody}` - ); + const errorMessage = await parseApiError(response, 'activate devportal'); + throw new Error(errorMessage); } - }, []); - // Export all API operations + // Activation successful, no response body expected + }, []); return { - createDevPortal, fetchDevPortals, fetchDevPortal, + createDevPortal, updateDevPortal, deleteDevPortal, activateDevPortal, diff --git a/portals/management-portal/src/pages/PortalManagement.tsx b/portals/management-portal/src/pages/PortalManagement.tsx index 90956ac35..628b4fe01 100644 --- a/portals/management-portal/src/pages/PortalManagement.tsx +++ b/portals/management-portal/src/pages/PortalManagement.tsx @@ -1,20 +1,35 @@ // src/pages/PortalManagement.tsx -import React, { useCallback, useMemo, useState } from "react"; -import { useNavigate, useParams, useLocation } from "react-router-dom"; -import { Box, Typography } from "@mui/material"; -import { DevPortalProvider, useDevPortals } from "../context/DevPortalContext"; -import { useNotifications } from "../context/NotificationContext"; -import ErrorBoundary from "../components/ErrorBoundary"; -import PortalList from "./portals/PortalList"; -import PortalForm from "./portals/PortalForm"; -import ThemeContainer from "./portals/ThemeContainer"; -import { PORTAL_CONSTANTS } from "../constants/portal"; -import { getPortalMode, getPortalIdFromPath, navigateToPortalList, navigateToPortalCreate, navigateToPortalTheme, navigateToPortalEdit } from "../utils/portalUtils"; -import type { PortalManagementProps, PortalFormData } from "../types/portal"; +import React, { useCallback, useMemo, useState } from 'react'; +import { useNavigate, useParams, useLocation } from 'react-router-dom'; +import { Box, Typography } from '@mui/material'; +import { DevPortalProvider, useDevPortals } from '../context/DevPortalContext'; +import { useNotifications } from '../context/NotificationContext'; +import ErrorBoundary from '../components/ErrorBoundary'; +import PortalList from './portals/PortalList'; +import PortalForm from './portals/PortalForm'; +import ThemeContainer from './portals/ThemeContainer'; +import { PORTAL_CONSTANTS } from '../constants/portal'; +import { + getPortalMode, + getPortalIdFromPath, + navigateToPortalList, + navigateToPortalCreate, + navigateToPortalTheme, + navigateToPortalEdit, +} from '../utils/portalUtils'; +import type { CreatePortalPayload, UpdatePortalPayload } from '../hooks/devportals'; + +type PortalManagementProps = Record; const PortalManagementContent: React.FC = () => { // Context access (from DevPortalProvider) - const { devportals, loading, error, createDevPortal, updateDevPortal, activateDevPortal } = useDevPortals(); + const { + devportals, + loading, + createDevPortal, + updateDevPortal, + activateDevPortal, + } = useDevPortals(); // Router access const navigate = useNavigate(); @@ -24,14 +39,17 @@ const PortalManagementContent: React.FC = () => { const [creatingPortal, setCreatingPortal] = useState(false); const { showNotification } = useNotifications(); - const mode = useMemo(() => getPortalMode(location.pathname), [location.pathname]); + const mode = useMemo( + () => getPortalMode(location.pathname), + [location.pathname] + ); const selectedPortalId = useMemo( () => getPortalIdFromPath(location.pathname) || params.portalId || null, [location.pathname, params.portalId] ); const selectedPortal = useMemo( - () => devportals.find(p => p.uuid === selectedPortalId), + () => devportals.find((p) => p.uuid === selectedPortalId), [devportals, selectedPortalId] ); @@ -46,63 +64,90 @@ const PortalManagementContent: React.FC = () => { ); const navigateToTheme = useCallback( - (portalId: string) => navigateToPortalTheme(navigate, location.pathname, portalId), + (portalId: string) => + navigateToPortalTheme(navigate, location.pathname, portalId), [navigate, location.pathname] ); const navigateToEdit = useCallback( - (portalId: string) => navigateToPortalEdit(navigate, location.pathname, portalId), + (portalId: string) => + navigateToPortalEdit(navigate, location.pathname, portalId), [navigate, location.pathname] ); - const handlePortalClick = useCallback((portalId: string) => { - navigateToTheme(portalId); - }, [navigateToTheme]); - - const handlePortalActivate = useCallback(async (portalId: string) => { - try { - await activateDevPortal(portalId); - showNotification(PORTAL_CONSTANTS.MESSAGES.PORTAL_ACTIVATED, 'success'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : PORTAL_CONSTANTS.MESSAGES.ACTIVATION_FAILED; - showNotification(errorMessage, 'error'); - } - }, [activateDevPortal, showNotification]); - - const handlePortalEdit = useCallback((portalId: string) => { - navigateToEdit(portalId); - }, [navigateToEdit]); - - const handleCreatePortal = React.useCallback(async (formData: PortalFormData) => { - setCreatingPortal(true); - try { - const createdPortal = await createDevPortal(formData); - const activationMessage = createdPortal.isActive - ? 'Developer portal created and activated successfully.' - : 'Developer portal created successfully, but not activated.'; - showNotification(activationMessage, 'success'); - // Navigate to theme screen for the new portal - navigateToTheme(createdPortal.uuid); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : PORTAL_CONSTANTS.MESSAGES.CREATION_FAILED; - showNotification(errorMessage, 'error'); - } finally { - setCreatingPortal(false); - } - }, [createDevPortal, navigateToTheme, showNotification]); - - const handleUpdatePortal = useCallback(async (formData: PortalFormData) => { - if (!selectedPortalId) return; - - try { - await updateDevPortal(selectedPortalId, formData); - showNotification('Developer Portal updated successfully.', 'success'); - navigateToList(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : PORTAL_CONSTANTS.MESSAGES.CREATION_FAILED; - showNotification(errorMessage, 'error'); - } - }, [selectedPortalId, updateDevPortal, navigateToList, showNotification]); + const handlePortalClick = useCallback( + (portalId: string) => { + navigateToTheme(portalId); + }, + [navigateToTheme] + ); + + const handlePortalActivate = useCallback( + async (portalId: string) => { + try { + await activateDevPortal(portalId); + showNotification(PORTAL_CONSTANTS.MESSAGES.PORTAL_ACTIVATED, 'success'); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PORTAL_CONSTANTS.MESSAGES.ACTIVATION_FAILED; + showNotification(errorMessage, 'error'); + } + }, + [activateDevPortal, showNotification] + ); + + const handlePortalEdit = useCallback( + (portalId: string) => { + navigateToEdit(portalId); + }, + [navigateToEdit] + ); + + const handleCreatePortal = React.useCallback( + async (formData: CreatePortalPayload | UpdatePortalPayload) => { + const fullData = formData as CreatePortalPayload; + setCreatingPortal(true); + try { + const createdPortal = await createDevPortal(fullData); + const activationMessage = createdPortal.isEnabled + ? 'Developer portal created and enabled successfully.' + : 'Developer portal created successfully, but not enabled.'; + showNotification(activationMessage, 'success'); + // Navigate to theme screen for the new portal + navigateToTheme(createdPortal.uuid); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PORTAL_CONSTANTS.MESSAGES.CREATION_FAILED; + showNotification(errorMessage, 'error'); + } finally { + setCreatingPortal(false); + } + }, + [createDevPortal, navigateToTheme, showNotification] + ); + + const handleUpdatePortal = useCallback( + async (formData: UpdatePortalPayload) => { + if (!selectedPortalId) return; + + try { + await updateDevPortal(selectedPortalId, formData); + showNotification('Developer Portal updated successfully.', 'success'); + navigateToList(); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PORTAL_CONSTANTS.MESSAGES.UPDATE_FAILED; + showNotification(errorMessage, 'error'); + } + }, + [selectedPortalId, updateDevPortal, navigateToList, showNotification] + ); const renderContent = () => { switch (mode) { @@ -111,7 +156,6 @@ const PortalManagementContent: React.FC = () => { = () => { ); @@ -162,7 +211,7 @@ const PortalManagementContent: React.FC = () => { return ( - + {/* Header */} {mode === PORTAL_CONSTANTS.MODES.LIST && ( @@ -173,7 +222,8 @@ const PortalManagementContent: React.FC = () => { - Define visibility of your portal and publish your first API. You can modify your selections later. + Define visibility of your portal and publish your first API. You + can modify your selections later. )} diff --git a/portals/management-portal/src/pages/apis/ApiPublish.tsx b/portals/management-portal/src/pages/apis/ApiPublish.tsx index 75044857b..df38afd4b 100644 --- a/portals/management-portal/src/pages/apis/ApiPublish.tsx +++ b/portals/management-portal/src/pages/apis/ApiPublish.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React from 'react'; import { Box, CircularProgress, @@ -6,69 +6,85 @@ import { Stack, Typography, Alert, -} from "@mui/material"; -import { useSearchParams } from "react-router-dom"; -import { useApisContext } from "../../context/ApiContext"; -import type { ApiSummary } from "../../hooks/apis"; - -import { DevPortalProvider, useDevPortals } from "../../context/DevPortalContext"; -import type { Portal } from "../../hooks/devportals"; -import { ApiPublishProvider, useApiPublishing} from "../../context/ApiPublishContext"; -import { Card, CardActionArea } from "../../components/src/components/Card"; -import { Button } from "../../components/src/components/Button"; -import { SearchBar } from "../../components/src/components/SearchBar"; -import DevPortalDeployCard from "./ApiPublish/DevPortalDeployCard"; -import DevPortalPickTable from "./ApiPublish/DevPortalPickTable"; -import ApiPublishModal from "./ApiPublish/ApiPublishModal"; -import type { ApiPublicationWithPortal } from "../../hooks/apiPublish"; -import { relativeTime } from "../overview/utils"; - -type Mode = "empty" | "pick" | "cards"; +} from '@mui/material'; +import { useSearchParams } from 'react-router-dom'; +import { useApisContext } from '../../context/ApiContext'; +import type { ApiSummary } from '../../hooks/apis'; + +import { + DevPortalProvider, + useDevPortals, +} from '../../context/DevPortalContext'; +import type { Portal } from '../../hooks/devportals'; +import { + ApiPublishProvider, + useApiPublishing, +} from '../../context/ApiPublishContext'; +import { Card, CardActionArea } from '../../components/src/components/Card'; +import { Button } from '../../components/src/components/Button'; +import { SearchBar } from '../../components/src/components/SearchBar'; +import DevPortalDeployCard from './ApiPublish/DevPortalDeployCard'; +import DevPortalPickTable from './ApiPublish/DevPortalPickTable'; +import ApiPublishModal from './ApiPublish/ApiPublishModal'; +import type { ApiPublicationWithPortal } from '../../hooks/apiPublish'; +import { relativeTime } from '../overview/utils'; + +type Mode = 'empty' | 'pick' | 'cards'; /* ---------------- page content ---------------- */ const DevelopContent: React.FC = () => { const { fetchApiById, selectApi } = useApisContext(); - const { devportals, refreshDevPortals, loading: devportalsLoading } = useDevPortals(); - const { publishedApis, loading: publishingLoading, refreshPublishedApis, publishApiToDevPortal } = useApiPublishing(); + const { + devportals, + refreshDevPortals, + loading: devportalsLoading, + } = useDevPortals(); + const { + publishedApis, + refreshPublishedApis, + publishApiToDevPortal, + clearPublishedApis, + } = useApiPublishing(); const [searchParams, setSearchParams] = useSearchParams(); const searchParamsKey = searchParams.toString(); - const apiIdFromQuery = searchParams.get("apiId"); + const apiIdFromQuery = searchParams.get('apiId'); const [api, setApi] = React.useState(null); const [loading, setLoading] = React.useState(true); - // Local published state - // const [publishedForApi, setPublishedForApi] = React.useState([]); - // UI mode + selection - const [mode, setMode] = React.useState("empty"); - const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); + const [mode, setMode] = React.useState('empty'); + const [selectedIds, setSelectedIds] = React.useState>( + () => new Set() + ); const [stagedIds, setStagedIds] = React.useState([]); - const [query, setQuery] = React.useState(""); + const [query, setQuery] = React.useState(''); // Publishing state - const [publishingIds, setPublishingIds] = React.useState>(() => new Set()); + const [publishingIds, setPublishingIds] = React.useState>( + () => new Set() + ); // Modal state const [publishModalOpen, setPublishModalOpen] = React.useState(false); - const [selectedPortalForPublish, setSelectedPortalForPublish] = React.useState(null); + const [selectedPortalForPublish, setSelectedPortalForPublish] = + React.useState(null); const effectiveApiId = React.useMemo(() => { if (apiIdFromQuery) return apiIdFromQuery; - return ""; // No current API fallback + return ''; // No current API fallback }, [apiIdFromQuery]); React.useEffect(() => { if (apiIdFromQuery || !api) return; const next = new URLSearchParams(searchParamsKey); - next.set("apiId", api.id); + next.set('apiId', api.id); setSearchParams(next, { replace: true }); }, [apiIdFromQuery, api?.id, searchParamsKey, setSearchParams]); React.useEffect(() => { - if (!effectiveApiId) { setLoading(false); setApi(null); @@ -85,27 +101,36 @@ const DevelopContent: React.FC = () => { // Refresh devportals and published APIs await Promise.all([ refreshDevPortals(), - refreshPublishedApis(effectiveApiId).then((pubs) => { + (async () => { + clearPublishedApis(); + const pubs = await refreshPublishedApis(effectiveApiId); if (pubs.length > 0) { - setStagedIds(pubs.map(p => p.uuid)); - setMode("cards"); + setStagedIds(pubs.map((p) => p.uuid)); + setMode('cards'); } else { - setMode("empty"); + setMode('empty'); } - }) + })(), ]); } catch (error) { console.error('Failed to load API data:', error); setApi(null); - setMode("empty"); + setMode('empty'); } finally { setLoading(false); } })(); - }, [effectiveApiId, fetchApiById, selectApi, refreshDevPortals, refreshPublishedApis]); + }, [ + effectiveApiId, + fetchApiById, + selectApi, + refreshDevPortals, + refreshPublishedApis, + clearPublishedApis, + ]); // Get published APIs for current API - const apiPublished = publishedApis[effectiveApiId] || []; + const apiPublished = publishedApis; // Create published map const publishedMap = React.useMemo(() => { @@ -155,8 +180,7 @@ const DevelopContent: React.FC = () => { }; const areAllSelected = - devportals.length > 0 && - devportals.every((p) => selectedIds.has(p.uuid)); + devportals.length > 0 && devportals.every((p) => selectedIds.has(p.uuid)); const isSomeSelected = selectedIds.size > 0 && @@ -185,7 +209,7 @@ const DevelopContent: React.FC = () => { if (selectedIds.size === 0) return; // setStagedIds(Array.from(selectedIds)); setStagedIds((prev) => [...new Set([...prev, ...Array.from(selectedIds)])]); - setMode("cards"); + setMode('cards'); }; // ---------- Rendering helpers ---------- @@ -193,22 +217,22 @@ const DevelopContent: React.FC = () => { setMode("pick")} - testId={""} + onClick={() => setMode('pick')} + testId={''} sx={{ - height: "100%", - display: "flex", - alignItems: "center", - justifyContent: "center", + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', }} > @@ -229,49 +253,51 @@ const DevelopContent: React.FC = () => { ); const renderCards = () => { - const selectedPortals = stagedIds.map((id) => { - const published = (publishedApis[effectiveApiId] || []).find(p => p.uuid === id); - if (published) return published; - const portal = devportalsById.get(id); - if (portal) { - // Properly construct ApiPublicationWithPortal from Portal - return { - uuid: portal.uuid, - name: portal.name, - identifier: portal.identifier, - description: portal.description, - portalUrl: portal.uiUrl || "", - apiUrl: portal.apiUrl || "", - hostname: portal.hostname || "", - isActive: portal.isActive || false, - createdAt: portal.createdAt || "", - updatedAt: portal.updatedAt || "", - associatedAt: "", - isPublished: false, - publication: { - status: "UNPUBLISHED", - apiVersion: "", - sandboxEndpoint: "", - productionEndpoint: "", - publishedAt: "", - updatedAt: "", - }, - } as ApiPublicationWithPortal; - } - return null; - }).filter((p): p is ApiPublicationWithPortal => Boolean(p)); + const selectedPortals = stagedIds + .map((id) => { + const published = publishedApis.find((p) => p.uuid === id); + if (published) return published; + const portal = devportalsById.get(id); + if (portal) { + // Properly construct ApiPublicationWithPortal from Portal + return { + uuid: portal.uuid, + name: portal.name, + identifier: portal.identifier, + description: portal.description, + portalUrl: portal.uiUrl || '', + apiUrl: portal.apiUrl || '', + hostname: portal.hostname || '', + isEnabled: portal.isEnabled || false, + createdAt: portal.createdAt || '', + updatedAt: portal.updatedAt || '', + associatedAt: '', + isPublished: false, + publication: { + status: 'UNPUBLISHED', + apiVersion: '', + sandboxEndpoint: '', + productionEndpoint: '', + publishedAt: '', + updatedAt: '', + }, + } as ApiPublicationWithPortal; + } + return null; + }) + .filter((p): p is ApiPublicationWithPortal => Boolean(p)); // filter by SearchBar query const q = query.trim().toLowerCase(); const filteredPortals = q ? selectedPortals.filter((p) => { - const text = `${p.name || ""} ${p.description || ""} ${p.portalUrl || ""}`; + const text = `${p.name || ''} ${p.description || ''} ${p.portalUrl || ''}`; return text.toLowerCase().includes(q); }) : selectedPortals; return selectedPortals.length === 0 ? ( - + No dev portals selected. Click "Select Dev Portals" to choose. @@ -299,7 +325,7 @@ const DevelopContent: React.FC = () => { minWidth: 500, maxWidth: 700, flex: 1, - justifyContent: "flex-end", + justifyContent: 'flex-end', }} > @@ -314,9 +340,7 @@ const DevelopContent: React.FC = () => { color="secondary" /> - + @@ -365,9 +389,9 @@ const DevelopContent: React.FC = () => { return ( - {mode === "empty" && renderEmptyTile()} + {mode === 'empty' && renderEmptyTile()} - {mode === "pick" && ( + {mode === 'pick' && ( { onClear={clearSelection} onAdd={addSelection} publishedIds={Array.from(publishedMap.keys())} - onBack={() => setMode(stagedIds.length ? "cards" : "empty")} + onBack={() => setMode(stagedIds.length ? 'cards' : 'empty')} /> )} - {mode === "cards" && renderCards()} + {mode === 'cards' && renderCards()} {/* Publish Modal */} {selectedPortalForPublish && ( @@ -413,4 +437,4 @@ const ApiPublish: React.FC = () => ( ); -export default ApiPublish; \ No newline at end of file +export default ApiPublish; diff --git a/portals/management-portal/src/pages/apis/ApiPublish/ApiPublishModal.tsx b/portals/management-portal/src/pages/apis/ApiPublish/ApiPublishModal.tsx index 01aec881f..65afa6443 100644 --- a/portals/management-portal/src/pages/apis/ApiPublish/ApiPublishModal.tsx +++ b/portals/management-portal/src/pages/apis/ApiPublish/ApiPublishModal.tsx @@ -10,6 +10,7 @@ import { import { Button } from '../../../components/src/components/Button'; import { useApisContext } from '../../../context/ApiContext'; import { useNotifications } from '../../../context/NotificationContext'; +import { PORTAL_CONSTANTS } from '../../../constants/portal'; import type { ApiSummary } from '../../../hooks/apis'; import { buildPublishPayload } from './mapper'; import ApiPublishForm from './ApiPublishForm'; @@ -99,8 +100,9 @@ const ApiPublishModal: React.FC = ({ open, portal, api, onClose, onPublis }; const handleAddTag = () => { - if (newTag.trim() && !formData.tags?.includes(newTag.trim())) { - setFormData((prev: any) => ({ ...prev, tags: [...(prev.tags || []), newTag.trim()] })); + const trimmedTag = newTag.trim(); + if (trimmedTag && !formData.tags?.includes(trimmedTag)) { + setFormData((prev: any) => ({ ...prev, tags: [...(prev.tags || []), trimmedTag] })); setNewTag(''); } }; @@ -111,11 +113,11 @@ const ApiPublishModal: React.FC = ({ open, portal, api, onClose, onPublis const handleSubmit = async () => { if (!portal?.uuid) { - showNotification('No portal selected', 'error'); + showNotification(PORTAL_CONSTANTS.MESSAGES.NO_PORTAL_SELECTED, 'error'); return; } if (!formData.apiName || !formData.productionURL) { - showNotification('Please provide API Name and Production URL', 'error'); + showNotification(PORTAL_CONSTANTS.MESSAGES.PROVIDE_API_NAME_AND_URL, 'error'); return; } @@ -125,7 +127,7 @@ const ApiPublishModal: React.FC = ({ open, portal, api, onClose, onPublis showNotification(`Published to ${portal.name || 'portal'}`, 'success'); onClose(); } catch (err: any) { - showNotification(err?.message || 'Failed to publish', 'error'); + showNotification(err?.message || PORTAL_CONSTANTS.MESSAGES.PUBLISH_FAILED, 'error'); } }; diff --git a/portals/management-portal/src/pages/apis/ApiPublish/DevPortalDeployCard.tsx b/portals/management-portal/src/pages/apis/ApiPublish/DevPortalDeployCard.tsx index 9b951641e..a838b916d 100644 --- a/portals/management-portal/src/pages/apis/ApiPublish/DevPortalDeployCard.tsx +++ b/portals/management-portal/src/pages/apis/ApiPublish/DevPortalDeployCard.tsx @@ -1,19 +1,12 @@ -import React from "react"; -import { - Box, - Divider, - Grid, - Paper, - Typography, - Tooltip, -} from "@mui/material"; -import LaunchOutlinedIcon from "@mui/icons-material/LaunchOutlined"; -import { Button } from "../../../components/src/components/Button"; -import { IconButton } from "../../../components/src/components/IconButton"; -import { Chip } from "../../../components/src/components/Chip"; -import { PORTAL_CONSTANTS } from "../../../constants/portal"; -import BijiraDPLogo from "../../BijiraDPLogo.png"; -import type { ApiPublicationWithPortal } from "../../../hooks/apiPublish"; +import React from 'react'; +import { Box, Divider, Grid, Paper, Typography, Tooltip } from '@mui/material'; +import LaunchOutlinedIcon from '@mui/icons-material/LaunchOutlined'; +import { Button } from '../../../components/src/components/Button'; +import { IconButton } from '../../../components/src/components/IconButton'; +import { Chip } from '../../../components/src/components/Chip'; +import { PORTAL_CONSTANTS } from '../../../constants/portal'; +import BijiraDPLogo from '../../BijiraDPLogo.png'; +import type { ApiPublicationWithPortal } from '../../../hooks/apiPublish'; const Card: React.FC> = ({ children, @@ -28,9 +21,9 @@ const Card: React.FC> = ({ border: (t) => `1px solid ${t.palette.divider}`, width: 380, height: 230, // fixed height for consistency - display: "flex", - flexDirection: "column", - justifyContent: "space-between", + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', ...(props as any).sx, }} > @@ -42,7 +35,6 @@ type Props = { portal: ApiPublicationWithPortal; apiId: string; publishingIds: Set; - relativeTime: (d?: string | Date | null) => string; onPublish: (portal: ApiPublicationWithPortal) => void; }; @@ -55,25 +47,28 @@ const DevPortalDeployCard: React.FC = ({ const isPublished = portal.isPublished; const publication = portal.publication; - const description = portal.description || ""; + const description = portal.description || ''; - let status = "NOT_PUBLISHED"; + let status = 'NOT_PUBLISHED'; let success = false; if (isPublished) { - if (publication?.status === "published") { - status = "PUBLISHED"; + if (publication?.status === 'published') { + status = 'PUBLISHED'; success = true; - } else if (publication?.status === "failed") { - status = "FAILED"; + } else if (publication?.status === 'failed') { + status = 'FAILED'; + success = false; + } else if (publication?.status) { + status = 'UNKNOWN'; success = false; } else { - status = "PUBLISHED"; // default to published if status is unknown - success = true; + status = 'PENDING'; + success = false; } } - const title = portal.name || "Dev Portal"; + const title = portal.name || 'Dev Portal'; const isPublishingThis = publishingIds.has(portal.uuid); const portalUrl = portal.portalUrl || portal.apiUrl; @@ -81,176 +76,183 @@ const DevPortalDeployCard: React.FC = ({ + {/* Logo block */} + - {/* Logo block */} - {/* {logoSrc ? ( */} - - {/* ) : null} */} - + component="img" + src={BijiraDPLogo} + alt="Bijira Dev Portal Logo" + sx={{ width: 90, height: 90, objectFit: 'contain' }} + /> + - {/* Title + description + URL */} - - - - {title} - - + {/* Title + description + URL */} + + + + {title} + + - + {description} + + + + - {description} - - - - - + + + + { + e.stopPropagation(); + if (portalUrl) { + window.open(portalUrl, '_blank', 'noopener,noreferrer'); + } }} - title={portalUrl} + aria-label={PORTAL_CONSTANTS.ARIA_LABELS.OPEN_PORTAL_URL} + disabled={!portalUrl} > - {portalUrl} - - - - - { - e.stopPropagation(); - window.open(portalUrl, "_blank", "noopener,noreferrer"); - }} - aria-label={PORTAL_CONSTANTS.ARIA_LABELS.OPEN_PORTAL_URL} - > - - - - - + + + + - - {/* right spacer */} - - {/* Divider */} - - {isPublished && ( - - !isPublished - ? t.palette.mode === "dark" - ? "rgba(107,114,128,0.12)" // neutral gray for not published - : "#F4F4F5" - : status === "FAILED" - ? t.palette.mode === "dark" - ? "rgba(239,68,68,0.12)" - : "#FDECEC" + {/* right spacer */} + + + + {/* Divider */} + + {isPublished && ( + + status === 'FAILED' + ? t.palette.mode === 'dark' + ? 'rgba(239,68,68,0.12)' + : '#FDECEC' + : success + ? t.palette.mode === 'dark' + ? 'rgba(16,185,129,0.12)' + : '#E8F7EC' + : status === 'PENDING' + ? t.palette.mode === 'dark' + ? 'rgba(59,130,246,0.12)' + : '#EFF6FF' + : t.palette.mode === 'dark' + ? 'rgba(251,191,36,0.12)' + : '#FFFBEB', + border: (t) => + `1px solid ${ + status === 'FAILED' + ? t.palette.error.light : success - ? t.palette.mode === "dark" - ? "rgba(16,185,129,0.12)" - : "#E8F7EC" - : t.palette.mode === "dark" - ? "rgba(239,68,68,0.12)" - : "#FDECEC", - border: (t) => - `1px solid ${ - !isPublished - ? t.palette.divider - : status === "FAILED" - ? t.palette.error.light - : success - ? "#D8EEDC" - : t.palette.error.light - }`, - borderRadius: 2, - px: 2, - py: 1.25, - mb: 2, - display: "flex", - alignItems: "center", - justifyContent: "space-between", - }} - > - Publish Status - - - )} + ? '#D8EEDC' + : status === 'PENDING' + ? t.palette.info.light + : t.palette.warning.light + }`, + borderRadius: 2, + px: 2, + py: 1.25, + mb: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }} + > + Publish Status + + + )} {/* Publish / Re-publish action (pinned to bottom) */} {!isPublished && ( @@ -260,7 +262,7 @@ const DevPortalDeployCard: React.FC = ({ disabled={!apiId || isPublishingThis} onClick={() => onPublish(portal)} > - {isPublishingThis ? "Adding…" : "Add API"} + {isPublishingThis ? 'Adding…' : 'Add API'} )} diff --git a/portals/management-portal/src/pages/apis/ApiPublish/DevPortalPickTable.tsx b/portals/management-portal/src/pages/apis/ApiPublish/DevPortalPickTable.tsx index 3477cd880..6cb65a317 100644 --- a/portals/management-portal/src/pages/apis/ApiPublish/DevPortalPickTable.tsx +++ b/portals/management-portal/src/pages/apis/ApiPublish/DevPortalPickTable.tsx @@ -1,19 +1,19 @@ -import React from "react"; -import { Box, Typography } from "@mui/material"; -import LaunchIcon from "@mui/icons-material/Launch"; -import type { Portal } from "../../../hooks/devportals"; -import { Tooltip } from "../../../components/src/components/Tooltip"; -import { Checkbox } from "../../../components/src/components/Checkbox"; -import { Chip } from "../../../components/src/components/Chip"; -import { TableRowNoData } from "../../../components/src/components/TableDefault/TableRowNoData"; -import { Button } from "../../../components/src/components/Button"; -import { TableBody } from "../../../components/src/components/TableDefault/TableBody/TableBody"; -import { TableDefault } from "../../../components/src/components/TableDefault"; -import { TableHead } from "../../../components/src/components/TableDefault/TableHead/TableHead"; -import { TableRow } from "../../../components/src/components/TableDefault/TableRow/TableRow"; -import { TableCell } from "../../../components/src/components/TableDefault/TableCell/TableCell"; -import { TableContainer } from "../../../components/src/components/TableDefault/TableContainer/TableContainer"; -import ArrowLeftLong from "../../../components/src/Icons/generated/ArrowLeftLong"; +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import LaunchIcon from '@mui/icons-material/Launch'; +import type { Portal } from '../../../hooks/devportals'; +import { Tooltip } from '../../../components/src/components/Tooltip'; +import { Checkbox } from '../../../components/src/components/Checkbox'; +import { Chip } from '../../../components/src/components/Chip'; +import { TableRowNoData } from '../../../components/src/components/TableDefault/TableRowNoData'; +import { Button } from '../../../components/src/components/Button'; +import { TableBody } from '../../../components/src/components/TableDefault/TableBody/TableBody'; +import { TableDefault } from '../../../components/src/components/TableDefault'; +import { TableHead } from '../../../components/src/components/TableDefault/TableHead/TableHead'; +import { TableRow } from '../../../components/src/components/TableDefault/TableRow/TableRow'; +import { TableCell } from '../../../components/src/components/TableDefault/TableCell/TableCell'; +import { TableContainer } from '../../../components/src/components/TableDefault/TableContainer/TableContainer'; +import ArrowLeftLong from '../../../components/src/Icons/generated/ArrowLeftLong'; type Props = { portals: Portal[]; @@ -35,7 +35,7 @@ const headerOverline = { opacity: 0.7 } as const; // Keep header widths in sync with the body grid const GRID = { checkboxColPx: 56, - nameColWidth: "20%", // reduced to give more space to URL + nameColWidth: '20%', // reduced to give more space to URL nameColMinPx: 180, descriptionColPx: 400, // fixed width for description uiUrlColPx: 250, // increased for better URL display @@ -58,9 +58,9 @@ const DevPortalPickTable: React.FC = ({ return Array.isArray(publishedIds) ? new Set(publishedIds) : publishedIds; }, [publishedIds]); - // Only show NOT published portals + // Only show NOT published AND enabled portals const visiblePortals = React.useMemo( - () => portals.filter((p) => !publishedSet.has(p.uuid)), + () => portals.filter((p) => !publishedSet.has(p.uuid) && p.isEnabled), [portals, publishedSet] ); @@ -123,13 +123,13 @@ const DevPortalPickTable: React.FC = ({ colSpan={6} sx={{ p: 0, - borderBottom: "none", - backgroundColor: "transparent", + borderBottom: 'none', + backgroundColor: 'transparent', }} > = ({ ${GRID.uiUrlColPx}px ${GRID.visibilityColPx}px `, - alignItems: "center", + alignItems: 'center', columnGap: 2, px: 2, // <-- matches body pill padding py: 1, // subtle vertical breathing room @@ -155,7 +155,7 @@ const DevPortalPickTable: React.FC = ({ }} onClick={(e: any) => e.stopPropagation()} disableRipple - inputProps={{ "aria-label": "select all dev portals" }} + inputProps={{ 'aria-label': 'select all dev portals' }} testId="table-head" /> @@ -191,26 +191,26 @@ const DevPortalPickTable: React.FC = ({ ) : ( visiblePortals.map((portal, idx) => { const isChecked = selectedIds.has(portal.uuid); - const title = portal.name || "Dev Portal"; - const initial = (title || "?").trim().charAt(0).toUpperCase(); + const title = portal.name || 'Dev Portal'; + const initial = (title || '?').trim().charAt(0).toUpperCase(); return ( onToggleRow(portal.uuid)} role="button" sx={{ - display: "grid", + display: 'grid', gridTemplateColumns: ` ${GRID.checkboxColPx}px minmax(${GRID.nameColMinPx}px, ${GRID.nameColWidth}) @@ -218,29 +218,29 @@ const DevPortalPickTable: React.FC = ({ ${GRID.uiUrlColPx}px ${GRID.visibilityColPx}px `, - alignItems: "center", + alignItems: 'center', columnGap: 2, px: 2, py: 1.25, borderRadius: 2, - backgroundColor: "grey.50", + backgroundColor: 'grey.50', boxShadow: - "0 1px 2px rgba(16,24,40,0.04), 0 1px 3px rgba(16,24,40,0.06)", - border: "1px solid", + '0 1px 2px rgba(16,24,40,0.04), 0 1px 3px rgba(16,24,40,0.06)', + border: '1px solid', borderColor: isChecked - ? "primary.light" - : "divider", - cursor: "pointer", + ? 'primary.light' + : 'divider', + cursor: 'pointer', transition: - "background-color 120ms ease, border-color 120ms ease", - "&:hover": { backgroundColor: "grey.100" }, + 'background-color 120ms ease, border-color 120ms ease', + '&:hover': { backgroundColor: 'grey.100' }, }} > {/* 1) Checkbox */} @@ -260,8 +260,8 @@ const DevPortalPickTable: React.FC = ({ {/* 2) Name (with avatar initial) */} = ({ sx={{ width: 35, height: 35, - borderRadius: "50%", - display: "grid", - placeItems: "center", - backgroundColor: "grey.100", - color: "text.primary", + borderRadius: '50%', + display: 'grid', + placeItems: 'center', + backgroundColor: 'grey.100', + color: 'text.primary', fontSize: 12, fontWeight: 700, - flex: "0 0 auto", + flex: '0 0 auto', }} aria-hidden > @@ -295,14 +295,20 @@ const DevPortalPickTable: React.FC = ({ {/* 3) Description */} - + - {portal.description || ""} + {portal.description || ''} @@ -311,19 +317,23 @@ const DevPortalPickTable: React.FC = ({ {portal.uiUrl ? ( { e.stopPropagation(); - window.open(portal.uiUrl, "_blank", "noopener,noreferrer"); + window.open( + portal.uiUrl, + '_blank', + 'noopener,noreferrer' + ); }} title={portal.uiUrl} > @@ -332,16 +342,19 @@ const DevPortalPickTable: React.FC = ({ variant="body2" color="inherit" noWrap - sx={{ - textDecoration: "underline", - maxWidth: "180px" + sx={{ + textDecoration: 'underline', + maxWidth: '180px', }} > {portal.uiUrl} ) : ( - + )} @@ -350,9 +363,19 @@ const DevPortalPickTable: React.FC = ({ {/* 5) Visibility */} @@ -368,7 +391,7 @@ const DevPortalPickTable: React.FC = ({ sx={{ py: 0.75, border: 0, - background: "transparent", + background: 'transparent', }} /> diff --git a/portals/management-portal/src/pages/apis/ApiPublish/mapper.ts b/portals/management-portal/src/pages/apis/ApiPublish/mapper.ts index 62f6841f7..4322d19d4 100644 --- a/portals/management-portal/src/pages/apis/ApiPublish/mapper.ts +++ b/portals/management-portal/src/pages/apis/ApiPublish/mapper.ts @@ -1,4 +1,4 @@ -import type { ApiPublishPayload } from "../../../hooks/apiPublish"; +import type { ApiPublishPayload } from '../../../hooks/apiPublish'; type FormLike = { apiName: string; @@ -15,7 +15,10 @@ type FormLike = { subscriptionPolicies?: string[]; }; -export const buildPublishPayload = (form: FormLike, devPortalUUID: string): ApiPublishPayload => { +export const buildPublishPayload = ( + form: FormLike, + devPortalUUID: string +): ApiPublishPayload => { return { devPortalUUID, endPoints: { @@ -27,7 +30,7 @@ export const buildPublishPayload = (form: FormLike, devPortalUUID: string): ApiP apiDescription: form.apiDescription ?? '', visibility: form.visibility ?? 'PUBLIC', tags: form.tags ?? [], - labels: form.labels ?? [], + labels: ['default'], owners: { technicalOwner: form.technicalOwner ?? '', technicalOwnerEmail: form.technicalOwnerEmail ?? '', @@ -35,7 +38,7 @@ export const buildPublishPayload = (form: FormLike, devPortalUUID: string): ApiP businessOwnerEmail: form.businessOwnerEmail ?? '', }, }, - subscriptionPolicies: form.subscriptionPolicies ?? [], + subscriptionPolicies: ['Default'], }; }; diff --git a/portals/management-portal/src/pages/overview/utils.ts b/portals/management-portal/src/pages/overview/utils.ts index 5c31c54b7..c4abca142 100644 --- a/portals/management-portal/src/pages/overview/utils.ts +++ b/portals/management-portal/src/pages/overview/utils.ts @@ -13,7 +13,8 @@ export const twoLetters = (s: string) => { return `${first}${second}`; }; -export const relativeTime = (d: Date) => { +export const relativeTime = (d?: Date) => { + if (!d) return ""; const diff = Math.max(0, Date.now() - d.getTime()); const sec = Math.floor(diff / 1000); const min = Math.floor(sec / 60); diff --git a/portals/management-portal/src/pages/overview/widgets/APIPortalWidget.tsx b/portals/management-portal/src/pages/overview/widgets/APIPortalWidget.tsx index 79b6956aa..0ad9c30b6 100644 --- a/portals/management-portal/src/pages/overview/widgets/APIPortalWidget.tsx +++ b/portals/management-portal/src/pages/overview/widgets/APIPortalWidget.tsx @@ -1,17 +1,11 @@ -import { - Box, - Card, - CardContent, - Typography, - Stack, -} from "@mui/material"; -import { useNavigate } from "react-router-dom"; -import { useDevPortals } from "../../../context/DevPortalContext"; -import { IconButton } from "../../../components/src/components/IconButton"; -import LaunchOutlinedIcon from "@mui/icons-material/LaunchOutlined"; +import { Box, Card, CardContent, Typography, Stack } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { useDevPortals } from '../../../context/DevPortalContext'; +import { IconButton } from '../../../components/src/components/IconButton'; +import LaunchOutlinedIcon from '@mui/icons-material/LaunchOutlined'; // Import the logo image -import BijiraDPLogo from "../../BijiraDPLogo.png"; +import BijiraDPLogo from '../../BijiraDPLogo.png'; type Portal = { id: string; name: string; href: string; description?: string }; @@ -21,37 +15,46 @@ type Props = { portals?: Portal[]; }; -export default function APIPortalWidget({ height = 220, href, portals = [] }: Props) { +export default function APIPortalWidget({ + height = 220, + href, + portals = [], +}: Props) { const navigate = useNavigate(); const { devportals } = useDevPortals(); // Use provided portals or map devportals - const displayPortals = portals.length > 0 ? portals : devportals - .filter(portal => portal.isActive) - .map(portal => ({ - id: portal.uuid, - name: portal.name, - href: portal.uiUrl || '#', - description: portal.description || '' - })); + const displayPortals = + portals.length > 0 + ? portals + : devportals + // .filter((portal) => portal.isEnabled) + .map((portal) => ({ + id: portal.uuid, + name: portal.name, + href: portal.uiUrl || '#', + description: portal.description || '', + })); return ( - + Dev Portals - + {displayPortals.length === 0 ? ( @@ -62,7 +65,7 @@ export default function APIPortalWidget({ height = 220, href, portals = [] }: Pr sx={{ width: 48, height: 48, opacity: 0.6, mb: 1 }} /> - No API portals available + No Developer Portals available ) : ( @@ -71,8 +74,8 @@ export default function APIPortalWidget({ height = 220, href, portals = [] }: Pr ({ - display: "flex", - alignItems: "center", + display: 'flex', + alignItems: 'center', gap: 1.5, p: 1.5, borderRadius: 2, @@ -85,9 +88,9 @@ export default function APIPortalWidget({ height = 220, href, portals = [] }: Pr variant="body2" fontWeight={600} sx={{ - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', }} > {portal.name} @@ -97,10 +100,10 @@ export default function APIPortalWidget({ height = 220, href, portals = [] }: Pr variant="caption" color="#666666" sx={{ - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - display: "block", + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + display: 'block', mt: 0.25, }} > @@ -113,7 +116,11 @@ export default function APIPortalWidget({ height = 220, href, portals = [] }: Pr size="small" onClick={(e: React.MouseEvent) => { e.stopPropagation(); - window.open(portal.href, '_blank', 'noopener,noreferrer'); + window.open( + portal.href, + '_blank', + 'noopener,noreferrer' + ); }} sx={{ flexShrink: 0 }} > @@ -130,7 +137,7 @@ export default function APIPortalWidget({ height = 220, href, portals = [] }: Pr navigate(href)} > Manage Dev Portals → diff --git a/portals/management-portal/src/pages/portals/PortalCard.tsx b/portals/management-portal/src/pages/portals/PortalCard.tsx index 2a22947c2..ccab30426 100644 --- a/portals/management-portal/src/pages/portals/PortalCard.tsx +++ b/portals/management-portal/src/pages/portals/PortalCard.tsx @@ -1,28 +1,50 @@ // REPLACE your existing OptionCard with this -import * as React from "react"; -import { Box, Divider, Typography, Tooltip, CircularProgress } from "@mui/material"; -import LaunchOutlinedIcon from "@mui/icons-material/LaunchOutlined"; +import * as React from 'react'; +import { + Box, + Divider, + Typography, + Tooltip, + CircularProgress, +} from '@mui/material'; +import LaunchOutlinedIcon from '@mui/icons-material/LaunchOutlined'; import { Card, CardActionArea, CardContent, -} from "../../components/src/components/Card"; -import Edit from "../../components/src/Icons/generated/Edit"; -import { Link } from "../../components/src/components/Link"; -import { Button } from "../../components/src/components/Button"; -import { IconButton } from "../../components/src/components/IconButton"; -import { Chip } from "../../components/src/components/Chip"; -import { PORTAL_CONSTANTS } from "../../constants/portal"; -import type { PortalCardProps } from "../../types/portal"; +} from '../../components/src/components/Card'; +import Edit from '../../components/src/Icons/generated/Edit'; +import { Link } from '../../components/src/components/Link'; +import { Button } from '../../components/src/components/Button'; +import { IconButton } from '../../components/src/components/IconButton'; +import { Chip } from '../../components/src/components/Chip'; +import { PORTAL_CONSTANTS } from '../../constants/portal'; + +interface PortalCardProps { + title: string; + description: string; + enabled: boolean; + onClick: () => void; + logoSrc?: string; + logoAlt?: string; + portalUrl?: string; + userAuthLabel?: string; + authStrategyLabel?: string; + visibilityLabel?: string; + onEdit?: () => void; + onActivate?: () => void; + activating?: boolean; + testId?: string; +} const valuePill = ( text: string, - variant: "green" | "grey" | "red" = "grey" + variant: 'green' | 'grey' | 'red' = 'grey' ) => { - if (variant === "green") { + if (variant === 'green') { return ; } - if (variant === "red") { + if (variant === 'red') { return ; } return ; @@ -31,7 +53,7 @@ const valuePill = ( const PortalCard: React.FC = ({ title, description, - selected, // currently unused visually, but kept for parity + enabled, // indicates if the portal is activated/enabled onClick, logoSrc, logoAlt = PORTAL_CONSTANTS.DEFAULT_LOGO_ALT, @@ -44,15 +66,15 @@ const PortalCard: React.FC = ({ activating, }) => { return ( - - + + {/* Logo block */} @@ -61,23 +83,23 @@ const PortalCard: React.FC = ({ width: 100, height: 100, borderRadius: 2, - bgcolor: "transparent", - display: "flex", - alignItems: "center", - justifyContent: "center", + bgcolor: 'transparent', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', }} > {logoSrc ? ( @@ -85,7 +107,7 @@ const PortalCard: React.FC = ({ component="img" src={logoSrc} alt={logoAlt} - sx={{ width: 90, height: 90, objectFit: "contain" }} + sx={{ width: 90, height: 90, objectFit: 'contain' }} /> ) : null} @@ -93,7 +115,7 @@ const PortalCard: React.FC = ({ {/* Title + description + URL */} - + {title} @@ -114,7 +136,7 @@ const PortalCard: React.FC = ({ sx={{ mt: 0.5, lineHeight: 1.5, - color: "rgba(0,0,0,0.6)", + color: 'rgba(0,0,0,0.6)', maxWidth: 300, }} variant="body2" @@ -122,7 +144,7 @@ const PortalCard: React.FC = ({ {description} - + = ({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - mr: 0.5 + mr: 0.5, }} > { - if (!selected) { + if (!enabled) { e.preventDefault(); return; } @@ -162,18 +188,22 @@ const PortalCard: React.FC = ({ { - if (!selected) return; + if (!enabled) return; e.stopPropagation(); - window.open(portalUrl, "_blank", "noopener,noreferrer"); + window.open(portalUrl, '_blank', 'noopener,noreferrer'); }} aria-label={PORTAL_CONSTANTS.ARIA_LABELS.OPEN_PORTAL_URL} > @@ -192,47 +222,47 @@ const PortalCard: React.FC = ({ {/* Spec rows */} - + User authentication - {valuePill(userAuthLabel, "grey")} + {valuePill(userAuthLabel, 'grey')} Authentication strategy - {valuePill(authStrategyLabel, "grey")} + {valuePill(authStrategyLabel, 'grey')} Visibility - {valuePill(visibilityLabel, selected ? "green" : "grey")} + {valuePill(visibilityLabel, enabled ? 'green' : 'grey')} {/* CTA */} - {selected ? ( + {enabled ? (