Skip to content

Commit dc6df38

Browse files
committed
Add shared YAML tab and Details Header (#7713)
Signed-off-by: Keith Chong <kykchong@redhat.com>
1 parent ef7e1ff commit dc6df38

File tree

8 files changed

+482
-34
lines changed

8 files changed

+482
-34
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import * as React from 'react';
2+
import { Link } from 'react-router-dom-v5-compat';
3+
import DevPreviewBadge from 'src/components/import/badges/DevPreviewBadge';
4+
5+
import FavoriteButton from '@gitops/components/shared/FavoriteButton/FavoriteButton';
6+
import ActionsDropdown from '@gitops/utils/components/ActionDropDown/ActionDropDown';
7+
import { DEFAULT_NAMESPACE } from '@gitops/utils/constants';
8+
import { isApplicationRefreshing } from '@gitops/utils/gitops';
9+
import { useGitOpsTranslation } from '@gitops/utils/hooks/useGitOpsTranslation';
10+
import { Action, K8sModel, K8sResourceCommon } from '@openshift-console/dynamic-plugin-sdk';
11+
import {
12+
Breadcrumb,
13+
BreadcrumbItem,
14+
Flex,
15+
PageBreadcrumb,
16+
PageGroup,
17+
PageSection,
18+
Spinner,
19+
Title,
20+
} from '@patternfly/react-core';
21+
22+
import './details-page-header.scss';
23+
24+
const PaneHeading: React.FC = ({ children }) => (
25+
<Flex
26+
alignItems={{ default: 'alignItemsCenter' }}
27+
justifyContent={{ default: 'justifyContentSpaceBetween' }}
28+
>
29+
{children}
30+
</Flex>
31+
);
32+
33+
type DetailsPageTitleProps = {
34+
breadcrumb: React.ReactNode;
35+
};
36+
37+
const DetailsPageTitle: React.FC<DetailsPageTitleProps> = ({ breadcrumb, children }) => (
38+
<div>
39+
<PageGroup>
40+
<PageBreadcrumb>{breadcrumb}</PageBreadcrumb>
41+
<PageSection className="details-page-title" hasBodyWrapper={false}>
42+
{children}
43+
</PageSection>
44+
</PageGroup>
45+
</div>
46+
);
47+
48+
type ApplicationPageTitleProps = {
49+
obj: K8sResourceCommon;
50+
model: K8sModel;
51+
name: string;
52+
namespace: string;
53+
actions: Action[];
54+
iconText: string;
55+
iconTitle: string;
56+
};
57+
58+
const DetailsPageHeader: React.FC<ApplicationPageTitleProps> = ({
59+
obj,
60+
model,
61+
name,
62+
namespace,
63+
actions,
64+
iconText,
65+
iconTitle,
66+
}) => {
67+
const { t } = useGitOpsTranslation();
68+
return (
69+
<>
70+
<div>
71+
<DetailsPageTitle
72+
breadcrumb={
73+
<Breadcrumb className="pf-c-breadcrumb co-breadcrumb">
74+
<BreadcrumbItem>
75+
<Link
76+
to={`/k8s/ns/${namespace || DEFAULT_NAMESPACE}/${
77+
model.apiGroup + '~' + model.apiVersion + '~' + model.kind
78+
}`}
79+
>
80+
Argo CD {t(model.labelPlural)}
81+
</Link>
82+
</BreadcrumbItem>
83+
<BreadcrumbItem>Argo CD {t(model.labelPlural + ' Details')}</BreadcrumbItem>
84+
</Breadcrumb>
85+
}
86+
>
87+
<PaneHeading>
88+
<Title headingLevel="h1">
89+
<span
90+
className="co-m-resource-icon co-m-resource-icon--lg argocd-resource-icon"
91+
title={iconTitle}
92+
>
93+
{iconText}
94+
</span>
95+
<span className="co-resource-item__resource-name">
96+
{name ?? obj?.metadata?.name}{' '}
97+
{isApplicationRefreshing(obj) ? <Spinner size="md" /> : <span></span>}
98+
</span>
99+
<span style={{ marginLeft: '10px', marginBottom: '3px' }}>
100+
<DevPreviewBadge />
101+
</span>
102+
</Title>
103+
<div className="co-actions">
104+
<FavoriteButton defaultName={name ?? obj?.metadata?.name} />
105+
<ActionsDropdown actions={actions} isKebabToggle={false} />
106+
</div>
107+
</PaneHeading>
108+
</DetailsPageTitle>
109+
</div>
110+
</>
111+
);
112+
};
113+
114+
export default DetailsPageHeader;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.argocd-resource-icon {
2+
background-color: #E9654B !important;
3+
color: white !important;
4+
}
5+
6+
.co-actions {
7+
display: flex;
8+
align-items: center;
9+
gap: var(--pf-v6-global--spacer--sm);
10+
}
11+
12+
.details-page-title {
13+
padding-block-start: var(--pf-t--global--spacer--sm);
14+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import * as React from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import cx from 'classnames';
4+
5+
import { RedExclamationCircleIcon } from '@openshift-console/dynamic-plugin-sdk';
6+
import {
7+
Button,
8+
Form,
9+
FormGroup,
10+
FormHelperText,
11+
HelperText,
12+
HelperTextItem,
13+
TextInput,
14+
Tooltip,
15+
} from '@patternfly/react-core';
16+
import { ModalVariant } from '@patternfly/react-core/deprecated';
17+
import { Modal as PfModal, ModalProps as PfModalProps } from '@patternfly/react-core/deprecated';
18+
import { StarIcon } from '@patternfly/react-icons';
19+
20+
import { useUserSettingsCompatibility } from './useUserSettingsCompatibility';
21+
22+
// import { Modal, RedExclamationCircleIcon, useUserSettingsCompatibility } from '@console/shared';
23+
// import { STORAGE_PREFIX } from '@console/shared/src/constants/common';
24+
25+
// Copied from console @console/shared/src/constants/common
26+
const STORAGE_PREFIX = 'bridge';
27+
28+
type ModalProps = {
29+
isFullScreen?: boolean;
30+
ref?: React.LegacyRef<PfModal>;
31+
} & PfModalProps;
32+
33+
const Modal: React.FC<ModalProps> = ({ isFullScreen = false, className, ...props }) => (
34+
<PfModal
35+
{...props}
36+
className={cx('ocs-modal', className)}
37+
appendTo={() => (isFullScreen ? document.body : document.querySelector('#modal-container'))}
38+
/>
39+
);
40+
41+
export type FavoritesType = {
42+
name: string;
43+
url: string;
44+
}[];
45+
46+
export const FAVORITES_CONFIG_MAP_KEY = 'console.favorites';
47+
export const FAVORITES_LOCAL_STORAGE_KEY = `${STORAGE_PREFIX}/favorites`;
48+
const MAX_FAVORITE_COUNT = 10;
49+
50+
type FavoriteButtonProps = {
51+
/** The default name to put in the input field */
52+
defaultName?: string;
53+
};
54+
55+
export const FavoriteButton = ({ defaultName }: FavoriteButtonProps) => {
56+
const { t } = useTranslation('console-app');
57+
const [isStarred, setIsStarred] = React.useState(false);
58+
const [isModalOpen, setIsModalOpen] = React.useState(false);
59+
const [name, setName] = React.useState<string>('');
60+
const [error, setError] = React.useState<string | null>(null);
61+
const [favorites, setFavorites, loaded] = useUserSettingsCompatibility<FavoritesType>(
62+
FAVORITES_CONFIG_MAP_KEY,
63+
FAVORITES_LOCAL_STORAGE_KEY,
64+
null,
65+
true,
66+
);
67+
const alphanumericRegex = /^[a-zA-Z0-9- ]*$/;
68+
69+
const currentUrlPath = window.location.pathname;
70+
71+
React.useEffect(() => {
72+
if (loaded) {
73+
const isCurrentlyFavorited = favorites?.some((favorite) => favorite.url === currentUrlPath);
74+
setIsStarred(isCurrentlyFavorited);
75+
}
76+
}, [loaded, favorites, currentUrlPath]);
77+
78+
const handleStarClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
79+
e.preventDefault();
80+
e.stopPropagation();
81+
const isCurrentlyFavorited = favorites?.some((favorite) => favorite.url === currentUrlPath);
82+
if (isCurrentlyFavorited) {
83+
const updatedFavorites = favorites?.filter((favorite) => favorite.url !== currentUrlPath);
84+
setFavorites(updatedFavorites);
85+
setIsStarred(false);
86+
} else {
87+
const currentUrlSplit = currentUrlPath.includes('~')
88+
? currentUrlPath.split('~')
89+
: currentUrlPath.split('/');
90+
const sanitizedDefaultName = (
91+
defaultName ?? currentUrlSplit.slice(-1)[0].split('?')[0]
92+
).replace(/[^a-zA-Z0-9- ]/g, '-');
93+
setName(sanitizedDefaultName);
94+
setIsModalOpen(true);
95+
}
96+
};
97+
98+
const handleModalClose = () => {
99+
setError('');
100+
setName('');
101+
setIsModalOpen(false);
102+
};
103+
104+
const handleConfirmStar = () => {
105+
const trimmedName = name.trim();
106+
if (!trimmedName) {
107+
setError(t('Name is required.'));
108+
return;
109+
}
110+
if (!alphanumericRegex.test(trimmedName)) {
111+
setError(t('Name can only contain letters, numbers, spaces, and hyphens.'));
112+
return;
113+
}
114+
const nameExists = favorites?.some((favorite) => favorite.name === name.trim());
115+
if (nameExists) {
116+
setError(
117+
t(
118+
'The name {{favoriteName}} already exists in your favorites. Choose a unique name to save to your favorites.',
119+
{ favoriteName: name },
120+
),
121+
);
122+
return;
123+
}
124+
125+
const newFavorite = { name: name.trim(), url: currentUrlPath };
126+
const updatedFavorites = [...(favorites || []), newFavorite];
127+
setFavorites(updatedFavorites);
128+
setIsStarred((prev) => !prev);
129+
setError('');
130+
setName('');
131+
setIsModalOpen(false);
132+
};
133+
134+
const handleNameChange = (value: string) => {
135+
setName(value);
136+
if (!alphanumericRegex.test(value)) {
137+
setError(t('Name can only contain letters, numbers, spaces, and hyphens.'));
138+
} else {
139+
setError(null);
140+
}
141+
};
142+
143+
const isDisabled = favorites?.length >= MAX_FAVORITE_COUNT && !isStarred;
144+
145+
const disabledTooltipText = t(
146+
'Maximum number of favorites ({{maxCount}}) reached. To add another favorite, remove an existing page from your favorites.',
147+
{ maxCount: MAX_FAVORITE_COUNT },
148+
);
149+
150+
const tooltipText = isDisabled
151+
? disabledTooltipText
152+
: isStarred
153+
? t('Remove from favorites')
154+
: t('Add to favorites');
155+
156+
return (
157+
<div className="co-fav-actions-icon">
158+
<Tooltip content={tooltipText} position="top">
159+
<Button
160+
icon={<StarIcon color={isStarred ? 'gold' : 'gray'} />}
161+
className="co-xl-icon-button"
162+
data-test="favorite-button"
163+
variant="plain"
164+
aria-label={tooltipText}
165+
aria-pressed={isStarred}
166+
onClick={handleStarClick}
167+
isDisabled={isDisabled}
168+
/>
169+
</Tooltip>
170+
171+
{isModalOpen && (
172+
<Modal
173+
title={t('Add to favorites')}
174+
isOpen={isModalOpen}
175+
onClose={handleModalClose}
176+
actions={[
177+
<Button
178+
key="confirm"
179+
variant="primary"
180+
onClick={handleConfirmStar}
181+
form="confirm-favorite"
182+
>
183+
{t('Save')}
184+
</Button>,
185+
<Button key="cancel" variant="link" onClick={handleModalClose}>
186+
{t('Cancel')}
187+
</Button>,
188+
]}
189+
variant={ModalVariant.small}
190+
>
191+
<Form id="confirm-favorite-form" onSubmit={handleConfirmStar}>
192+
<FormGroup label={t('Name')} isRequired fieldId="input-name">
193+
<TextInput
194+
id="confirm-favorite-form-name"
195+
data-test="input-name"
196+
name="name"
197+
type="text"
198+
onChange={(e, v) => handleNameChange(v)}
199+
value={name || ''}
200+
autoFocus
201+
required
202+
/>
203+
{error && (
204+
<FormHelperText>
205+
<HelperText>
206+
<HelperTextItem variant="error" icon={<RedExclamationCircleIcon />}>
207+
{error}
208+
</HelperTextItem>
209+
</HelperText>
210+
</FormHelperText>
211+
)}
212+
</FormGroup>
213+
</Form>
214+
</Modal>
215+
)}
216+
</div>
217+
);
218+
};
219+
220+
export default FavoriteButton;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import * as React from 'react';
2+
3+
import { useUserSettings } from '@openshift-console/dynamic-plugin-sdk';
4+
5+
export const deseralizeData = (data: string | null) => {
6+
if (typeof data !== 'string') {
7+
return data;
8+
}
9+
try {
10+
return JSON.parse(data);
11+
} catch {
12+
return data;
13+
}
14+
};
15+
16+
export const useUserSettingsCompatibility = <T>(
17+
key: string,
18+
storageKey: string,
19+
defaultValue?: T,
20+
sync = false,
21+
): [T, React.Dispatch<React.SetStateAction<T>>, boolean] => {
22+
const [settings, setSettings, loaded] = useUserSettings<T>(
23+
key,
24+
localStorage.getItem(storageKey) !== null
25+
? deseralizeData(localStorage.getItem(storageKey))
26+
: defaultValue,
27+
sync,
28+
);
29+
30+
React.useEffect(
31+
() => () => {
32+
if (loaded) {
33+
localStorage.removeItem(storageKey);
34+
}
35+
},
36+
// eslint-disable-next-line react-hooks/exhaustive-deps
37+
[loaded],
38+
);
39+
40+
return [settings, setSettings, loaded];
41+
};

0 commit comments

Comments
 (0)