Skip to content

Commit 7c6a231

Browse files
Merge pull request #765 from zhuje/ou1195-list-all-projects-main-pr
OU-1195: List OCP namespaces in Create Dashboard ProjectSelector
2 parents 6c91df7 + ca947a5 commit 7c6a231

11 files changed

Lines changed: 464 additions & 298 deletions

web/locales/en/plugin__monitoring-plugin.json

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,21 +166,73 @@
166166
"Time range": "Time range",
167167
"Refresh interval": "Refresh interval",
168168
"Could not parse JSON data for dashboard \"{{dashboard}}\"": "Could not parse JSON data for dashboard \"{{dashboard}}\"",
169-
"Dashboard Variables": "Dashboard Variables",
169+
"Rename Dashboard": "Rename Dashboard",
170+
"Dashboard name": "Dashboard name",
171+
"Renaming...": "Renaming...",
172+
"Rename": "Rename",
173+
"Duplicate Dashboard": "Duplicate Dashboard",
174+
"Loading...": "Loading...",
175+
"Failed to load project permissions. Please refresh the page and try again.": "Failed to load project permissions. Please refresh the page and try again.",
176+
"Select namespace": "Select namespace",
177+
"No namespace found for \"{{filter}}\"": "No namespace found for \"{{filter}}\"",
178+
"Duplicate": "Duplicate",
179+
"this dashboard": "this dashboard",
180+
"Permanently delete dashboard?": "Permanently delete dashboard?",
181+
"Are you sure you want to delete ": "Are you sure you want to delete ",
182+
"? This action can not be undone.": "? This action can not be undone.",
183+
"Deleting...": "Deleting...",
184+
"Delete": "Delete",
185+
"Must be 75 or fewer characters long": "Must be 75 or fewer characters long",
186+
"Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!": "Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!",
187+
"Project is required": "Project is required",
188+
"Dashboard name is required": "Dashboard name is required",
189+
"Project \"{{project}}\" created successfully": "Project \"{{project}}\" created successfully",
190+
"Failed to create project \"{{project}}\". Please try again.": "Failed to create project \"{{project}}\". Please try again.",
191+
"Error creating project: {{error}}": "Error creating project: {{error}}",
192+
"Failed to create dashboard. Please try again.": "Failed to create dashboard. Please try again.",
193+
"Checking permissions...": "Checking permissions...",
194+
"Create": "Create",
195+
"To create dashboards, contact your cluster administrator for permission.": "To create dashboards, contact your cluster administrator for permission.",
196+
"Create Dashboard": "Create Dashboard",
197+
"Select project": "Select project",
198+
"Select a project": "Select a project",
199+
"No project found for \"{{filter}}\"": "No project found for \"{{filter}}\"",
200+
"my-new-dashboard": "my-new-dashboard",
201+
"Creating...": "Creating...",
202+
"View and manage dashboards.": "View and manage dashboards.",
203+
"Rename dashboard": "Rename dashboard",
204+
"Duplicate dashboard": "Duplicate dashboard",
205+
"Delete dashboard": "Delete dashboard",
206+
"You don't have permissions for dashboard actions": "You don't have permissions for dashboard actions",
207+
"Dashboard": "Dashboard",
208+
"Project": "Project",
209+
"Created on": "Created on",
210+
"Last Modified": "Last Modified",
211+
"Filter by name": "Filter by name",
212+
"Filter by project": "Filter by project",
213+
"No dashboards found": "No dashboards found",
214+
"No results match the filter criteria. Clear filters to show results.": "No results match the filter criteria. Clear filters to show results.",
215+
"No Perses dashboards are currently available in this project.": "No Perses dashboards are currently available in this project.",
216+
"Clear all filters": "Clear all filters",
217+
"Dashboard not found": "Dashboard not found",
218+
"The dashboard \"{{name}}\" was not found in project \"{{project}}\".": "The dashboard \"{{name}}\" was not found in project \"{{project}}\".",
219+
"Empty Dashboard": "Empty Dashboard",
220+
"To get started add something to your dashboard": "To get started add something to your dashboard",
221+
"Edit": "Edit",
222+
"You don't have permission to edit this dashboard": "You don't have permission to edit this dashboard",
170223
"No matching datasource found": "No matching datasource found",
171224
"No Dashboard Available in Selected Project": "No Dashboard Available in Selected Project",
172225
"To explore data, create a dashboard for this project": "To explore data, create a dashboard for this project",
173226
"No Perses Project Available": "No Perses Project Available",
174227
"To explore data, create a Perses Project": "To explore data, create a Perses Project",
175-
"Empty Dashboard": "Empty Dashboard",
176-
"To get started add something to your dashboard": "To get started add something to your dashboard",
228+
"Project is required for fetching project dashboards": "Project is required for fetching project dashboards",
177229
"No projects found": "No projects found",
178230
"No results match the filter criteria.": "No results match the filter criteria.",
179231
"Clear filters": "Clear filters",
180232
"Select project...": "Select project...",
181233
"Projects": "Projects",
182-
"Project": "Project",
183-
"Dashboard": "Dashboard",
234+
"All Projects": "All Projects",
235+
"useToast must be used within ToastProvider": "useToast must be used within ToastProvider",
184236
"Refresh off": "Refresh off",
185237
"{{count}} second_one": "{{count}} second",
186238
"{{count}} second_other": "{{count}} seconds",
@@ -203,7 +255,7 @@
203255
"Component(s)": "Component(s)",
204256
"Alert": "Alert",
205257
"Incidents": "Incidents",
206-
"Clear all filters": "Clear all filters",
258+
"Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information.": "Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information.",
207259
"Filter type selection": "Filter type selection",
208260
"Incident ID": "Incident ID",
209261
"Severity filters": "Severity filters",
@@ -303,6 +355,5 @@
303355
"No metrics targets found": "No metrics targets found",
304356
"Error loading latest targets data": "Error loading latest targets data",
305357
"Search by endpoint or namespace...": "Search by endpoint or namespace...",
306-
"Text": "Text",
307-
"Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information.":"Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information."
358+
"Text": "Text"
308359
}

web/src/components/dashboards/perses/dashboard-action-modals.tsx

Lines changed: 79 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,19 @@ import {
1313
HelperTextItemVariant,
1414
ModalVariant,
1515
AlertVariant,
16-
Select,
17-
SelectOption,
18-
SelectList,
19-
MenuToggle,
20-
MenuToggleElement,
2116
Stack,
2217
StackItem,
2318
Spinner,
2419
} from '@patternfly/react-core';
20+
import { TypeaheadSelect, TypeaheadSelectOption } from '@patternfly/react-templates';
2521
import { ExclamationCircleIcon } from '@patternfly/react-icons';
26-
import React, { useMemo, useState } from 'react';
22+
import React, { useMemo } from 'react';
2723
import { useTranslation } from 'react-i18next';
2824
import {
2925
useUpdateDashboardMutation,
3026
useCreateDashboardMutation,
3127
useDeleteDashboardMutation,
28+
useCreateProjectMutation,
3229
} from './dashboard-api';
3330
import {
3431
renameDashboardDialogValidationSchema,
@@ -46,9 +43,9 @@ import {
4643
getResourceExtendedDisplayName,
4744
} from '@perses-dev/core';
4845
import { useToast } from './ToastProvider';
49-
import { usePerses } from './hooks/usePerses';
5046
import { generateMetadataName } from './dashboard-utils';
51-
import { useProjectPermissions } from './dashboard-permissions';
47+
import { useEditableProjects } from './hooks/useEditableProjects';
48+
import { usePerses } from './hooks/usePerses';
5249
import { t_global_spacer_200, t_global_font_weight_200 } from '@patternfly/react-tokens';
5350
import { useNavigate } from 'react-router-dom-v5-compat';
5451
import { usePerspective, getDashboardUrl } from '../../hooks/usePerspective';
@@ -189,19 +186,17 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
189186

190187
const navigate = useNavigate();
191188
const { perspective } = usePerspective();
192-
const [isProjectSelectOpen, setIsProjectSelectOpen] = useState(false);
193-
194-
const { persesProjects, persesProjectsLoading } = usePerses();
195189

196-
const hookInput = useMemo(() => {
197-
return persesProjects || [];
198-
}, [persesProjects]);
190+
const {
191+
editableProjects,
192+
allProjects,
193+
hasEditableProject,
194+
permissionsLoading,
195+
permissionsError,
196+
} = useEditableProjects();
199197

200-
const { editableProjects } = useProjectPermissions(hookInput);
201-
202-
const filteredProjects = useMemo(() => {
203-
return persesProjects.filter((project) => editableProjects.includes(project.metadata.name));
204-
}, [persesProjects, editableProjects]);
198+
const { persesProjects } = usePerses();
199+
const createProjectMutation = useCreateProjectMutation();
205200

206201
const defaultProject = useMemo(() => {
207202
if (!dashboard) return '';
@@ -210,8 +205,8 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
210205
return dashboard.metadata.project;
211206
}
212207

213-
return filteredProjects[0]?.metadata.name || '';
214-
}, [dashboard, editableProjects, filteredProjects]);
208+
return allProjects[0] || '';
209+
}, [dashboard, editableProjects, allProjects]);
215210

216211
const { schema: validationSchema } = useDashboardValidationSchema(defaultProject, t);
217212

@@ -226,30 +221,58 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
226221
},
227222
});
228223

224+
const selectedProjectName = form.watch('projectName');
225+
226+
const projectOptions = useMemo<TypeaheadSelectOption[]>(() => {
227+
if (!editableProjects) {
228+
return [];
229+
}
230+
return editableProjects.map((project) => ({
231+
content: project,
232+
value: project,
233+
selected: project === selectedProjectName,
234+
}));
235+
}, [editableProjects, selectedProjectName]);
236+
229237
const createDashboardMutation = useCreateDashboardMutation();
230238

231239
React.useEffect(() => {
232-
if (isOpen && dashboard && filteredProjects.length > 0 && defaultProject) {
240+
if (isOpen && dashboard && editableProjects?.length > 0 && defaultProject) {
233241
form.reset({
234242
projectName: defaultProject,
235243
dashboardName: '',
236244
});
237245
}
238-
}, [isOpen, dashboard, defaultProject, filteredProjects.length, form]);
239-
240-
const selectedProjectName = form.watch('projectName');
241-
const selectedProjectDisplay = useMemo(() => {
242-
const selectedProject = filteredProjects.find((p) => p.metadata.name === selectedProjectName);
243-
return selectedProject
244-
? getResourceDisplayName(selectedProject)
245-
: selectedProjectName || t('Select project');
246-
}, [filteredProjects, selectedProjectName, t]);
246+
}, [isOpen, dashboard, defaultProject, editableProjects?.length, form]);
247247

248248
if (!dashboard) {
249249
return null;
250250
}
251251

252-
const processForm: SubmitHandler<CreateDashboardValidationType> = (data) => {
252+
const processForm: SubmitHandler<CreateDashboardValidationType> = async (data) => {
253+
// Check if project exists, create it if it doesn't
254+
const projectExists = persesProjects?.some(
255+
(project) => project.metadata.name === data.projectName,
256+
);
257+
258+
if (!projectExists) {
259+
try {
260+
await createProjectMutation.mutateAsync(data.projectName);
261+
addAlert(
262+
t('Project "{{project}}" created successfully', { project: data.projectName }),
263+
'success',
264+
);
265+
} catch (projectError) {
266+
const errorMessage =
267+
projectError?.message ||
268+
t('Failed to create project "{{project}}". Please try again.', {
269+
project: data.projectName,
270+
});
271+
addAlert(t('Error creating project: {{error}}', { error: errorMessage }), 'danger');
272+
return;
273+
}
274+
}
275+
253276
const newDashboard: DashboardResource = {
254277
...dashboard,
255278
metadata: {
@@ -295,18 +318,8 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
295318
form.reset();
296319
};
297320

298-
const onProjectToggle = () => {
299-
setIsProjectSelectOpen(!isProjectSelectOpen);
300-
};
301-
302-
const onProjectSelect = (
303-
_event: React.MouseEvent<Element, MouseEvent> | undefined,
304-
value: string | number | undefined,
305-
) => {
306-
if (typeof value === 'string') {
307-
form.setValue('projectName', value);
308-
setIsProjectSelectOpen(false);
309-
}
321+
const onProjectSelect = (_event: any, selection: string) => {
322+
form.setValue('projectName', selection);
310323
};
311324

312325
return (
@@ -317,11 +330,16 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
317330
ouiaId="DuplicateModal"
318331
aria-labelledby="duplicate-modal"
319332
>
320-
<ModalHeader title="Duplicate Dashboard" labelId="duplicate-modal-title" />
321-
{persesProjectsLoading ? (
333+
<ModalHeader title={t('Duplicate Dashboard')} labelId="duplicate-modal-title" />
334+
{permissionsLoading ? (
322335
<ModalBody style={{ textAlign: 'center', padding: '2rem' }}>
323336
{t('Loading...')} <Spinner aria-label="Duplicate Dashboard Modal Loading" />
324337
</ModalBody>
338+
) : permissionsError ? (
339+
<ModalBody style={{ textAlign: 'center', padding: '2rem' }}>
340+
<ExclamationCircleIcon />
341+
{t('Failed to load project permissions. Please refresh the page and try again.')}
342+
</ModalBody>
325343
) : (
326344
<FormProvider {...form}>
327345
<form onSubmit={form.handleSubmit(processForm)}>
@@ -368,42 +386,28 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
368386
<Controller
369387
control={form.control}
370388
name="projectName"
371-
render={({ field, fieldState }) => (
389+
render={({ fieldState }) => (
372390
<FormGroup
373391
label={t('Select namespace')}
374392
isRequired
375393
fieldId="duplicate-modal-select-namespace-form-group"
376394
style={formGroupStyle}
377395
>
378396
<LabelSpacer />
379-
<Select
380-
id="duplicate-modal-select-namespace-form-group-select"
381-
isOpen={isProjectSelectOpen}
382-
selected={field.value}
397+
<TypeaheadSelect
398+
key={selectedProjectName || 'no-selection'}
399+
initialOptions={projectOptions}
400+
placeholder={t('Select namespace')}
401+
noOptionsFoundMessage={(filter) =>
402+
t('No namespace found for "{{filter}}"', { filter })
403+
}
404+
onClearSelection={() => {
405+
form.setValue('projectName', '');
406+
}}
383407
onSelect={onProjectSelect}
384-
onOpenChange={setIsProjectSelectOpen}
385-
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
386-
<MenuToggle
387-
ref={toggleRef}
388-
onClick={onProjectToggle}
389-
isExpanded={isProjectSelectOpen}
390-
isFullWidth
391-
>
392-
{selectedProjectDisplay}
393-
</MenuToggle>
394-
)}
395-
>
396-
<SelectList>
397-
{filteredProjects.map((project) => (
398-
<SelectOption
399-
key={project.metadata.name}
400-
value={project.metadata.name}
401-
>
402-
{getResourceDisplayName(project)}
403-
</SelectOption>
404-
))}
405-
</SelectList>
406-
</Select>
408+
isCreatable={false}
409+
maxMenuHeight="200px"
410+
/>
407411
{fieldState.error && (
408412
<FormHelperText>
409413
<HelperText>
@@ -430,6 +434,7 @@ export const DuplicateActionModal = ({ dashboard, isOpen, onClose }: ActionModal
430434
isDisabled={
431435
!(form.watch('dashboardName') || '')?.trim() ||
432436
!(form.watch('projectName') || '')?.trim() ||
437+
!hasEditableProject ||
433438
createDashboardMutation.isPending
434439
}
435440
isLoading={createDashboardMutation.isPending}

web/src/components/dashboards/perses/dashboard-api.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { DashboardResource } from '@perses-dev/core';
1+
import { DashboardResource, ProjectResource } from '@perses-dev/core';
22
import buildURL from './perses/url-builder';
33
import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query';
44
import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk';
55
import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
66
import { StatusError } from '@perses-dev/core';
7+
import { PERSES_PROXY_BASE_PATH } from './perses-client';
78

89
const resource = 'dashboards';
910

@@ -121,3 +122,36 @@ export function useDashboardList(
121122
...options,
122123
});
123124
}
125+
126+
export const createPersesProject = async (projectName: string): Promise<ProjectResource> => {
127+
const createProjectURL = '/api/v1/projects';
128+
const persesURL = `${PERSES_PROXY_BASE_PATH}${createProjectURL}`;
129+
130+
const newProject: Partial<ProjectResource> = {
131+
kind: 'Project',
132+
metadata: {
133+
name: projectName,
134+
version: 0,
135+
},
136+
spec: {
137+
display: {
138+
name: projectName,
139+
},
140+
},
141+
};
142+
143+
return consoleFetchJSON.post(persesURL, newProject);
144+
};
145+
146+
export const useCreateProjectMutation = (): UseMutationResult<ProjectResource, Error, string> => {
147+
const queryClient = useQueryClient();
148+
149+
return useMutation<ProjectResource, Error, string>({
150+
mutationKey: ['projects'],
151+
mutationFn: createPersesProject,
152+
onSuccess: () => {
153+
queryClient.invalidateQueries({ queryKey: ['projects'] });
154+
queryClient.invalidateQueries({ queryKey: [resource] });
155+
},
156+
});
157+
};

0 commit comments

Comments
 (0)