Skip to content

Commit

Permalink
feat(ui): notification subscriptions edit field #10310 (#10839)
Browse files Browse the repository at this point in the history
* feat(ui): notification subscriptions edit field

> this new field is just an abstraction of relevant annotations

Signed-off-by: Mayursinh Sarvaiya <marvinduff97@gmail.com>

* fix: codeql regex issue

Signed-off-by: Mayursinh Sarvaiya <marvinduff97@gmail.com>

Signed-off-by: Mayursinh Sarvaiya <marvinduff97@gmail.com>
  • Loading branch information
Marvin9 authored and alexmt committed Oct 14, 2022
1 parent 2d9f13d commit 39f9565
Show file tree
Hide file tree
Showing 8 changed files with 344 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {ComparisonStatusIcon, HealthStatusIcon, syncStatusMessage, urlPattern, f
import {ApplicationRetryOptions} from '../application-retry-options/application-retry-options';
import {ApplicationRetryView} from '../application-retry-view/application-retry-view';
import {Link} from 'react-router-dom';
import {EditNotificationSubscriptions, useEditNotificationSubscriptions} from './edit-notification-subscriptions';
import {EditAnnotations} from './edit-annotations';

require('./application-summary.scss');

Expand All @@ -34,12 +36,21 @@ function swap(array: any[], a: number, b: number) {
return array;
}

export const ApplicationSummary = (props: {app: models.Application; updateApp: (app: models.Application, query: {validate?: boolean}) => Promise<any>}) => {
export interface ApplicationSummaryProps {
app: models.Application;
updateApp: (app: models.Application, query: {validate?: boolean}) => Promise<any>;
}

export const ApplicationSummary = (props: ApplicationSummaryProps) => {
const app = JSON.parse(JSON.stringify(props.app)) as models.Application;
const isHelm = app.spec.source.hasOwnProperty('chart');
const initialState = app.spec.destination.server === undefined ? 'NAME' : 'URL';
const [destFormat, setDestFormat] = React.useState(initialState);
const [changeSync, setChangeSync] = React.useState(false);

const notificationSubscriptions = useEditNotificationSubscriptions(app.metadata.annotations || {});
const updateApp = notificationSubscriptions.withNotificationSubscriptions(props.updateApp);

const attributes = [
{
title: 'PROJECT',
Expand All @@ -66,7 +77,12 @@ export const ApplicationSummary = (props: {app: models.Application; updateApp: (
.join(' ')}
</Expandable>
),
edit: (formApi: FormApi) => <FormField formApi={formApi} field='metadata.annotations' component={MapInputField} />
edit: (formApi: FormApi) => <EditAnnotations formApi={formApi} app={app} />
},
{
title: 'NOTIFICATION SUBSCRIPTIONS',
view: false, // eventually the subscription input values will be merged in 'ANNOTATIONS', therefore 'ANNOATIONS' section is responsible to represent subscription values,
edit: () => <EditNotificationSubscriptions {...notificationSubscriptions} />
},
{
title: 'CLUSTER',
Expand Down Expand Up @@ -319,7 +335,7 @@ export const ApplicationSummary = (props: {app: models.Application; updateApp: (
updatedApp.spec.syncPolicy = {};
}
updatedApp.spec.syncPolicy.automated = {prune, selfHeal};
await props.updateApp(updatedApp, {validate: false});
await updateApp(updatedApp, {validate: false});
} catch (e) {
ctx.notifications.show({
content: <ErrorNotification title={`Unable to "${confirmationTitle.replace(/\?/g, '')}:`} e={e} />,
Expand All @@ -338,7 +354,7 @@ export const ApplicationSummary = (props: {app: models.Application; updateApp: (
setChangeSync(true);
const updatedApp = JSON.parse(JSON.stringify(props.app)) as models.Application;
updatedApp.spec.syncPolicy.automated = null;
await props.updateApp(updatedApp, {validate: false});
await updateApp(updatedApp, {validate: false});
} catch (e) {
ctx.notifications.show({
content: <ErrorNotification title='Unable to disable Auto-Sync' e={e} />,
Expand Down Expand Up @@ -433,7 +449,7 @@ export const ApplicationSummary = (props: {app: models.Application; updateApp: (
</div>
)}
<EditablePanel
save={props.updateApp}
save={updateApp}
validate={input => ({
'spec.project': !input.spec.project && 'Project name is required',
'spec.destination.server': !input.spec.destination.server && input.spec.destination.hasOwnProperty('server') && 'Cluster server is required',
Expand All @@ -442,6 +458,7 @@ export const ApplicationSummary = (props: {app: models.Application; updateApp: (
values={app}
title={app.metadata.name.toLocaleUpperCase()}
items={attributes}
onModeSwitch={() => notificationSubscriptions.onResetNotificationSubscriptions()}
/>
<Consumer>
{ctx => (
Expand Down Expand Up @@ -546,7 +563,16 @@ export const ApplicationSummary = (props: {app: models.Application; updateApp: (
)}
</Consumer>
<BadgePanel app={props.app.metadata.name} />
<EditablePanel save={props.updateApp} values={app} title='INFO' items={infoItems} onModeSwitch={() => setAdjustedCount(0)} />
<EditablePanel
save={updateApp}
values={app}
title='INFO'
items={infoItems}
onModeSwitch={() => {
setAdjustedCount(0);
notificationSubscriptions.onResetNotificationSubscriptions();
}}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as React from 'react';
import {FormField} from 'argo-ui';
import {FormApi} from 'react-form';
import * as models from '../../../shared/models';
import {MapInputField} from '../../../shared/components';
import {notificationSubscriptionsParser} from './edit-notification-subscriptions';

export const EditAnnotations = (props: {formApi: FormApi; app: models.Application}) => {
const once = React.useRef(false);

const removeNotificationSubscriptionRelatedAnnotations = () => {
const notificationSubscriptions = notificationSubscriptionsParser.annotationsToSubscriptions(props.app.metadata.annotations);

if (notificationSubscriptions.length > 0) {
const annotationsWithoutNotificationSubscriptions = props.app.metadata.annotations || {};

for (const notificationSubscriptionAnnotation of notificationSubscriptions) {
const key = notificationSubscriptionsParser.subscriptionToAnnotationKey(notificationSubscriptionAnnotation);

delete annotationsWithoutNotificationSubscriptions[key];
}

props.formApi.setValue('metadata.annotations', annotationsWithoutNotificationSubscriptions);
}
};

if (!once.current) {
once.current = true;
removeNotificationSubscriptionRelatedAnnotations();
}

return <FormField formApi={props.formApi} field='metadata.annotations' component={MapInputField} />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.edit-notification-subscriptions {
border-bottom: none;

&__subscription {
display: flex;
align-items: flex-end;
gap: 8px;
margin-bottom: 12px;
}

&__autocomplete-wrapper {
border-bottom: none;
}

&__input-prefix {
font-size: 12px;
width: fit-content !important;
}

input.argo-field + div {
z-index: 100 !important;
}

.button-close {
margin-top: auto !important;
margin-bottom: auto !important;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import {Autocomplete} from 'argo-ui';
import * as React from 'react';
import {DataLoader} from '../../../shared/components';
import * as models from '../../../shared/models';
import {services} from '../../../shared/services';

import {ApplicationSummaryProps} from './application-summary';

require('./edit-notification-subscriptions.scss');

export const NOTIFICATION_SUBSCRIPTION_ANNOTATION_PREFIX = 'notifications.argoproj.io/subscribe';

export const NOTIFICATION_SUBSCRIPTION_ANNOTATION_REGEX = new RegExp(`^notifications\.argoproj\.io\/subscribe\.[a-zA-Z-]{1,100}\.[a-zA-Z-]{1,100}$`);

export type TNotificationSubscription = {
trigger: string;
// notification service name
service: string;
// a semicolon separated list of recipients
value: string;
};

export const notificationSubscriptionsParser = {
annotationsToSubscriptions: (annotations: models.Application['metadata']['annotations']): TNotificationSubscription[] => {
const subscriptions: TNotificationSubscription[] = [];

for (const [key, value] of Object.entries(annotations || {})) {
if (NOTIFICATION_SUBSCRIPTION_ANNOTATION_REGEX.test(key)) {
try {
const [trigger, service] = key.slice(NOTIFICATION_SUBSCRIPTION_ANNOTATION_PREFIX.length + 1 /* for dot "." */).split('.');

subscriptions.push({trigger, service, value});
} catch (e) {
// console.error(`annotationsToSubscriptions parsing issue for ${key}`);
throw new Error(e);
}
}
}

return subscriptions;
},
subscriptionsToAnnotations: (subscriptions: TNotificationSubscription[]): models.Application['metadata']['annotations'] => {
const annotations: models.Application['metadata']['annotations'] = {};

for (const subscription of subscriptions || []) {
annotations[notificationSubscriptionsParser.subscriptionToAnnotationKey(subscription)] = subscription.value;
}

return annotations;
},
subscriptionToAnnotationKey: (subscription: TNotificationSubscription): string =>
`${NOTIFICATION_SUBSCRIPTION_ANNOTATION_PREFIX}.${subscription.trigger}.${subscription.service}`
};

/**
* split the notification subscription related annotation to have it in seperate edit field
* this hook will emit notification subscription state, controller & merge utility to core annotations helpful when final submit
*/
export const useEditNotificationSubscriptions = (annotations: models.Application['metadata']['annotations']) => {
const [subscriptions, setSubscriptions] = React.useState(notificationSubscriptionsParser.annotationsToSubscriptions(annotations));

const onAddNewSubscription = () => {
const lastSubscription = subscriptions[subscriptions.length - 1];

if (subscriptions.length === 0 || lastSubscription.trigger || lastSubscription.service || lastSubscription.value) {
setSubscriptions([
...subscriptions,
{
trigger: '',
service: '',
value: ''
}
]);
}
};

const onEditSubscription = (idx: number, subscription: TNotificationSubscription) => {
const existingSubscription = subscriptions.findIndex((sub, toFindIdx) => toFindIdx !== idx && sub.service === subscription.service && sub.trigger === subscription.trigger);
let newSubscriptions = [...subscriptions];

if (existingSubscription !== -1) {
// remove existing subscription
newSubscriptions = newSubscriptions.filter((_, newSubscriptionIdx) => newSubscriptionIdx !== existingSubscription);
// decrement index because one value is removed
idx--;
}

if (idx === -1) {
newSubscriptions = [subscription];
} else {
newSubscriptions = newSubscriptions.map((oldSubscription, oldSubscriptionIdx) => (oldSubscriptionIdx === idx ? subscription : oldSubscription));
}

setSubscriptions(newSubscriptions);
};

const onRemoveSubscription = (idx: number) => idx >= 0 && setSubscriptions(subscriptions.filter((_, i) => i !== idx));

const withNotificationSubscriptions = (updateApp: ApplicationSummaryProps['updateApp']) => (...args: Parameters<ApplicationSummaryProps['updateApp']>) => {
const app = args[0];

const notificationSubscriptionsRaw = notificationSubscriptionsParser.subscriptionsToAnnotations(subscriptions);

if (Object.keys(notificationSubscriptionsRaw)?.length) {
app.metadata.annotations = {
...notificationSubscriptionsRaw,
...(app.metadata.annotations || {})
};
}

return updateApp(app, args[1]);
};

const onResetNotificationSubscriptions = () => setSubscriptions(notificationSubscriptionsParser.annotationsToSubscriptions(annotations));

return {
/**
* abstraction of notification subscription annotations in edit view
*/
subscriptions,
onAddNewSubscription,
onEditSubscription,
onRemoveSubscription,
/**
* merge abstracted 'subscriptions' into core 'metadata.annotations' in form submit
*/
withNotificationSubscriptions,
onResetNotificationSubscriptions
};
};

export interface EditNotificationSubscriptionsProps extends ReturnType<typeof useEditNotificationSubscriptions> {}

export const EditNotificationSubscriptions = ({subscriptions, onAddNewSubscription, onEditSubscription, onRemoveSubscription}: EditNotificationSubscriptionsProps) => {
return (
<div className='edit-notification-subscriptions argo-field'>
{subscriptions.map((subscription, idx) => (
<div className='edit-notification-subscriptions__subscription' key={idx}>
<input className='argo-field edit-notification-subscriptions__input-prefix' disabled={true} value={NOTIFICATION_SUBSCRIPTION_ANNOTATION_PREFIX} />
<b>&nbsp;.&nbsp;</b>
<DataLoader load={() => services.notification.listTriggers().then(triggers => triggers.map(trigger => trigger.name))}>
{triggersList => (
<Autocomplete
wrapperProps={{
className: 'argo-field edit-notification-subscriptions__autocomplete-wrapper'
}}
inputProps={{
className: 'argo-field',
placeholder: 'on-sync-running',
title: 'Trigger'
}}
value={subscription.trigger}
onChange={e => {
onEditSubscription(idx, {
...subscription,
trigger: e.target.value
});
}}
items={triggersList}
onSelect={trigger => onEditSubscription(idx, {...subscription, trigger})}
filterSuggestions={true}
qeid='application-edit-notification-subscription-trigger'
/>
)}
</DataLoader>
<b>&nbsp;.&nbsp;</b>
<DataLoader load={() => services.notification.listServices().then(_services => _services.map(service => service.name))}>
{serviceList => (
<Autocomplete
wrapperProps={{
className: 'argo-field edit-notification-subscriptions__autocomplete-wrapper'
}}
inputProps={{
className: 'argo-field',
placeholder: 'slack',
title: 'Service'
}}
value={subscription.service}
onChange={e => {
onEditSubscription(idx, {
...subscription,
service: e.target.value
});
}}
items={serviceList}
onSelect={service => onEditSubscription(idx, {...subscription, service})}
filterSuggestions={true}
qeid='application-edit-notification-subscription-service'
/>
)}
</DataLoader>
&nbsp;=&nbsp;
<input
autoComplete='fake'
className='argo-field'
placeholder='my-channel1; my-channel2'
title='Value'
value={subscription.value}
onChange={e => {
onEditSubscription(idx, {
...subscription,
value: e.target.value
});
}}
qe-id='application-edit-notification-subscription-value'
/>
<button className='button-close'>
<i className='fa fa-times' style={{cursor: 'pointer'}} onClick={() => onRemoveSubscription(idx)} />
</button>
</div>
))}
{subscriptions.length === 0 && <label>No items</label>}
<div>
<button className='argo-button argo-button--base argo-button--short' onClick={() => onAddNewSubscription()}>
<i className='fa fa-plus' style={{cursor: 'pointer'}} />
</button>
</div>
</div>
);
};

0 comments on commit 39f9565

Please sign in to comment.