fix(ui): edit alert and misc feedback for alerts page (#9388)

* fix(ui): edit alert and misc feedback for alerts page

* disable destination for system generated alerts

* minor ui comments resolved

* fix edit mode in alerts workflow

* address comments
This commit is contained in:
Chirag Madlani 2022-12-20 10:13:10 +05:30 committed by GitHub
parent 2398161653
commit bac86cba69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 732 additions and 338 deletions

View File

@ -1,3 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.00002 8.65912C5.8954 8.65912 5.79067 8.61915 5.71079 8.53927L1.61989 4.44834C1.46004 4.28849 1.46004 4.02964 1.61989 3.86989C1.77974 3.71014 2.03859 3.71004 2.19834 3.86989L6.00002 7.6716L9.80167 3.86989C9.96157 3.71004 10.2204 3.71004 10.3802 3.86989C10.5399 4.02974 10.54 4.28859 10.3802 4.44834L6.28925 8.53927C6.20937 8.61915 6.10464 8.65912 6.00002 8.65912Z" fill="#37352F" stroke="#37352F" stroke-width="0.6"/> <path d="M6.00002 8.65912C5.8954 8.65912 5.79067 8.61915 5.71079 8.53927L1.61989 4.44834C1.46004 4.28849 1.46004 4.02964 1.61989 3.86989C1.77974 3.71014 2.03859 3.71004 2.19834 3.86989L6.00002 7.6716L9.80167 3.86989C9.96157 3.71004 10.2204 3.71004 10.3802 3.86989C10.5399 4.02974 10.54 4.28859 10.3802 4.44834L6.28925 8.53927C6.20937 8.61915 6.10464 8.65912 6.00002 8.65912Z" fill="#37352F" stroke="#37352F" stroke-width="0.6"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 532 B

After

Width:  |  Height:  |  Size: 510 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5455 7.99998C11.5455 8.13947 11.4922 8.27911 11.3857 8.38561L5.93112 13.8401C5.71799 14.0533 5.37285 14.0533 5.15985 13.8401C4.94685 13.627 4.94672 13.2819 5.15985 13.0689L10.2288 7.99998L5.15985 2.93107C4.94672 2.71794 4.94672 2.3728 5.15985 2.1598C5.37299 1.9468 5.71812 1.94667 5.93112 2.1598L11.3857 7.61434C11.4922 7.72084 11.5455 7.86048 11.5455 7.99998Z" fill="#37352F" stroke="#37352F" stroke-width="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

View File

@ -12,6 +12,7 @@
*/ */
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { Operation } from 'fast-json-patch';
import { PagingResponse } from 'Models'; import { PagingResponse } from 'Models';
import axiosClient from '.'; import axiosClient from '.';
import { AlertAction } from '../generated/alerts/alertAction'; import { AlertAction } from '../generated/alerts/alertAction';
@ -36,6 +37,21 @@ export const createAlertAction = async (alertAction: CreateAlertAction) => {
return response.data; return response.data;
}; };
export const patchAlertAction = async (id: string, operation: Operation) => {
const response = await axiosClient.patch<
Operation,
AxiosResponse<AlertAction>
>(`${BASE_URL}/${id}`, operation);
return response.data;
};
export const updateAlertAction = async (alertAction: AlertAction) => {
const response = await axiosClient.put<AlertAction>(BASE_URL, alertAction);
return response.data;
};
export const deleteAlertAction = async (id: string) => { export const deleteAlertAction = async (id: string) => {
const response = await axiosClient.delete(`${BASE_URL}/${id}`); const response = await axiosClient.delete(`${BASE_URL}/${id}`);

View File

@ -13,7 +13,11 @@
import { PagingResponse } from 'Models'; import { PagingResponse } from 'Models';
import axiosClient from '.'; import axiosClient from '.';
import { AlertActionType, Status } from '../generated/alerts/alertAction'; import {
AlertAction,
AlertActionType,
Status,
} from '../generated/alerts/alertAction';
import { Alerts, TriggerConfig } from '../generated/alerts/alerts'; import { Alerts, TriggerConfig } from '../generated/alerts/alerts';
import { EntitySpelFilters } from '../generated/alerts/entitySpelFilters'; import { EntitySpelFilters } from '../generated/alerts/entitySpelFilters';
import { Function } from '../generated/type/function'; import { Function } from '../generated/type/function';
@ -60,6 +64,12 @@ export const createAlert = async (alert: Alerts) => {
return response.data; return response.data;
}; };
export const updateAlert = async (alert: Alerts) => {
const response = await axiosClient.put<Alerts>(BASE_URL, alert);
return response.data;
};
export const deleteAlert = async (id: string) => { export const deleteAlert = async (id: string) => {
const response = await axiosClient.delete(`${BASE_URL}/${id}`); const response = await axiosClient.delete(`${BASE_URL}/${id}`);
@ -94,7 +104,9 @@ export const getDefaultTriggerConfigs = async (after?: string) => {
}; };
export const getAlertActionForAlerts = async (id: string) => { export const getAlertActionForAlerts = async (id: string) => {
const response = await axiosClient.get(`${BASE_URL}/allAlertAction/${id}`); const response = await axiosClient.get<AlertAction[]>(
`${BASE_URL}/allAlertAction/${id}`
);
return response.data; return response.data;
}; };

View File

@ -216,7 +216,7 @@ const SearchDropdown: FC<SearchDropdownProps> = ({
</span> </span>
)} )}
</Space> </Space>
<DropDown className="flex self-center" /> <DropDown className="flex self-center" height={12} width={12} />
</Space> </Space>
</Button> </Button>
</Dropdown> </Dropdown>

View File

@ -17,10 +17,16 @@
box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.3); box-shadow: 0 0 10px 2px rgba(0, 0, 0, 0.3);
height: 60px; height: 60px;
position: fixed; position: fixed;
right: 40px; right: 20px;
bottom: 20px; bottom: 20px;
width: 60px; width: 60px;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
cursor: pointer; cursor: pointer;
scale: 0.75;
transition: scale 0.5s ease-in-out;
}
.slack-chat .bubble:hover {
scale: 1;
} }

View File

@ -408,7 +408,6 @@
"edit-team-type": "Edit Team Type", "edit-team-type": "Edit Team Type",
"configure-webhook-type": "Configure {{webhookType}} Webhooks", "configure-webhook-type": "Configure {{webhookType}} Webhooks",
"ms-teams": "MS Teams", "ms-teams": "MS Teams",
"ms-team": "MS Team",
"bot": "Bot", "bot": "Bot",
"bot-plural": "Bots", "bot-plural": "Bots",
"policy-name": "Policy name", "policy-name": "Policy name",
@ -584,7 +583,15 @@
"completed-description": "Completed Description", "completed-description": "Completed Description",
"assigned-entity": "Assigned {{entity}}", "assigned-entity": "Assigned {{entity}}",
"total-assets-view": "Total Assets View", "total-assets-view": "Total Assets View",
"total-active-user": "Total Active User" "total-active-user": "Total Active User",
"alert-actions": "Alert Actions",
"test-results": "Test Results",
"send-to": "Send to",
"receiver-plural": "Receivers",
"admin-plural": "Admins",
"owner-plural": "Owners",
"follower-plural": "Followers",
"email-plural": "Emails"
}, },
"message": { "message": {
"service-email-required": "Service account Email is required", "service-email-required": "Service account Email is required",
@ -709,7 +716,8 @@
"alerts-description": "Stay current with timely alerts using webhooks.", "alerts-description": "Stay current with timely alerts using webhooks.",
"alerts-destination-description": "Send notifications to Slack, MS Teams, Email, or and use Webhooks.", "alerts-destination-description": "Send notifications to Slack, MS Teams, Email, or and use Webhooks.",
"alerts-trigger-description": "Trigger for all data assets or a specific entity.", "alerts-trigger-description": "Trigger for all data assets or a specific entity.",
"alerts-filter-description": "Specify the change events to narrow the scope of your alerts." "alerts-filter-description": "Specify the change events to narrow the scope of your alerts.",
"length-validator-error": "At least {{length}} {{field}} required"
}, },
"server": { "server": {
"you-have-not-action-anything-yet": "You have not {{action}} anything yet.", "you-have-not-action-anything-yet": "You have not {{action}} anything yet.",
@ -729,7 +737,8 @@
"ingestion-workflow-operation-error": "Error while {{operation}} ingestion workflow {{displayName}}", "ingestion-workflow-operation-error": "Error while {{operation}} ingestion workflow {{displayName}}",
"team-moved-error": "Error while moving team", "team-moved-error": "Error while moving team",
"entity-details-fetch-error": "Error while fetching details for {{entityType}} {{entityName}}", "entity-details-fetch-error": "Error while fetching details for {{entityType}} {{entityName}}",
"create-entity-success": "{{entity}} created successfully." "create-entity-success": "{{entity}} created successfully.",
"update-entity-success": "{{entity}} updated successfully."
}, },
"url": {} "url": {}
} }

View File

@ -404,7 +404,6 @@
"edit-team-type": "Mettre à jour le type d'équipe", "edit-team-type": "Mettre à jour le type d'équipe",
"configure-webhook-type": "Configurer {{webhookType}} Webhooks", "configure-webhook-type": "Configurer {{webhookType}} Webhooks",
"ms-teams": "MS Teams", "ms-teams": "MS Teams",
"ms-team": "MS Team",
"policy-name": "Nom de Police", "policy-name": "Nom de Police",
"add-new-policy": "Ajouter une nouvelle police", "add-new-policy": "Ajouter une nouvelle police",
"policies": "Polices", "policies": "Polices",

View File

@ -14,30 +14,35 @@ import { PlusOutlined } from '@ant-design/icons';
import { import {
Button, Button,
Card, Card,
Checkbox,
Col, Col,
Collapse,
Divider, Divider,
Form, Form,
Input, Input,
Row, Row,
Select, Select,
Space, Space,
Switch,
Typography, Typography,
} from 'antd'; } from 'antd';
import { useForm } from 'antd/lib/form/Form'; import { useForm } from 'antd/lib/form/Form';
import { DefaultOptionType } from 'antd/lib/select'; import { DefaultOptionType } from 'antd/lib/select';
import { AxiosError } from 'axios'; import { get, intersection, isEmpty, map, pick, startCase, trim } from 'lodash';
import { get, intersection, isEmpty, map, startCase } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHistory, useParams } from 'react-router-dom'; import { useHistory, useParams } from 'react-router-dom';
import { createAlertAction } from '../../axiosAPIs/alertActionAPI'; import {
createAlertAction,
updateAlertAction,
} from '../../axiosAPIs/alertActionAPI';
import { import {
createAlert, createAlert,
getAlertActionForAlerts,
getAlertsFromId, getAlertsFromId,
getDefaultTriggerConfigs, getDefaultTriggerConfigs,
getEntityFilterFunctions, getEntityFilterFunctions,
getFilterFunctions, getFilterFunctions,
updateAlert,
} from '../../axiosAPIs/alertsAPI'; } from '../../axiosAPIs/alertsAPI';
import { import {
getSearchedUsersAndTeams, getSearchedUsersAndTeams,
@ -51,19 +56,26 @@ import {
import { PROMISE_STATE } from '../../enums/common.enum'; import { PROMISE_STATE } from '../../enums/common.enum';
import { AlertAction } from '../../generated/alerts/alertAction'; import { AlertAction } from '../../generated/alerts/alertAction';
import { import {
AlertFilterRule,
Alerts, Alerts,
AlertTriggerType, AlertTriggerType,
Effect, Effect,
EntityReference, EntityReference,
ProviderType,
TriggerConfig, TriggerConfig,
} from '../../generated/alerts/alerts'; } from '../../generated/alerts/alerts';
import { AlertActionType } from '../../generated/alerts/api/createAlertAction'; import {
AlertActionType,
CreateAlertAction,
} from '../../generated/alerts/api/createAlertAction';
import { EntitySpelFilters } from '../../generated/alerts/entitySpelFilters'; import { EntitySpelFilters } from '../../generated/alerts/entitySpelFilters';
import { Function } from '../../generated/type/function'; import { Function } from '../../generated/type/function';
import { import {
getAlertActionTypeDisplayName,
getAlertsActionTypeIcon, getAlertsActionTypeIcon,
getDisplayNameForTriggerType, getDisplayNameForTriggerType,
getFunctionDisplayName, getFunctionDisplayName,
listLengthValidator,
StyledCard, StyledCard,
} from '../../utils/Alerts/AlertsUtil'; } from '../../utils/Alerts/AlertsUtil';
import { getSettingPath } from '../../utils/RouterUtils'; import { getSettingPath } from '../../utils/RouterUtils';
@ -76,6 +88,8 @@ const AddAlertPage = () => {
const [form] = useForm<Alerts>(); const [form] = useForm<Alerts>();
const history = useHistory(); const history = useHistory();
const { fqn } = useParams<{ fqn: string }>(); const { fqn } = useParams<{ fqn: string }>();
// To block certain action based on provider of the Alert e.g. System / User
const [provider, setProvider] = useState<ProviderType>(ProviderType.User);
const [filterFunctions, setFilterFunctions] = useState<Function[]>(); const [filterFunctions, setFilterFunctions] = useState<Function[]>();
const [defaultTriggers, setDefaultTriggers] = useState<Array<TriggerConfig>>( const [defaultTriggers, setDefaultTriggers] = useState<Array<TriggerConfig>>(
@ -88,23 +102,34 @@ const AddAlertPage = () => {
const fetchAlert = async () => { const fetchAlert = async () => {
try { try {
setLoadingCount((count) => count + 1); setLoadingCount((count) => count + 1);
const response: Alerts = await getAlertsFromId(fqn); const response: Alerts = await getAlertsFromId(fqn);
const alertActions = await getAlertActionForAlerts(response.id);
const requestFilteringRules = const requestFilteringRules =
response.filteringRules?.map((curr) => ({ response.filteringRules?.map(
(curr) =>
({
...curr, ...curr,
condition: curr.condition condition: curr.condition
.replace(new RegExp(`${curr.name}\\('`), '') .replace(new RegExp(`${curr.name}\\('`), '')
.replace(new RegExp(`'\\)`), ''), .replaceAll("'", '')
})) ?? []; .replace(new RegExp(`\\)`), '')
.split(',')
.map(trim),
} as unknown as AlertFilterRule)
) ?? [];
setProvider(response.provider ?? ProviderType.User);
form.setFieldsValue({ form.setFieldsValue({
...response, ...response,
filteringRules: requestFilteringRules, filteringRules: requestFilteringRules,
alertActions: alertActions as unknown as EntityReference[],
}); });
} catch { } catch {
showErrorToast( showErrorToast(
t('message.entity-fetch-error', { entity: t('label.alert') }), t('server.entity-fetch-error', { entity: t('label.alert') }),
fqn fqn
); );
} finally { } finally {
@ -149,29 +174,37 @@ const AddAlertPage = () => {
fetchDefaultTriggerConfig(); fetchDefaultTriggerConfig();
}, []); }, []);
const handleSave = async (data: Alerts) => { const isEditMode = useMemo(() => !isEmpty(fqn), [fqn]);
const { filteringRules, alertActions } = data;
const requestFilteringRules = filteringRules?.map((curr) => ({ const updateCreateAlertActions = async (alertActions: AlertAction[]) => {
...curr, const api = isEditMode ? updateAlertAction : createAlertAction;
condition: `${curr.name}(${map( if (isEditMode) {
curr.condition, if (!form.isFieldTouched(['alertActions'])) {
(v: string) => `'${v}'` // If destination is not changed return given alertAction as it is
)?.join(', ')})`, return Promise.resolve(
})); alertActions.map((action) => ({
id: action.id ?? '',
type: 'alertAction',
}))
);
}
}
try { // Else Create AlertActions and return new IDs
const actions: AlertAction[] = const promises =
alertActions?.map( alertActions?.map((action) =>
(action) => api(
({ pick(action, [
...action, 'alertActionConfig',
enabled: true, 'alertActionType',
} as unknown as AlertAction) 'name',
'displayName',
'timeout',
'batchSize',
]) as CreateAlertAction
)
) ?? []; ) ?? [];
const promises = actions.map((action) => createAlertAction(action));
const responses = await Promise.allSettled(promises); const responses = await Promise.allSettled(promises);
const requestAlertActions: EntityReference[] = responses.map((res) => { const requestAlertActions: EntityReference[] = responses.map((res) => {
@ -185,15 +218,50 @@ const AddAlertPage = () => {
}; };
}); });
return Promise.resolve(requestAlertActions);
};
const handleSave = async (data: Alerts) => {
const { filteringRules, alertActions } = data;
if (!alertActions?.length) {
return;
}
const api = isEditMode ? updateAlert : createAlert;
const requestFilteringRules = filteringRules?.map((curr) => ({
...curr,
condition: `${curr.name}(${map(
curr.condition,
(v: string) => `'${v}'`
)?.join(', ')})`,
}));
const modifiedAlertActions = alertActions?.map(
(action) =>
({
...action,
name: action.name ?? action.displayName,
displayName: action.displayName,
} as unknown as AlertAction)
);
try { try {
await createAlert({ const requestAlertActions = await updateCreateAlertActions(
modifiedAlertActions
);
try {
await api({
...data, ...data,
filteringRules: requestFilteringRules, filteringRules: requestFilteringRules,
alertActions: requestAlertActions, alertActions: requestAlertActions,
}); });
showErrorToast( showErrorToast(
t('server.create-entity-success', { entity: t('alert-plural') }) t(`server.${isEditMode ? 'update' : 'create'}-entity-success`, {
entity: t('label.alert-plural'),
})
); );
history.push( history.push(
getSettingPath( getSettingPath(
@ -203,15 +271,24 @@ const AddAlertPage = () => {
); );
} catch (error) { } catch (error) {
showErrorToast( showErrorToast(
t('server.entity-creation-error', { t(
`server.${
isEditMode ? 'entity-updating-error' : 'entity-creation-error'
}`,
{
entity: t('label.alert-plural'), entity: t('label.alert-plural'),
}), }
(error as AxiosError).message )
); );
} }
} catch (error) { } catch (error) {
showErrorToast( showErrorToast(
t('server.entity-creation-error', { entity: t('label.alert-plural') }) t(
`server.${
isEditMode ? 'entity-updating-error' : 'entity-creation-error'
}`,
{ entity: t('label.alert-plural') }
)
); );
} }
}; };
@ -222,7 +299,7 @@ const AddAlertPage = () => {
return response.hits.hits.map((d) => ({ return response.hits.hits.map((d) => ({
label: d._source.displayName ?? d._source.name, label: d._source.displayName ?? d._source.name,
value: d._source.id, value: d._source.fullyQualifiedName,
})); }));
} catch (error) { } catch (error) {
return []; return [];
@ -313,37 +390,171 @@ const AddAlertPage = () => {
// Watchers // Watchers
const filters = Form.useWatch(['filteringRules'], form); const filters = Form.useWatch(['filteringRules'], form);
const alertActions = Form.useWatch(['alertActions'], form);
const entitySelected = Form.useWatch(['triggerConfig', 'entities'], form); const entitySelected = Form.useWatch(['triggerConfig', 'entities'], form);
const trigger = Form.useWatch(['triggerConfig', 'type'], form); const trigger = Form.useWatch(['triggerConfig', 'type'], form);
const alertActions = Form.useWatch(['alertActions'], form);
// Run time values needed for conditional rendering // Run time values needed for conditional rendering
const functions = useMemo(() => { const functions = useMemo(() => {
if (entityFunctions) { if (entityFunctions) {
if (!trigger || trigger === AlertTriggerType.AllDataAssets) { const exitingFunctions = filters?.map((f) => f.name) ?? [];
return entityFunctions['all'].supportedFunctions.sort(); let supportedFunctions: string[][] = [];
}
const arrFunctions = entitySelected?.map( if (!trigger || trigger === AlertTriggerType.AllDataAssets) {
supportedFunctions = [entityFunctions['all'].supportedFunctions];
} else {
supportedFunctions =
entitySelected?.map(
(entity) => (entity) =>
entityFunctions[entity as unknown as string].supportedFunctions entityFunctions[entity as unknown as string].supportedFunctions
); ) ?? [];
}
const functions = arrFunctions const functions = intersection(...supportedFunctions)
? intersection(...arrFunctions).sort() .sort()
: []; .map((func) => ({
label: getFunctionDisplayName(func),
value: func,
disabled: exitingFunctions.includes(func),
}));
return functions as string[]; return functions as DefaultOptionType[];
} }
return []; return [];
}, [entitySelected, entityFunctions]); }, [entitySelected, entityFunctions, filters]);
const selectedTrigger = useMemo( const selectedTrigger = useMemo(
() => defaultTriggers.find(({ type }) => trigger === type), () => defaultTriggers.find(({ type }) => trigger === type),
[defaultTriggers, trigger] [defaultTriggers, trigger]
); );
const handleChange = (changedValues: Partial<Alerts>) => {
const { triggerConfig } = changedValues;
if (triggerConfig?.entities || triggerConfig?.type) {
form.resetFields(['filteringRules', 'condition']);
}
};
const getDestinationConfigFields = useCallback(
(name: number) => {
const alertActionType = get(alertActions, [name, 'alertActionType']);
if (alertActions && alertActions[name]) {
switch (alertActionType) {
case AlertActionType.Email:
return (
<>
<Form.Item
required
label={t('label.name')}
labelCol={{ span: 24 }}
name={[name, 'displayName']}>
<Input
disabled={provider === ProviderType.System}
placeholder={t('label.name')}
/>
</Form.Item>
<Form.Item
label={t('label.receiver-plural')}
labelCol={{ span: 24 }}
name={[name, 'alertActionConfig', 'receivers']}>
<Select
showSearch
mode="tags"
open={false}
placeholder={t('label.enter-entity', {
entity: t('label.email-plural'),
})}
/>
</Form.Item>
<Space align="baseline">
<label>{t('label.send-to')}:</label>
<Form.Item
name={[name, 'alertActionConfig', 'sendToAdmins']}
valuePropName="checked">
<Checkbox>{t('label.admin-plural')}</Checkbox>
</Form.Item>
<Form.Item
name={[name, 'alertActionConfig', 'sendToOwners']}
valuePropName="checked">
<Checkbox>{t('label.owner-plural')}</Checkbox>
</Form.Item>
<Form.Item
name={[name, 'alertActionConfig', 'sendToFollowers']}
valuePropName="checked">
<Checkbox>{t('label.follower-plural')}</Checkbox>
</Form.Item>
</Space>
</>
);
case AlertActionType.GenericWebhook:
case AlertActionType.SlackWebhook:
case AlertActionType.MSTeamsWebhook:
return (
<>
<Form.Item required name={[name, 'displayName']}>
<Input
disabled={provider === ProviderType.System}
placeholder={t('label.name')}
/>
</Form.Item>
<Form.Item
required
name={[name, 'alertActionConfig', 'endpoint']}>
<Input
disabled={provider === ProviderType.System}
placeholder={
t('label.endpoint-url') +
': ' +
'http(s)://www.example.com'
}
/>
</Form.Item>
<Collapse ghost>
<Collapse.Panel
header={`${t('label.advanced-config')}:`}
key="1">
<Space>
<Form.Item
initialValue={10}
label="Batch Size"
labelCol={{ span: 24 }}
name={[name, 'batchSize']}>
<Input disabled={provider === ProviderType.System} />
</Form.Item>
<Form.Item
colon
initialValue={10}
label={`${t(
'label.connection-timeout-plural-optional'
)}`}
labelCol={{ span: 24 }}
name={[name, 'timeout']}>
<Input disabled={provider === ProviderType.System} />
</Form.Item>
</Space>
<Form.Item
label={t('label.secret-key')}
labelCol={{ span: 24 }}
name={[name, 'alertActionConfig', 'secretKey']}>
<Input
disabled={provider === ProviderType.System}
placeholder={t('label.secret-key')}
/>
</Form.Item>
</Collapse.Panel>
</Collapse>
</>
);
}
}
return <></>;
},
[alertActions]
);
return ( return (
<> <>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
@ -359,14 +570,15 @@ const AddAlertPage = () => {
<Form<Alerts> <Form<Alerts>
className="alerts-notification-form" className="alerts-notification-form"
form={form} form={form}
onFinish={handleSave}> onFinish={handleSave}
onValuesChange={handleChange}>
<Card loading={loadingCount > 0}> <Card loading={loadingCount > 0}>
<Form.Item <Form.Item
label={t('label.name')} label={t('label.name')}
labelCol={{ span: 24 }} labelCol={{ span: 24 }}
name="name" name="name"
rules={[{ required: true }]}> rules={[{ required: true }]}>
<Input /> <Input disabled={isEditMode} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('label.description')} label={t('label.description')}
@ -375,7 +587,7 @@ const AddAlertPage = () => {
rules={[{ required: true }]}> rules={[{ required: true }]}>
<Input.TextArea /> <Input.TextArea />
</Form.Item> </Form.Item>
<Form.Item>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col span={8}> <Col span={8}>
<Space className="w-full" direction="vertical" size={16}> <Space className="w-full" direction="vertical" size={16}>
@ -396,7 +608,12 @@ const AddAlertPage = () => {
</Form.Item> </Form.Item>
{selectedTrigger?.type === {selectedTrigger?.type ===
AlertTriggerType.SpecificDataAsset && ( AlertTriggerType.SpecificDataAsset && (
<Form.Item name={['triggerConfig', 'entities']}> <Form.Item
required
messageVariables={{
fieldName: t('label.data-assets'),
}}
name={['triggerConfig', 'entities']}>
<Select <Select
showArrow showArrow
className="w-full" className="w-full"
@ -421,21 +638,40 @@ const AddAlertPage = () => {
subHeading={t('message.alerts-filter-description')} subHeading={t('message.alerts-filter-description')}
/> />
<Form.List name="filteringRules"> <Form.List
{(fields, { add, remove }) => ( name="filteringRules"
rules={[
{
validator: listLengthValidator(
t('label.filter-plural')
),
},
]}>
{(fields, { add, remove }, { errors }) => (
<> <>
<Form.Item>
<Button
block
icon={<PlusOutlined />}
type="default"
onClick={() => add({}, 0)}>
{t('label.add-entity', {
entity: t('label.filter-plural'),
})}
</Button>
</Form.Item>
{fields.map(({ key, name }) => ( {fields.map(({ key, name }) => (
<div key={`filteringRules-${key}`}> <div key={`filteringRules-${key}`}>
{name > 0 && (
<Divider
style={{ margin: 0, marginBottom: '16px' }}
/>
)}
<div className="d-flex gap-1"> <div className="d-flex gap-1">
<div className="flex-1"> <div className="flex-1">
<Form.Item key={key} name={[name, 'name']}> <Form.Item key={key} name={[name, 'name']}>
<Select <Select
options={functions?.map( options={functions}
(func: string) => ({
label: getFunctionDisplayName(func),
value: func,
})
)}
placeholder={t('label.select-field', { placeholder={t('label.select-field', {
field: t('label.condition'), field: t('label.condition'),
})} })}
@ -453,10 +689,13 @@ const AddAlertPage = () => {
key={key} key={key}
name={[name, 'effect']}> name={[name, 'effect']}>
<Select <Select
options={map(Effect, (func: string) => ({ options={map(
Effect,
(func: string) => ({
label: startCase(func), label: startCase(func),
value: func, value: func,
}))} })
)}
placeholder={t('label.select-field', { placeholder={t('label.select-field', {
field: t('label.effect'), field: t('label.effect'),
})} })}
@ -476,22 +715,9 @@ const AddAlertPage = () => {
onClick={() => remove(name)} onClick={() => remove(name)}
/> />
</div> </div>
{fields.length - 1 !== key && (
<Divider style={{ margin: '8px 0' }} />
)}
</div> </div>
))} ))}
<Form.Item> <Form.ErrorList errors={errors} />
<Button
block
icon={<PlusOutlined />}
type="dashed"
onClick={() => add()}>
{t('label.add-entity', {
entity: t('label.filter-plural'),
})}
</Button>
</Form.Item>
</> </>
)} )}
</Form.List> </Form.List>
@ -504,11 +730,36 @@ const AddAlertPage = () => {
subHeading={t('message.alerts-destination-description')} subHeading={t('message.alerts-destination-description')}
/> />
<Form.List name="alertActions"> <Form.List
{(fields, { add, remove }) => ( name="alertActions"
rules={[
{
validator: listLengthValidator(
t('label.destination')
),
},
]}>
{(fields, { add, remove }, { errors }) => (
<> <>
<Form.Item>
<Button
block
disabled={provider === ProviderType.System}
icon={<PlusOutlined />}
type="default"
onClick={() => add({}, 0)}>
{t('label.add-entity', {
entity: t('label.destination'),
})}
</Button>
</Form.Item>
{fields.map(({ key, name }) => ( {fields.map(({ key, name }) => (
<div key={`alertActions-${key}`}> <div key={`alertActions-${key}`}>
{name > 0 && (
<Divider
style={{ margin: 0, marginBottom: '16px' }}
/>
)}
<div className="d-flex" style={{ gap: '10px' }}> <div className="d-flex" style={{ gap: '10px' }}>
<div className="flex-1"> <div className="flex-1">
<Form.Item <Form.Item
@ -516,12 +767,16 @@ const AddAlertPage = () => {
key={key} key={key}
name={[name, 'alertActionType']}> name={[name, 'alertActionType']}>
<Select <Select
disabled={
provider === ProviderType.System
}
placeholder={t('label.select-field', { placeholder={t('label.select-field', {
field: t('label.source'), field: t('label.source'),
})} })}
showSearch={false}> showSearch={false}>
{map(AlertActionType, (value) => { {map(AlertActionType, (value) => {
return ( return value ===
AlertActionType.ActivityFeed ? null : (
<Select.Option <Select.Option
key={value} key={value}
value={value}> value={value}>
@ -529,77 +784,16 @@ const AddAlertPage = () => {
{getAlertsActionTypeIcon( {getAlertsActionTypeIcon(
value as AlertActionType value as AlertActionType
)} )}
{value} {getAlertActionTypeDisplayName(
value
)}
</Space> </Space>
</Select.Option> </Select.Option>
); );
})} })}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item required name={[name, 'name']}> {getDestinationConfigFields(name)}
<Input placeholder={t('label.name')} />
</Form.Item>
<Form.Item
required
name={[
name,
'alertActionConfig',
'endpoint',
]}>
<Input
placeholder={
t('label.endpoint-url') +
': ' +
'http(s)://www.example.com'
}
/>
</Form.Item>
<Form.Item
label={t('label.advanced-config')}
name={[name, 'enabled']}
valuePropName="checked">
<Switch />
</Form.Item>
{get(
alertActions,
`${name}.enabled`,
false
) && (
<>
<Space className="w-full" size={16}>
<Form.Item
initialValue={10}
label="Batch Size"
labelCol={{ span: 24 }}
name={[name, 'batchSize']}>
<Input defaultValue={10} />
</Form.Item>
<Form.Item
colon
initialValue={10}
label={`${t(
'label.connection-timeout-plural-optional'
)}`}
labelCol={{ span: 24 }}
name={[name, 'timeout']}>
<Input />
</Form.Item>
</Space>
<Form.Item
label={t('label.secret-key')}
labelCol={{ span: 24 }}
name={[
name,
'alertActionConfig',
'secretKey',
]}>
<Input
placeholder={t('label.secret-key')}
/>
</Form.Item>
</>
)}
</div> </div>
<Button <Button
data-testid={`remove-filter-rule-${name}`} data-testid={`remove-filter-rule-${name}`}
@ -614,21 +808,9 @@ const AddAlertPage = () => {
onClick={() => remove(name)} onClick={() => remove(name)}
/> />
</div> </div>
{fields.length - 1 !== key && <Divider />}
</div> </div>
))} ))}
<Form.Item> <Form.ErrorList errors={errors} />
<Button
block
icon={<PlusOutlined />}
type="dashed"
onClick={() => add()}>
{t('label.add-entity', {
entity: t('label.destination'),
})}
</Button>
</Form.Item>
</> </>
)} )}
</Form.List> </Form.List>
@ -643,9 +825,12 @@ const AddAlertPage = () => {
</Button> </Button>
</Col> </Col>
</Row> </Row>
</Form.Item>
</Card> </Card>
</Form> </Form>
</Col> </Col>
<Col span={24} />
<Col span={24} />
</Row> </Row>
</> </>
); );

View File

@ -13,7 +13,7 @@
.alerts-notification-form { .alerts-notification-form {
.ant-form-item { .ant-form-item {
margin-bottom: 12px; margin-bottom: 16px;
} }
.footer { .footer {

View File

@ -0,0 +1,99 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Table, Typography } from 'antd';
import { startCase } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getAlertActionForAlerts } from '../../axiosAPIs/alertsAPI';
import Loader from '../../components/Loader/Loader';
import { AlertAction } from '../../generated/alerts/alertAction';
import { Alerts } from '../../generated/alerts/alerts';
import { showErrorToast } from '../../utils/ToastUtils';
export const AlertsExpanded = ({ alert }: { alert: Alerts }) => {
const [alertActions, setAlertActions] = useState<AlertAction[]>([]);
const [loading, setLoading] = useState(true);
const { t } = useTranslation();
const fetchAlertActions = useCallback(async () => {
try {
setLoading(true);
const response = await getAlertActionForAlerts(alert.id);
setAlertActions(response);
} catch (error) {
showErrorToast(
t('server.entity-fetch-error', { entity: t('label.alert-actions') })
);
} finally {
setLoading(false);
}
}, [alert.id]);
useEffect(() => {
if (alert.id) {
fetchAlertActions();
}
}, [alert.id]);
const columns = useMemo(
() => [
{
title: t('label.name'),
dataIndex: 'displayName',
width: '200px',
key: 'displayName',
},
{
title: t('label.destination'),
dataIndex: 'alertActionType',
width: '200px',
key: 'alertActionType',
render: startCase,
},
{
title: t('label.batch-size'),
dataIndex: 'batchSize',
width: '200px',
key: 'batchSize',
},
{
title: t('label.timeout'),
dataIndex: 'timeout',
width: '200px',
key: 'timeout',
},
{
title: t('label.endpoint'),
dataIndex: ['alertActionConfig', 'endpoint'],
width: '200px',
key: 'endpoint',
},
],
[]
);
return (
<div className="p-x-sm">
<Typography.Title level={5}>{t('label.alert-actions')}</Typography.Title>
<Table
bordered={false}
columns={columns}
dataSource={alertActions}
loading={{ spinning: loading, indicator: <Loader size="small" /> }}
pagination={false}
rowKey="name"
size="small"
/>
</div>
);
};

View File

@ -10,11 +10,14 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import Icon from '@ant-design/icons/lib/components/Icon';
import { Button, Col, Row, Table, Tag, Tooltip, Typography } from 'antd'; import { Button, Col, Row, Table, Tag, Tooltip, Typography } from 'antd';
import { isNil } from 'lodash'; import { isNil } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ReactComponent as DropDownIcon } from '../../assets/svg/DropDown.svg';
import { ReactComponent as RightArrowIcon } from '../../assets/svg/ic-right-arrow.svg';
import { getAllAlerts } from '../../axiosAPIs/alertsAPI'; import { getAllAlerts } from '../../axiosAPIs/alertsAPI';
import DeleteWidgetModal from '../../components/common/DeleteWidget/DeleteWidgetModal'; import DeleteWidgetModal from '../../components/common/DeleteWidget/DeleteWidgetModal';
import NextPrevious from '../../components/common/next-previous/NextPrevious'; import NextPrevious from '../../components/common/next-previous/NextPrevious';
@ -32,6 +35,7 @@ import { getDisplayNameForTriggerType } from '../../utils/Alerts/AlertsUtil';
import { getSettingPath } from '../../utils/RouterUtils'; import { getSettingPath } from '../../utils/RouterUtils';
import SVGIcons, { Icons } from '../../utils/SvgUtils'; import SVGIcons, { Icons } from '../../utils/SvgUtils';
import { showErrorToast } from '../../utils/ToastUtils'; import { showErrorToast } from '../../utils/ToastUtils';
import { AlertsExpanded } from './AlertRowExpanded';
const AlertsPage = () => { const AlertsPage = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -166,6 +170,17 @@ const AlertsPage = () => {
bordered bordered
columns={columns} columns={columns}
dataSource={alerts} dataSource={alerts}
expandable={{
expandedRowRender: (record) => <AlertsExpanded alert={record} />,
expandIcon: ({ expanded, onExpand, expandable, record }) =>
expandable && (
<Icon
component={expanded ? DropDownIcon : RightArrowIcon}
size={16}
onClick={(e) => onExpand(record, e)}
/>
),
}}
loading={{ spinning: loading, indicator: <Loader /> }} loading={{ spinning: loading, indicator: <Loader /> }}
pagination={false} pagination={false}
rowKey="id" rowKey="id"
@ -187,6 +202,7 @@ const AlertsPage = () => {
<DeleteWidgetModal <DeleteWidgetModal
afterDeleteAction={handleAlertDelete} afterDeleteAction={handleAlertDelete}
allowSoftDelete={false}
entityId={selectedAlert?.id || ''} entityId={selectedAlert?.id || ''}
entityName={selectedAlert?.name || ''} entityName={selectedAlert?.name || ''}
entityType={EntityType.ALERT} entityType={EntityType.ALERT}

View File

@ -324,3 +324,16 @@
.quick-filter-dropdown-trigger-btn:focus { .quick-filter-dropdown-trigger-btn:focus {
background-color: @trigger-btn-hover-bg; background-color: @trigger-btn-hover-bg;
} }
// Rotate
.rotate-inverse {
transform: rotate(180deg);
}
.rotate-90 {
transform: rotate(90deg);
}
.rotate--90 {
transform: rotate(-90deg);
}

View File

@ -362,10 +362,6 @@
animation: highlight 3000ms ease-out; animation: highlight 3000ms ease-out;
} }
.rotate-inverse {
transform: rotate(180deg);
}
.rjsf.no-header #root__title, .rjsf.no-header #root__title,
.rjsf.no-header #root__description, .rjsf.no-header #root__description,
.rjsf .form-additional .control-label, .rjsf .form-additional .control-label,

View File

@ -11,14 +11,15 @@
* limitations under the License. * limitations under the License.
*/ */
import { Card, Typography } from 'antd'; import { Typography } from 'antd';
import { RuleObject } from 'antd/lib/form';
import i18next from 'i18next'; import i18next from 'i18next';
import React from 'react'; import React from 'react';
import { ReactComponent as AllActivityIcon } from '../../assets/svg/all-activity.svg'; import { ReactComponent as AllActivityIcon } from '../../assets/svg/all-activity.svg';
import { ReactComponent as MailIcon } from '../../assets/svg/ic-mail.svg'; import { ReactComponent as MailIcon } from '../../assets/svg/ic-mail.svg';
import { ReactComponent as MSTeamsIcon } from '../../assets/svg/ms-teams-grey.svg'; import { ReactComponent as MSTeamsIcon } from '../../assets/svg/ms-teams.svg';
import { ReactComponent as SlackIcon } from '../../assets/svg/slack-grey.svg'; import { ReactComponent as SlackIcon } from '../../assets/svg/slack.svg';
import { ReactComponent as WebhookIcon } from '../../assets/svg/webhook-grey.svg'; import { ReactComponent as WebhookIcon } from '../../assets/svg/webhook.svg';
import { AlertActionType } from '../../generated/alerts/alertAction'; import { AlertActionType } from '../../generated/alerts/alertAction';
import { AlertTriggerType } from '../../generated/alerts/alerts'; import { AlertTriggerType } from '../../generated/alerts/alerts';
@ -65,13 +66,13 @@ export const StyledCard = ({
subHeading: string; subHeading: string;
}) => { }) => {
return ( return (
<Card bordered={false} className="bg-grey"> <div className="bg-grey p-sm rounded-4">
<Typography.Text>{heading}</Typography.Text> <Typography.Text>{heading}</Typography.Text>
<br /> <br />
<Typography.Text className="text-xs text-grey-muted"> <Typography.Text className="text-xs text-grey-muted">
{subHeading} {subHeading}
</Typography.Text> </Typography.Text>
</Card> </div>
); );
}; };
@ -83,3 +84,43 @@ export const getDisplayNameForTriggerType = (type: AlertTriggerType) => {
return i18next.t('label.specific-data-assets'); return i18next.t('label.specific-data-assets');
} }
}; };
/**
*
* @param name Field name used to identify which field has error
* @param minLengthRequired how many item should be there in the list
* @returns If validation failed throws an error else resolve
*/
export const listLengthValidator =
<T,>(name: string, minLengthRequired = 1) =>
async (_: RuleObject, list: T[]) => {
if (!list || list.length < minLengthRequired) {
return Promise.reject(
new Error(
i18next.t('message.length-validator-error', {
length: minLengthRequired,
field: name,
})
)
);
}
return Promise.resolve();
};
export const getAlertActionTypeDisplayName = (
alertActionType: AlertActionType
) => {
switch (alertActionType) {
case AlertActionType.ActivityFeed:
return i18next.t('label.activity-feed');
case AlertActionType.Email:
return i18next.t('label.email');
case AlertActionType.GenericWebhook:
return i18next.t('label.webhook');
case AlertActionType.SlackWebhook:
return i18next.t('label.slack');
case AlertActionType.MSTeamsWebhook:
return i18next.t('label.ms-teams');
}
};

View File

@ -11,8 +11,6 @@
* limitations under the License. * limitations under the License.
*/ */
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Typography } from 'antd'; import { Typography } from 'antd';
import { ExpandableConfig } from 'antd/lib/table/interface'; import { ExpandableConfig } from 'antd/lib/table/interface';
import classNames from 'classnames'; import classNames from 'classnames';
@ -21,6 +19,7 @@ import { isEmpty, upperCase } from 'lodash';
import { EntityTags } from 'Models'; import { EntityTags } from 'Models';
import React from 'react'; import React from 'react';
import { ReactComponent as DashboardIcon } from '../assets/svg/dashboard-grey.svg'; import { ReactComponent as DashboardIcon } from '../assets/svg/dashboard-grey.svg';
import { ReactComponent as DropDownIcon } from '../assets/svg/DropDown.svg';
import { ReactComponent as MlModelIcon } from '../assets/svg/mlmodal.svg'; import { ReactComponent as MlModelIcon } from '../assets/svg/mlmodal.svg';
import { ReactComponent as PipelineIcon } from '../assets/svg/pipeline-grey.svg'; import { ReactComponent as PipelineIcon } from '../assets/svg/pipeline-grey.svg';
import { ReactComponent as TableIcon } from '../assets/svg/table-grey.svg'; import { ReactComponent as TableIcon } from '../assets/svg/table-grey.svg';
@ -373,7 +372,7 @@ export function getTableExpandableConfig<
className="m-r-xs cursor-pointer" className="m-r-xs cursor-pointer"
data-testid="expand-icon" data-testid="expand-icon"
onClick={(e) => onExpand(record, e)}> onClick={(e) => onExpand(record, e)}>
<FontAwesomeIcon icon={expanded ? faCaretDown : faCaretRight} /> {expanded ? <DropDownIcon /> : <DropDownIcon />}
</Typography.Text> </Typography.Text>
), ),
}; };