Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/components/dashboards/perses/ToastProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useAlerts must be used within AlertProvider');
throw new Error('useToast must be used within ToastProvider');
}
return context;
};
Expand Down
24 changes: 24 additions & 0 deletions web/src/components/dashboards/perses/dashboard-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,27 @@ export const useUpdateDashboardMutation = (): UseMutationResult<
},
});
};

const createDashboard = async (entity: DashboardResource): Promise<DashboardResource> => {
const url = buildURL({
resource: resource,
project: entity.metadata.project,
});

return consoleFetchJSON.post(url, entity);
};

export const useCreateDashboardMutation = (
onSuccess?: (data: DashboardResource, variables: DashboardResource) => Promise<unknown> | unknown,
): UseMutationResult<DashboardResource, Error, DashboardResource> => {
const queryClient = useQueryClient();

return useMutation<DashboardResource, Error, DashboardResource>({
mutationKey: [resource],
mutationFn: (dashboard) => createDashboard(dashboard),
onSuccess: onSuccess,
onSettled: () => {
return queryClient.invalidateQueries({ queryKey: [resource] });
},
});
};
9 changes: 9 additions & 0 deletions web/src/components/dashboards/perses/dashboard-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { OCPDashboardToolbar } from './dashboard-toolbar';
import { useUpdateDashboardMutation } from './dashboard-api';
import { useTranslation } from 'react-i18next';
import { useToast } from './ToastProvider';
import { useSearchParams } from 'react-router-dom-v5-compat';

export interface DashboardAppProps {
dashboardResource: DashboardResource | EphemeralDashboardResource;
Expand Down Expand Up @@ -81,6 +82,14 @@ export const OCPDashboardApp = (props: DashboardAppProps): ReactElement => {
const { openDiscardChangesConfirmationDialog, closeDiscardChangesConfirmationDialog } =
useDiscardChangesConfirmationDialog();

const [searchParams] = useSearchParams();
const isEdit = searchParams.get('edit');
useEffect(() => {
if (isEdit === 'true') {
setEditMode(true);
}
}, [isEdit, setEditMode]);

const handleDiscardChanges = (): void => {
// Reset to the original spec and exit edit mode
if (originalDashboard) {
Expand Down
261 changes: 261 additions & 0 deletions web/src/components/dashboards/perses/dashboard-create-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import { useEffect, useState } from 'react';
import {
Button,
Dropdown,
DropdownList,
DropdownItem,
MenuToggle,
MenuToggleElement,
Modal,
ModalBody,
ModalHeader,
ModalFooter,
ModalVariant,
FormGroup,
Form,
TextInput,
} from '@patternfly/react-core';
import { usePerses } from './hooks/usePerses';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom-v5-compat';

import { DashboardResource } from '@perses-dev/core';
import { useCreateDashboardMutation } from './dashboard-api';
import { createNewDashboard } from './dashboard-utils';
import { useToast } from './ToastProvider';
import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective';
import { t_color_red_50 } from '@patternfly/react-tokens';
import { usePersesEditPermissions } from './dashboard-toolbar';
import { persesDashboardDataTestIDs } from '../../data-test';

export const DashboardCreateDialog: React.FunctionComponent = () => {
const { t } = useTranslation(process.env.I18N_NAMESPACE);
const navigate = useNavigate();
const { perspective } = usePerspective();
const { addAlert } = useToast();
const { canEdit, loading } = usePersesEditPermissions();
const disabled = !canEdit;

const { persesProjects } = usePerses();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [dashboardName, setDashboardName] = useState<string>('');
const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({});

const createDashboardMutation = useCreateDashboardMutation();

useEffect(() => {
if (persesProjects && persesProjects.length > 0 && selectedProject === null) {
setSelectedProject(persesProjects[0].metadata.name);
}
}, [persesProjects, selectedProject]);

const { persesProjectDashboards: dashboards } = usePerses(selectedProject || undefined);

const warningColor = t_color_red_50.value;

const handleSetDashboardName = (_event, dashboardName: string) => {
setDashboardName(dashboardName);
if (formErrors.dashboardName) {
setFormErrors((prev) => ({ ...prev, dashboardName: '' }));
}
};

const handleAdd = async () => {
setFormErrors({});

if (!selectedProject || !dashboardName.trim()) {
const errors: { [key: string]: string } = {};
if (!selectedProject) errors.project = 'Project is required';
if (!dashboardName.trim()) errors.dashboardName = 'Dashboard name is required';
setFormErrors(errors);
return;
}

try {
if (
dashboards &&
dashboards.some(
(d) =>
d.metadata.project === selectedProject &&
d.metadata.name.toLowerCase() === dashboardName.trim().toLowerCase(),
)
) {
setFormErrors({
dashboardName: `Dashboard name "${dashboardName}" already exists in this project`,
});
return;
}

const newDashboard: DashboardResource = createNewDashboard(
dashboardName.trim(),
selectedProject as string,
);

const createdDashboard = await createDashboardMutation.mutateAsync(newDashboard);

addAlert(`Dashboard "${dashboardName}" created successfully`, 'success');

const dashboardUrl = getDashboardUrl(perspective);
const dashboardParam = `dashboard=${createdDashboard.metadata.name}`;
const projectParam = `project=${createdDashboard.metadata.project}`;
const editModeParam = `edit=true`;
navigate(`${dashboardUrl}?${dashboardParam}&${projectParam}&${editModeParam}`);

setIsModalOpen(false);
setDashboardName('');
setFormErrors({});
} catch (error) {
const errorMessage = error?.message || t('Failed to create dashboard. Please try again.');
addAlert(`Error creating dashboard: ${errorMessage}`, 'danger');
setFormErrors({ general: errorMessage });
}
};

const handleModalToggle = () => {
setIsModalOpen(!isModalOpen);
setIsDropdownOpen(false);
if (isModalOpen) {
setDashboardName('');
setFormErrors({});
}
};

const handleDropdownToggle = () => {
setIsDropdownOpen(!isDropdownOpen);
};

const onFocus = () => {
const element = document.getElementById('modal-dropdown-toggle');
(element as HTMLElement)?.focus();
};

const onEscapePress = () => {
if (isDropdownOpen) {
setIsDropdownOpen(!isDropdownOpen);
onFocus();
} else {
handleModalToggle();
}
};

const onSelect = (
event: React.MouseEvent<Element, MouseEvent> | undefined,
value: string | number | undefined,
) => {
setSelectedProject(typeof value === 'string' ? value : null);
setIsDropdownOpen(false);
onFocus();
};

return (
<>
<Button
variant="primary"
onClick={handleModalToggle}
isDisabled={disabled || loading}
data-test={persesDashboardDataTestIDs.createDashboardButtonToolbar}
>
{loading ? t('Loading...') : t('Create')}
</Button>
<Modal
variant={ModalVariant.small}
isOpen={isModalOpen}
onClose={handleModalToggle}
onEscapePress={onEscapePress}
aria-labelledby="modal-with-dropdown"
>
<ModalHeader title="Create Dashboard" />
<ModalBody>
{formErrors.general && (
<div style={{ marginBottom: '16px', color: 'var(--pf-global--danger-color--100)' }}>
{formErrors.general}
</div>
)}
<Form
onSubmit={(e) => {
e.preventDefault();
handleAdd();
}}
>
<FormGroup
label="Select project"
isRequired
fieldId="form-group-create-dashboard-dialog-project-selection"
>
<Dropdown
isOpen={isDropdownOpen}
onSelect={onSelect}
onOpenChange={(isOpen: boolean) => setIsDropdownOpen(isOpen)}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={handleDropdownToggle}
isExpanded={isDropdownOpen}
isFullWidth
>
{selectedProject}
</MenuToggle>
)}
>
<DropdownList>
{persesProjects.map((project, i) => (
<DropdownItem
value={`${project.metadata.name}`}
key={`${i}-${project.metadata.name}`}
>
{project.metadata.name}
</DropdownItem>
))}
</DropdownList>
</Dropdown>
</FormGroup>
<FormGroup
label="Dashboard name"
isRequired
fieldId="form-group-create-dashboard-dialog-name"
>
<TextInput
isRequired
type="text"
id="text-input-create-dashboard-dialog-name"
name="text-input-create-dashboard-dialog-name"
placeholder={t('my-new-dashboard')}
value={dashboardName}
onChange={handleSetDashboardName}
/>
{formErrors.dashboardName && (
<div
style={{
color: warningColor,
fontSize: '14px',
marginTop: '4px',
}}
>
{formErrors.dashboardName}
</div>
)}
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
key="create"
variant="primary"
onClick={handleAdd}
isDisabled={
!dashboardName?.trim() || !selectedProject || createDashboardMutation.isPending
}
isLoading={createDashboardMutation.isPending}
>
{createDashboardMutation.isPending ? 'Creating...' : 'Create'}
</Button>
<Button key="cancel" variant="link" onClick={handleModalToggle}>
Cancel
</Button>
</ModalFooter>
</Modal>
</>
);
};
31 changes: 21 additions & 10 deletions web/src/components/dashboards/perses/dashboard-frame.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { ReactNode } from 'react';
import { DashboardEmptyState } from './emptystates/DashboardEmptyState';
import { DashboardHeader } from './dashboard-skeleton';
import { DashboardHeader } from './dashboard-header';
import { CombinedDashboardMetadata } from './hooks/useDashboardsData';
import { ProjectBar } from './project/ProjectBar';
import { PersesWrapper } from './PersesWrapper';
import { Overview } from '@openshift-console/dynamic-plugin-sdk';
import { ToastProvider } from './ToastProvider';
import { PageSection } from '@patternfly/react-core';
import { t_global_spacer_sm } from '@patternfly/react-tokens';

interface DashboardFrameProps {
activeProject: string | null;
Expand All @@ -32,14 +33,24 @@ export const DashboardFrame: React.FC<DashboardFrameProps> = ({
{activeProjectDashboardsMetadata?.length === 0 ? (
<DashboardEmptyState />
) : (
<DashboardHeader
boardItems={activeProjectDashboardsMetadata}
changeBoard={changeBoard}
dashboardName={dashboardName}
activeProject={activeProject}
>
<Overview>{children}</Overview>
</DashboardHeader>
<>
<DashboardHeader
boardItems={activeProjectDashboardsMetadata}
changeBoard={changeBoard}
dashboardName={dashboardName}
activeProject={activeProject}
>
<PageSection
hasBodyWrapper={false}
style={{
paddingTop: 0,
paddingLeft: t_global_spacer_sm.value,
}}
>
{children}
</PageSection>
</DashboardHeader>
</>
)}
</PersesWrapper>
</ToastProvider>
Expand Down
Loading