Skip to content
Draft
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
22 changes: 22 additions & 0 deletions gravitee-apim-baros/src/components/ui/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { forwardRef, type ComponentPropsWithRef } from 'react';
import { cn } from '@baros/lib/utils';

const Input = forwardRef<HTMLInputElement, ComponentPropsWithRef<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);

Input.displayName = 'Input';

export { Input };
19 changes: 19 additions & 0 deletions gravitee-apim-baros/src/components/ui/label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { forwardRef, type ComponentPropsWithRef } from 'react';
import { cn } from '@baros/lib/utils';

const Label = forwardRef<HTMLLabelElement, ComponentPropsWithRef<'label'>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
),
);

Label.displayName = 'Label';

export { Label };
27 changes: 27 additions & 0 deletions gravitee-apim-baros/src/components/ui/select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { forwardRef, type ComponentPropsWithRef } from 'react';
import { cn } from '@baros/lib/utils';
import { ChevronDown } from 'lucide-react';

const Select = forwardRef<HTMLSelectElement, ComponentPropsWithRef<'select'>>(
({ className, children, ...props }, ref) => {
return (
<div className="relative">
<select
className={cn(
'flex h-9 w-full appearance-none rounded-md border border-input bg-transparent px-3 py-1 pr-8 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
>
{children}
</select>
<ChevronDown className="absolute right-2 top-2.5 h-4 w-4 opacity-50 pointer-events-none" />
</div>
);
},
);

Select.displayName = 'Select';

export { Select };
2 changes: 2 additions & 0 deletions gravitee-gamma/docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
6 changes: 5 additions & 1 deletion gravitee-gamma/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { Box, Settings, Search, Globe, type LucideIcon } from 'lucide-react';
import { Box, Settings, Search, Globe, Workflow, type LucideIcon } from 'lucide-react';
import { TopNav } from '@baros/components/layout/TopNav';
import { TopNavUser } from '@baros/components/layout/TopNavUser';
import { GraviteeLogo } from '@baros/components/layout/GraviteeLogo';
Expand All @@ -10,6 +10,7 @@ import type { AppOption } from '@baros/components/layout/AppDropdown';
import { AppBetaLayout } from './app-beta/AppBetaLayout';
import { DeveloperPortalLayout } from './developer-portal/DeveloperPortalLayout';
import { PortalHomepageRemote } from './developer-portal/PortalHomepageRemote';
import { PolicyStudioPage } from './policy-studio/PolicyStudioPage';

const INSTALLATION_APPLICATIONS_URL = '/management/v2/installation/applications';

Expand Down Expand Up @@ -57,6 +58,7 @@ const FALLBACK_APPS: AppOption[] = [
{ key: 'app-alpha', name: 'App Alpha', icon: Box },
{ key: 'app-beta', name: 'App Beta', icon: Settings },
{ key: 'developer-portal', name: 'Developer Portal', icon: Globe },
{ key: 'policy-studio/demo-api', name: 'Policy Studio', icon: Workflow },
];

const APP_ALPHA_ENTRY_URL = 'http://localhost:4201/remoteEntry.js';
Expand Down Expand Up @@ -92,6 +94,7 @@ function useActiveAppKey(): string {
if (pathname.startsWith('/app-alpha')) return 'app-alpha';
if (pathname.startsWith('/app-beta')) return 'app-beta';
if (pathname.startsWith('/developer-portal')) return 'developer-portal';
if (pathname.startsWith('/policy-studio')) return 'policy-studio/demo-api';
return '';
}

Expand Down Expand Up @@ -152,6 +155,7 @@ export function App() {
<Route path="/" element={<WelcomePage />} />
<Route path="/app-alpha/*" element={<AppAlpha />} />
<Route path="/app-beta/*" element={<AppBetaLayout />} />
<Route path="/policy-studio/:apiId" element={<PolicyStudioPage />} />
<Route path="/developer-portal" element={<DeveloperPortalLayout />}>
<Route path="homepage" element={<PortalHomepageRemote />} />
<Route index element={<Navigate to="homepage" replace />} />
Expand Down
244 changes: 244 additions & 0 deletions gravitee-gamma/src/app/policy-studio/PolicyStudioLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { useState, useMemo, useRef, type Dispatch } from 'react';
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragOverEvent,
type DragEndEvent,
} from '@dnd-kit/core';
import type { PolicyPlugin, StepKey, Phase, DragData } from './types';
import type { PolicyStudioState, PolicyStudioAction } from './policy-studio.reducer';
import { newStep, isPhaseCompatible } from './policy-studio.utils';
import { FlowsSidebar } from './components/FlowsSidebar';
import { FlowCanvas } from './components/FlowCanvas';
import { PolicyCatalog } from './components/PolicyCatalog';
import { StepConfigSheet } from './components/StepConfigSheet';
import { ErrorBoundary } from './components/ErrorBoundary';

interface PolicyStudioLayoutProps {
readonly state: PolicyStudioState;
readonly dispatch: Dispatch<PolicyStudioAction>;
readonly policies: PolicyPlugin[];
readonly apiType: string;
}

export function PolicyStudioLayout({ state, dispatch, policies, apiType }: PolicyStudioLayoutProps) {
const [selectedStepKey, setSelectedStepKey] = useState<StepKey | null>(null);
const [activeDragData, setActiveDragData] = useState<DragData | null>(null);
const [dropPhaseState, setDropPhaseState] = useState<{ phase: Phase; status: 'compatible' | 'incompatible' } | null>(null);

const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);

const policyMap = useMemo(
() => new Map(policies.map((p) => [p.id, p])),
[policies],
);

const requestCompatiblePolicies = useMemo(
() => policies.filter((p) => isPhaseCompatible(p, 'request', apiType)),
[policies, apiType],
);
const responseCompatiblePolicies = useMemo(
() => policies.filter((p) => isPhaseCompatible(p, 'response', apiType)),
[policies, apiType],
);

const currentFlow = state.flows[state.selectedFlowIndex] ?? null;
const selectedStep = selectedStepKey && currentFlow
? (currentFlow[selectedStepKey.phase] ?? [])[selectedStepKey.index] ?? null
: null;
const sheetOpen = selectedStepKey !== null;

const lastDropPhaseRef = useRef<string | null>(null);

function handleStepSelect(stepKey: StepKey) {
setSelectedStepKey(stepKey);
}

function handleSheetOpenChange(open: boolean) {
if (!open) setSelectedStepKey(null);
}

function handleDragStart(event: DragStartEvent) {
const data = event.active.data.current as DragData | undefined;
setActiveDragData(data ?? null);
}

function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) {
if (lastDropPhaseRef.current !== null) {
lastDropPhaseRef.current = null;
setDropPhaseState(null);
}
return;
}

const dragData = active.data.current as DragData | undefined;
if (!dragData || dragData.type !== 'policy') return;

const overData = over.data.current as { type?: string; phase?: Phase } | undefined;
if (overData?.type !== 'phase' || !overData.phase) return;

const policy = policyMap.get(dragData.policyId);
const status = isPhaseCompatible(policy, overData.phase, apiType) ? 'compatible' : 'incompatible';
const key = `${overData.phase}-${status}`;

if (lastDropPhaseRef.current === key) return;
lastDropPhaseRef.current = key;
setDropPhaseState({ phase: overData.phase, status });
}

function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveDragData(null);
setDropPhaseState(null);
lastDropPhaseRef.current = null;

if (!over) return;

const dragData = active.data.current as DragData | undefined;
if (!dragData) return;

if (dragData.type === 'policy') {
const overData = over.data.current as { type?: string; phase?: Phase; flowIndex?: number } | undefined;
if (overData?.type !== 'phase' || !overData.phase) return;

const policy = policyMap.get(dragData.policyId);
if (!isPhaseCompatible(policy, overData.phase, apiType)) return;

const step = newStep(dragData.policyId, policy?.name);
dispatch({
type: 'ADD_STEP',
flowIndex: overData.flowIndex ?? state.selectedFlowIndex,
phase: overData.phase,
step,
});
return;
}

if (dragData.type === 'step') {
if (active.id === over.id) return;

const flow = state.flows[dragData.flowIndex];
if (!flow) return;

const steps = flow[dragData.phase] ?? [];
const oldIndex = steps.findIndex((s) => s.id === active.id);
const newIndex = steps.findIndex((s) => s.id === over.id);

if (oldIndex === -1 || newIndex === -1) return;

dispatch({
type: 'REORDER_STEP',
flowIndex: dragData.flowIndex,
phase: dragData.phase,
from: oldIndex,
to: newIndex,
});
}
}

function handleDragCancel() {
setActiveDragData(null);
setDropPhaseState(null);
lastDropPhaseRef.current = null;
}

function getDropStateForPhase(phase: Phase): 'compatible' | 'incompatible' | null {
if (!dropPhaseState || dropPhaseState.phase !== phase) return null;
return dropPhaseState.status;
}

const dragOverlayLabel = useMemo(() => {
if (!activeDragData) return null;
if (activeDragData.type === 'policy') {
const policy = policyMap.get(activeDragData.policyId);
return policy?.name ?? activeDragData.policyId;
}
if (activeDragData.type === 'step') {
const flow = state.flows[activeDragData.flowIndex];
const step = flow?.[activeDragData.phase]?.[activeDragData.index];
return step?.name ?? step?.policy ?? 'Step';
}
return null;
}, [activeDragData, policyMap, state.flows]);

return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="flex h-full overflow-hidden border rounded-lg">
<ErrorBoundary fallbackLabel="Sidebar error">
<FlowsSidebar
flows={state.flows}
selectedIndex={state.selectedFlowIndex}
onSelect={(index) => dispatch({ type: 'SELECT_FLOW', index })}
onAdd={(flow) => dispatch({ type: 'ADD_FLOW', flow })}
onRemove={(index) => dispatch({ type: 'REMOVE_FLOW', index })}
onToggle={(index) => dispatch({ type: 'TOGGLE_FLOW_ENABLED', index })}
/>
</ErrorBoundary>

<ErrorBoundary fallbackLabel="Canvas error">
<FlowCanvas
flow={currentFlow}
flowIndex={state.selectedFlowIndex}
selectedStepKey={selectedStepKey}
requestCompatiblePolicies={requestCompatiblePolicies}
responseCompatiblePolicies={responseCompatiblePolicies}
onStepSelect={handleStepSelect}
onStepRemove={(phase, stepIndex) =>
dispatch({ type: 'REMOVE_STEP', flowIndex: state.selectedFlowIndex, phase, stepIndex })
}
onInsertStep={(phase, atIndex, policyId) => {
const policy = policyMap.get(policyId);
const step = newStep(policyId, policy?.name);
dispatch({ type: 'INSERT_STEP', flowIndex: state.selectedFlowIndex, phase, step, atIndex });
}}
requestDropState={getDropStateForPhase('request')}
responseDropState={getDropStateForPhase('response')}
/>
</ErrorBoundary>

<ErrorBoundary fallbackLabel="Catalog error">
<PolicyCatalog policies={policies} />
</ErrorBoundary>
</div>

<DragOverlay>
{dragOverlayLabel && (
<div className="rounded-md border bg-background px-3 py-1.5 text-sm shadow-lg">
{dragOverlayLabel}
</div>
)}
</DragOverlay>

<StepConfigSheet
open={sheetOpen}
onOpenChange={handleSheetOpenChange}
step={selectedStep}
phase={selectedStepKey?.phase ?? null}
onSave={(configuration) => {
if (!selectedStepKey) return;
dispatch({
type: 'UPDATE_STEP_CONFIG',
flowIndex: state.selectedFlowIndex,
phase: selectedStepKey.phase,
stepIndex: selectedStepKey.index,
configuration,
});
}}
/>
</DndContext>
);
}
Loading