Merge pull request #698 from grafana/dev
Preparing for the release: merge dev to main
This commit is contained in:
commit
2c0b87d95c
39 changed files with 1899 additions and 1453 deletions
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
|
@ -27,6 +27,22 @@ jobs:
|
|||
run: |
|
||||
pre-commit run --all-files
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container: python:3.9
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 14.17.0
|
||||
- name: Unit Testing Frontend
|
||||
run: |
|
||||
pip install $(grep "pre-commit" engine/requirements.txt)
|
||||
npm install -g yarn
|
||||
cd grafana-plugin/
|
||||
yarn --network-timeout 500000
|
||||
yarn test
|
||||
|
||||
test-technical-documentation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
# Change Log
|
||||
|
||||
## v1.0.41 (2022-10-24)
|
||||
- Add personal email notifications
|
||||
- Bug fixes
|
||||
|
||||
## v1.0.40 (2022-10-05)
|
||||
- Improved database and celery backends support
|
||||
- Added script to import PagerDuty users to Grafana
|
||||
|
|
|
|||
|
|
@ -385,10 +385,19 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
organization=kwargs["organization"],
|
||||
integration=kwargs["integration"],
|
||||
team=kwargs["team"],
|
||||
deleted_at=None,
|
||||
)
|
||||
except cls.DoesNotExist:
|
||||
kwargs.update(defaults)
|
||||
alert_receive_channel = cls.create(**kwargs)
|
||||
except cls.MultipleObjectsReturned:
|
||||
# general team may inherit integrations from deleted teams
|
||||
alert_receive_channel = cls.objects.filter(
|
||||
organization=kwargs["organization"],
|
||||
integration=kwargs["integration"],
|
||||
team=kwargs["team"],
|
||||
deleted_at=None,
|
||||
).first()
|
||||
return alert_receive_channel
|
||||
|
||||
@property
|
||||
|
|
|
|||
|
|
@ -144,3 +144,28 @@ def test_notify_maintenance_with_general_channel(make_organization, make_alert_r
|
|||
mock_post_message.assert_called_once_with(
|
||||
organization, organization.general_log_channel_id, "maintenance mode enabled"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_or_create_manual_integration_deleted_team(make_organization, make_team, make_alert_receive_channel):
|
||||
organization = make_organization(general_log_channel_id="CHANNEL-ID")
|
||||
# setup general manual integration
|
||||
general_manual = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=organization, team=None, integration=AlertReceiveChannel.INTEGRATION_MANUAL, defaults={}
|
||||
)
|
||||
# setup another team manual integration
|
||||
team1 = make_team(organization)
|
||||
team1_manual = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=organization, team=team1, integration=AlertReceiveChannel.INTEGRATION_MANUAL, defaults={}
|
||||
)
|
||||
|
||||
# team is deleted
|
||||
team1.delete()
|
||||
team1_manual.refresh_from_db()
|
||||
assert team1_manual.team is None
|
||||
|
||||
# it should still be possible to get a manual integration for general team
|
||||
integration = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=organization, team=None, integration=AlertReceiveChannel.INTEGRATION_MANUAL, defaults={}
|
||||
)
|
||||
assert integration == general_manual
|
||||
|
|
|
|||
|
|
@ -76,7 +76,8 @@ class ScheduleBaseSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
def get_number_of_escalation_chains(self, obj):
|
||||
# num_escalation_chains param added in queryset via annotate. Check ScheduleView.get_queryset
|
||||
# return 0 for just created schedules
|
||||
return getattr(obj, "num_escalation_chains", 0)
|
||||
num = getattr(obj, "num_escalation_chains", 0)
|
||||
return num or 0
|
||||
|
||||
def validate(self, attrs):
|
||||
if "slack_channel_id" in attrs:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from rest_framework.views import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.alerts.models import EscalationChain
|
||||
from apps.alerts.models import EscalationChain, EscalationPolicy
|
||||
from apps.api.permissions import MODIFY_ACTIONS, READ_ACTIONS, ActionPermission, AnyRole, IsAdmin, IsAdminOrEditor
|
||||
from apps.api.serializers.schedule_base import ScheduleFastSerializer
|
||||
from apps.api.serializers.schedule_polymorphic import (
|
||||
|
|
@ -108,22 +108,28 @@ class ScheduleView(
|
|||
slack_team_identity=organization.slack_team_identity,
|
||||
slack_id=OuterRef("channel"),
|
||||
)
|
||||
escalation_policies = (
|
||||
EscalationPolicy.objects.values("notify_schedule")
|
||||
.order_by("notify_schedule")
|
||||
.annotate(num_escalation_chains=Count("notify_schedule"))
|
||||
.filter(notify_schedule=OuterRef("id"))
|
||||
)
|
||||
queryset = queryset.annotate(
|
||||
slack_channel_name=Subquery(slack_channels.values("name")[:1]),
|
||||
slack_channel_pk=Subquery(slack_channels.values("public_primary_key")[:1]),
|
||||
num_escalation_chains=Count(
|
||||
"escalation_policies__escalation_chain",
|
||||
distinct=True,
|
||||
),
|
||||
num_escalation_chains=Subquery(escalation_policies.values("num_escalation_chains")[:1]),
|
||||
)
|
||||
return queryset
|
||||
|
||||
def get_queryset(self):
|
||||
is_short_request = self.request.query_params.get("short", "false") == "true"
|
||||
organization = self.request.auth.organization
|
||||
queryset = OnCallSchedule.objects.filter(
|
||||
organization=organization,
|
||||
team=self.request.user.current_team,
|
||||
queryset = OnCallSchedule.objects.filter(organization=organization, team=self.request.user.current_team).defer(
|
||||
# avoid requesting large text fields which are not used when listing schedules
|
||||
"cached_ical_file_primary",
|
||||
"prev_ical_file_primary",
|
||||
"cached_ical_file_overrides",
|
||||
"prev_ical_file_overrides",
|
||||
)
|
||||
if not is_short_request:
|
||||
queryset = self._annotate_queryset(queryset)
|
||||
|
|
|
|||
|
|
@ -42,13 +42,13 @@ def users_in_ical(usernames_from_ical, organization, include_viewers=False):
|
|||
Parse ical file and return list of users found
|
||||
"""
|
||||
# Only grafana username will be used, consider adding grafana email and id
|
||||
|
||||
users_found_in_ical = organization.users
|
||||
if not include_viewers:
|
||||
users_found_in_ical = users_found_in_ical.filter(role__in=(Role.ADMIN, Role.EDITOR))
|
||||
|
||||
user_emails = [v.lower() for v in usernames_from_ical]
|
||||
users_found_in_ical = users_found_in_ical.filter(
|
||||
(Q(username__in=usernames_from_ical) | Q(email__in=usernames_from_ical))
|
||||
(Q(username__in=usernames_from_ical) | Q(email__lower__in=user_emails))
|
||||
).distinct()
|
||||
|
||||
# Here is the example how we extracted users previously, using slack fields too
|
||||
|
|
@ -394,8 +394,8 @@ def get_missing_users_from_ical_event(event, organization):
|
|||
all_usernames, _ = get_usernames_from_ical_event(event)
|
||||
users = list(get_users_from_ical_event(event, organization))
|
||||
found_usernames = [u.username for u in users]
|
||||
found_emails = [u.email for u in users]
|
||||
return [u for u in all_usernames if u != "" and u not in found_usernames and u not in found_emails]
|
||||
found_emails = [u.email.lower() for u in users]
|
||||
return [u for u in all_usernames if u != "" and u not in found_usernames and u.lower() not in found_emails]
|
||||
|
||||
|
||||
def get_users_from_ical_event(event, organization):
|
||||
|
|
@ -536,7 +536,8 @@ def get_user_events_from_calendars(ical_obj: Calendar, calendars: tuple, user: U
|
|||
for component in calendar.walk():
|
||||
if component.name == "VEVENT":
|
||||
event_user = get_usernames_from_ical_event(component)
|
||||
if event_user[0][0] in [user.username, user.email]:
|
||||
event_user_value = event_user[0][0]
|
||||
if event_user_value == user.username or event_user_value.lower() == user.email.lower():
|
||||
ical_obj.add_component(component)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar
|
|||
from common.constants.role import Role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_users_in_ical_email_case_insensitive(make_organization_and_user, make_user_for_organization):
|
||||
organization, user = make_organization_and_user()
|
||||
user = make_user_for_organization(organization, username="foo", email="TestingUser@test.com")
|
||||
|
||||
usernames = ["testinguser@test.com"]
|
||||
result = users_in_ical(usernames, organization)
|
||||
assert set(result) == {user}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"include_viewers",
|
||||
|
|
|
|||
|
|
@ -358,7 +358,7 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep):
|
|||
author_verbal = resolution_note.author_verbal(mention=True)
|
||||
resolution_note_text_block = {
|
||||
"type": "section",
|
||||
"text": {"type": "plain_text", "text": resolution_note.text, "emoji": True},
|
||||
"text": {"type": "mrkdwn", "text": resolution_note.text, "emoji": True},
|
||||
}
|
||||
blocks.append(resolution_note_text_block)
|
||||
context_block = {
|
||||
|
|
|
|||
16
engine/apps/user_management/apps.py
Normal file
16
engine/apps/user_management/apps.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from django.apps import AppConfig
|
||||
from django.db import models
|
||||
|
||||
|
||||
# enable a __lower field lookup for email fields
|
||||
# https://docs.djangoproject.com/en/4.1/howto/custom-lookups/#a-bilateral-transformer-example
|
||||
class LowerCase(models.Transform):
|
||||
lookup_name = "lower"
|
||||
function = "LOWER"
|
||||
|
||||
|
||||
class UserManagementConfig(AppConfig):
|
||||
name = "apps.user_management"
|
||||
|
||||
def ready(self):
|
||||
models.EmailField.register_lookup(LowerCase)
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import pytest
|
||||
|
||||
from apps.user_management.models import User
|
||||
from common.constants.role import Role
|
||||
|
||||
|
||||
|
|
@ -22,3 +23,16 @@ def test_self_or_admin(
|
|||
assert admin.self_or_admin(editor, organization) is False
|
||||
assert admin.self_or_admin(second_admin, organization) is True
|
||||
assert admin.self_or_admin(admin_from_another_organization, organization) is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_lower_email_filter(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
):
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization, email="TestingUser@test.com")
|
||||
make_user_for_organization(organization, email="testing_user@test.com")
|
||||
|
||||
assert User.objects.get(email__lower="testinguser@test.com") == user
|
||||
assert User.objects.filter(email__lower__in=["testinguser@test.com"]).get() == user
|
||||
|
|
|
|||
|
|
@ -1,8 +1,29 @@
|
|||
// This file is needed because it is used by vscode and other tools that
|
||||
// call `jest` directly. However, unless you are doing anything special
|
||||
// do not edit this file
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
const standard = require('@grafana/toolkit/src/config/jest.plugin.config');
|
||||
moduleDirectories: ['node_modules', 'src'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
||||
|
||||
// This process will use the same config that `yarn test` is using
|
||||
module.exports = standard.jestConfig();
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
isolatedModules: true,
|
||||
babelConfig: true
|
||||
},
|
||||
},
|
||||
|
||||
transform: {
|
||||
'^.+\\.js?$': require.resolve('babel-jest'),
|
||||
'^.+\\.jsx?$': require.resolve('babel-jest'),
|
||||
'^.+\\.ts?$': require.resolve('ts-jest'),
|
||||
'^.+\\.tsx?$': require.resolve('ts-jest'),
|
||||
},
|
||||
|
||||
moduleNameMapper: {
|
||||
"grafana/app/(.*)": '<rootDir>/src/jest/grafanaMock.ts',
|
||||
"jest/outgoingWebhooksStub": '<rootDir>/src/jest/outgoingWebhooksStub.ts',
|
||||
"^jest$": '<rootDir>/src/jest',
|
||||
'^.+\\.(css|scss)$': '<rootDir>/src/jest/styleMock.ts',
|
||||
"^lodash-es$": "lodash",
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"stylelint": "stylelint ./src/**/*.css",
|
||||
"stylelint:fix": "stylelint --fix ./src/**/*.css",
|
||||
"build": "grafana-toolkit plugin:build",
|
||||
"test": "grafana-toolkit plugin:test",
|
||||
"test": "jest --verbose",
|
||||
"dev": "grafana-toolkit plugin:dev",
|
||||
"watch": "grafana-toolkit plugin:dev --watch",
|
||||
"sign": "grafana-toolkit plugin:sign",
|
||||
|
|
@ -55,21 +55,30 @@
|
|||
"@grafana/runtime": "^9.1.1",
|
||||
"@grafana/toolkit": "^9.1.1",
|
||||
"@grafana/ui": "^9.1.1",
|
||||
"@jest/globals": "^27.5.1",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "12",
|
||||
"@types/dompurify": "^2.3.4",
|
||||
"@types/jest": "^27.5.1",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/react-copy-to-clipboard": "^5.0.4",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-responsive": "^8.0.5",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-test-renderer": "^17.0.2",
|
||||
"@types/throttle-debounce": "^5.0.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"dompurify": "^2.3.12",
|
||||
"eslint-plugin-rulesdir": "^0.2.1",
|
||||
"jest": "^27.5.1",
|
||||
"jest-environment-jsdom": "^27.5.1",
|
||||
"lint-staged": "^10.2.11",
|
||||
"lodash-es": "^4.17.21",
|
||||
"moment-timezone": "^0.5.35",
|
||||
"plop": "^2.7.4",
|
||||
"postcss-loader": "^7.0.1",
|
||||
"react-test-renderer": "^17.0.2",
|
||||
"ts-jest": "^27.1.3",
|
||||
"ts-loader": "^9.3.1",
|
||||
"webpack-bundle-analyzer": "^4.6.1"
|
||||
},
|
||||
|
|
|
|||
26
grafana-plugin/src/components/Avatar/Avatar.test.tsx
Normal file
26
grafana-plugin/src/components/Avatar/Avatar.test.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
|
||||
import Avatar from 'components/Avatar/Avatar';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
describe('Avatar', () => {
|
||||
const avatarSrc = 'http://avatar.com/';
|
||||
const avatarSizeLarge = 'large';
|
||||
const avatarSizeSmall = 'small';
|
||||
|
||||
test("Avatar's image points to given src attribute", async () => {
|
||||
render(<Avatar size={avatarSizeLarge} src={avatarSrc} />);
|
||||
const imageEl = await screen.findByTestId<HTMLImageElement>('test__avatar');
|
||||
expect(imageEl.src).toBe(avatarSrc);
|
||||
});
|
||||
|
||||
test('Avatar appends sizing class', async () => {
|
||||
render(<Avatar size={avatarSizeSmall} src={avatarSrc} />);
|
||||
const imageEl = await screen.findByTestId<HTMLImageElement>('test__avatar');
|
||||
expect(imageEl.classList).toContain(`avatarSize-${avatarSizeSmall}`);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,7 +19,7 @@ const Avatar: FC<AvatarProps> = (props) => {
|
|||
return null;
|
||||
}
|
||||
|
||||
return <img src={src} className={cx('root', `avatarSize-${size}`, className)} {...rest} />;
|
||||
return <img src={src} className={cx('root', `avatarSize-${size}`, className)} data-testid="test__avatar" {...rest} />;
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
|
|
|
|||
37
grafana-plugin/src/components/CardButton/CardButton.test.tsx
Normal file
37
grafana-plugin/src/components/CardButton/CardButton.test.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import 'jest/matchMedia.ts';
|
||||
import React from 'react';
|
||||
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import CardButton from 'components/CardButton/CardButton';
|
||||
|
||||
describe('CardButton', () => {
|
||||
function getProps(onClickMock: jest.Mock = jest.fn()) {
|
||||
return {
|
||||
icon: <></>,
|
||||
description: 'Description',
|
||||
title: 'Title',
|
||||
selected: true,
|
||||
onClick: onClickMock,
|
||||
};
|
||||
}
|
||||
|
||||
test('It updates class and calls onClick prop on click', () => {
|
||||
const onClickMock = jest.fn();
|
||||
render(<CardButton {...getProps(onClickMock)} />);
|
||||
|
||||
const rootEl = getRootBlockEl()
|
||||
|
||||
fireEvent.click(rootEl);
|
||||
|
||||
expect(rootEl.classList).toContain("root_selected")
|
||||
expect(onClickMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function getRootBlockEl(): HTMLElement {
|
||||
return screen.queryByTestId<HTMLElement>('test__cardButton');
|
||||
}
|
||||
});
|
||||
|
|
@ -26,7 +26,7 @@ const CardButton: FC<CardButtonProps> = (props) => {
|
|||
}, [selected]);
|
||||
|
||||
return (
|
||||
<Block onClick={handleClick} withBackground className={cx('root', { root_selected: selected })}>
|
||||
<Block onClick={handleClick} withBackground className={cx('root', { root_selected: selected })} data-testid='test__cardButton'>
|
||||
<div className={cx('icon')}>{icon}</div>
|
||||
<div className={cx('meta')}>
|
||||
<VerticalGroup spacing="xs">
|
||||
|
|
|
|||
53
grafana-plugin/src/components/Collapse/Collapse.test.tsx
Normal file
53
grafana-plugin/src/components/Collapse/Collapse.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'jest/matchMedia.ts';
|
||||
import React from 'react';
|
||||
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
|
||||
import Collapse, { CollapseProps } from 'components/Collapse/Collapse';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
describe('Collapse', () => {
|
||||
function getProps(isOpen: boolean, onClick: jest.Mock = jest.fn()) {
|
||||
return {
|
||||
label: 'Toggle',
|
||||
isOpen: isOpen,
|
||||
onClick: onClick
|
||||
} as CollapseProps
|
||||
}
|
||||
|
||||
test('Content becomes visible on click', () => {
|
||||
render(<Collapse {...getProps(false)} />);
|
||||
|
||||
const hiddenChildrenContent = getChildrenEl();
|
||||
expect(hiddenChildrenContent).toBeNull();
|
||||
|
||||
const toggler = getTogglerEl();
|
||||
fireEvent.click(toggler);
|
||||
|
||||
expect(hiddenChildrenContent).toBeDefined();
|
||||
});
|
||||
|
||||
test('Content is collapsed for [isOpen=false]', () => {
|
||||
render(<Collapse {...getProps(false)} />);
|
||||
|
||||
const content = getChildrenEl();
|
||||
expect(content).toBeNull();
|
||||
})
|
||||
|
||||
test('Content is not collapsed for [isOpen=true]', () => {
|
||||
render(<Collapse {...getProps(true)} />);
|
||||
|
||||
const content = getChildrenEl();
|
||||
expect(content).toBeDefined();
|
||||
});
|
||||
|
||||
function getChildrenEl(): HTMLElement {
|
||||
return screen.queryByTestId<HTMLElement>('test__children');
|
||||
}
|
||||
|
||||
function getTogglerEl(): HTMLElement {
|
||||
return screen.queryByTestId<HTMLElement>('test__toggle');
|
||||
}
|
||||
});
|
||||
|
|
@ -3,11 +3,9 @@ import React, { FC, useCallback, useState } from 'react';
|
|||
import { Icon } from '@grafana/ui';
|
||||
import cn from 'classnames/bind';
|
||||
|
||||
import Block from 'components/GBlock/Block';
|
||||
|
||||
import styles from 'components/Collapse/Collapse.module.css';
|
||||
|
||||
interface CollapseProps {
|
||||
export interface CollapseProps {
|
||||
label: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onToggle?: (isOpen: boolean) => void;
|
||||
|
|
@ -45,11 +43,19 @@ const Collapse: FC<CollapseProps> = (props) => {
|
|||
|
||||
return (
|
||||
<div className={cx('root', className)}>
|
||||
<div className={cx('header', { 'header_with-background': headerWithBackground })} onClick={onHeaderClickCallback}>
|
||||
<div
|
||||
className={cx('header', { 'header_with-background': headerWithBackground })}
|
||||
onClick={onHeaderClickCallback}
|
||||
data-testid="test__toggle"
|
||||
>
|
||||
<Icon name={isOpen ? 'angle-down' : 'angle-right'} size="xl" className={cx('icon')} />
|
||||
<div className={cx('label')}> {label}</div>
|
||||
</div>
|
||||
{isOpen && <div className={cx('content', contentClassName)}>{children}</div>}
|
||||
{isOpen && (
|
||||
<div className={cx('content', contentClassName)} data-testid="test__children">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -99,9 +99,10 @@ const GForm = (props: GFormProps) => {
|
|||
return (
|
||||
<Form maxWidth="none" id={form.name} defaultValues={data} onSubmit={handleSubmit}>
|
||||
{({ register, errors, control }) => {
|
||||
return form.fields.map((formItem: FormItem) => {
|
||||
return form.fields.map((formItem: FormItem, formIndex: number) => {
|
||||
return (
|
||||
<Field
|
||||
key={formIndex}
|
||||
disabled={formItem.getDisabled ? formItem.getDisabled(data) : false}
|
||||
label={formItem.label || capitalCase(formItem.name)}
|
||||
invalid={!!errors[formItem.name]}
|
||||
|
|
|
|||
|
|
@ -107,16 +107,6 @@ const GTable: FC<Props> = (props) => {
|
|||
[data]
|
||||
);
|
||||
|
||||
/* useEffect(() => { // todo clear selection on data change
|
||||
if (rowSelection && rowSelection.selectedRowKeys.length) {
|
||||
const { selectedRowKeys, onChange } = rowSelection;
|
||||
const newSelectedRowKeys = selectedRowKeys.filter((key: string) =>
|
||||
data.some((item: any) => item[rowKey as string] === key)
|
||||
);
|
||||
onChange(newSelectedRowKeys);
|
||||
}
|
||||
}, [data?.length]); */
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columns = [...columnsProp];
|
||||
|
||||
|
|
@ -146,7 +136,7 @@ const GTable: FC<Props> = (props) => {
|
|||
}, [rowSelection, columnsProp, data]);
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root')} data-testid="test__gTable">
|
||||
<Table
|
||||
expandable={expandable}
|
||||
rowKey={rowKey}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ export default function PageErrorHandlingWrapper({
|
|||
|
||||
const store = useStore();
|
||||
|
||||
if (!errorData.isWrongTeamError) {return children();}
|
||||
if (!errorData.isWrongTeamError) {
|
||||
return children();
|
||||
}
|
||||
|
||||
const currentTeamId = store.userStore.currentUser?.current_team;
|
||||
const currentTeam = store.grafanaTeamStore.items[currentTeamId]?.name;
|
||||
|
|
|
|||
35
grafana-plugin/src/components/SourceCode/SourceCode.test.tsx
Normal file
35
grafana-plugin/src/components/SourceCode/SourceCode.test.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import 'jest/matchMedia.ts';
|
||||
import React from 'react';
|
||||
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import SourceCode from './SourceCode';
|
||||
|
||||
describe('SourceCode', () => {
|
||||
test("SourceCode doesn't render clipboard for [showCopyToClipboard=false]", () => {
|
||||
render(<SourceCode showCopyToClipboard={false} />);
|
||||
const codeEl = screen.queryByRole<HTMLElement>('code');
|
||||
expect(codeEl).toBeNull();
|
||||
});
|
||||
|
||||
test('SourceCode renders clipboard for [showCopyToClipboard=true]', () => {
|
||||
render(<SourceCode showCopyToClipboard />);
|
||||
const codeEl = screen.queryByRole<HTMLElement>('code');
|
||||
expect(codeEl).toBeDefined();
|
||||
});
|
||||
|
||||
test('SourceCode displays just copy icon for [showClipboardIconOnly=true]', () => {
|
||||
render(<SourceCode showClipboardIconOnly />);
|
||||
expect(screen.queryByTestId<HTMLElement>('test__copyIcon')).toBeDefined();
|
||||
expect(screen.queryByTestId<HTMLElement>('test__copyIconWithText')).toBeNull();
|
||||
});
|
||||
|
||||
test('SourceCode displays copy icon and text for [showClipboardIconOnly=false]', () => {
|
||||
render(<SourceCode />);
|
||||
expect(screen.queryByTestId<HTMLElement>('test__copyIcon')).toBeNull();
|
||||
expect(screen.queryByTestId<HTMLElement>('test__copyIconWithText')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -31,9 +31,9 @@ const SourceCode: FC<SourceCodeProps> = (props) => {
|
|||
}}
|
||||
>
|
||||
{showClipboardIconOnly ? (
|
||||
<IconButton className={cx('copyIcon')} size={'lg'} name="copy" />
|
||||
<IconButton className={cx('copyIcon')} size={'lg'} name="copy" data-testid="test__copyIcon" />
|
||||
) : (
|
||||
<Button className={cx('copyButton')} variant="primary" size="xs" icon="copy">
|
||||
<Button className={cx('copyButton')} variant="primary" size="xs" icon="copy" data-testid="test__copyIconWithText">
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
|
|||
onClose={onHide}
|
||||
closeOnMaskClick
|
||||
>
|
||||
<div className={cx('content')}>
|
||||
<div className={cx('content')} data-testid="test__outgoingWebhookEditForm">
|
||||
<GForm form={form} data={data} onSubmit={handleSubmit} />
|
||||
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
|
||||
<Button form={form.name} type="submit">
|
||||
|
|
|
|||
1
grafana-plugin/src/jest/grafanaMock.ts
Normal file
1
grafana-plugin/src/jest/grafanaMock.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default {};
|
||||
15
grafana-plugin/src/jest/matchMedia.ts
Normal file
15
grafana-plugin/src/jest/matchMedia.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// @ts-ignore
|
||||
export default global.matchMedia =
|
||||
global.matchMedia ||
|
||||
function (query) {
|
||||
return {
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
};
|
||||
26
grafana-plugin/src/jest/outgoingWebhooksStub.ts
Normal file
26
grafana-plugin/src/jest/outgoingWebhooksStub.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { OutgoingWebhook } from "models/outgoing_webhook/outgoing_webhook.types";
|
||||
|
||||
export default [
|
||||
{
|
||||
id: 'K2E45EI2586HS',
|
||||
name: 'hook-1',
|
||||
team: null,
|
||||
webhook: 'http://google.ro',
|
||||
data: null,
|
||||
user: 'rares',
|
||||
password: 'password',
|
||||
authorization_header: 'auth-header',
|
||||
forward_whole_payload: false,
|
||||
},
|
||||
{
|
||||
id: 'KL3UZQQF2KE5V',
|
||||
name: 'hook-3',
|
||||
team: null,
|
||||
webhook: 'http://google.ro',
|
||||
data: null,
|
||||
user: null,
|
||||
password: null,
|
||||
authorization_header: null,
|
||||
forward_whole_payload: false,
|
||||
},
|
||||
] as OutgoingWebhook[];
|
||||
1
grafana-plugin/src/jest/styleMock.ts
Normal file
1
grafana-plugin/src/jest/styleMock.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default {};
|
||||
13
grafana-plugin/src/jest/utils.ts
Normal file
13
grafana-plugin/src/jest/utils.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function mockUseStore() {
|
||||
jest.mock('state/useStore', () => ({
|
||||
useStore: () => ({
|
||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function mockGrafanaLocationSrv() {
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
getLocationSrv: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import 'jest/matchMedia.ts';
|
||||
import React from 'react';
|
||||
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import outgoingWebhooksStub from 'jest/outgoingWebhooksStub';
|
||||
|
||||
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
|
||||
|
||||
import { OutgoingWebhooks } from './OutgoingWebhooks';
|
||||
|
||||
|
||||
const outgoingWebhooks = outgoingWebhooksStub as OutgoingWebhook[];
|
||||
const outgoingWebhookStore = () => ({
|
||||
loadItem: () => Promise.resolve(outgoingWebhooks[0]),
|
||||
updateItems: () => Promise.resolve(),
|
||||
getSearchResult: () => outgoingWebhooks,
|
||||
items: outgoingWebhooks.reduce((prev, current) => {
|
||||
prev[current.id] = current;
|
||||
return prev;
|
||||
}, {}),
|
||||
});
|
||||
|
||||
jest.mock('state/useStore', () => ({
|
||||
useStore: () => ({
|
||||
outgoingWebhookStore: outgoingWebhookStore(),
|
||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||
}),
|
||||
}));
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
getLocationSrv: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('OutgoingWebhooks', () => {
|
||||
const storeMock = {
|
||||
isUserActionAllowed: jest.fn().mockReturnValue(true),
|
||||
outgoingWebhookStore: outgoingWebhookStore(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
console.warn = () => {};
|
||||
console.error = () => {};
|
||||
});
|
||||
|
||||
test('It renders all retrieved webhooks', async () => {
|
||||
render(<OutgoingWebhooks {...getProps()} />);
|
||||
|
||||
const gTable = screen.queryByTestId('test__gTable');
|
||||
const rows = gTable.querySelectorAll('tbody tr');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(() => queryEditForm()).toThrow(); // edit doesn't show for [id=undefined]
|
||||
expect(rows.length).toBe(outgoingWebhooks.length);
|
||||
});
|
||||
});
|
||||
|
||||
test('It opens Edit View if [id] is supplied', async () => {
|
||||
const id = outgoingWebhooks[0].id;
|
||||
render(<OutgoingWebhooks {...getProps(id)} />);
|
||||
|
||||
expect(() => queryEditForm()).toThrow(); // before updates kick in
|
||||
await waitFor(() => {
|
||||
expect(queryEditForm()).toBeDefined(); // edit shows for [id=?]
|
||||
});
|
||||
});
|
||||
|
||||
function getProps(id: OutgoingWebhook['id'] = undefined): any {
|
||||
return { store: storeMock, query: { id } };
|
||||
}
|
||||
|
||||
function queryEditForm(): HTMLElement {
|
||||
return screen.getByTestId<HTMLElement>('test__outgoingWebhookEditForm');
|
||||
}
|
||||
});
|
||||
|
|
@ -60,7 +60,9 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
query: { id },
|
||||
} = this.props;
|
||||
|
||||
if (!id) {return;}
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let outgoingWebhook: OutgoingWebhook | void = undefined;
|
||||
const isNewWebhook = id === 'new';
|
||||
|
|
@ -193,4 +195,6 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
|
|||
};
|
||||
}
|
||||
|
||||
export { OutgoingWebhooks };
|
||||
|
||||
export default withMobXProviderContext(OutgoingWebhooks);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -229,3 +229,28 @@
|
|||
name: {{ template "snippet.redis.password.secret.name" . }}
|
||||
key: redis-password
|
||||
{{- end }}
|
||||
|
||||
{{- define "snippet.oncall.smtp.env" -}}
|
||||
{{- if .Values.oncall.smtp.enabled -}}
|
||||
- name: FEATURE_EMAIL_INTEGRATION_ENABLED
|
||||
value: {{ .Values.oncall.smtp.enabled | toString | title | quote }}
|
||||
- name: EMAIL_HOST
|
||||
value: {{ .Values.oncall.smtp.host | quote }}
|
||||
- name: EMAIL_PORT
|
||||
value: {{ .Values.oncall.smtp.port | default "587" | quote }}
|
||||
- name: EMAIL_HOST_USER
|
||||
value: {{ .Values.oncall.smtp.username | quote }}
|
||||
- name: EMAIL_HOST_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "oncall.fullname" . }}-smtp
|
||||
key: smtp-password
|
||||
- name: EMAIL_USE_TLS
|
||||
value: {{ .Values.oncall.smtp.tls | toString | title | quote }}
|
||||
- name: DEFAULT_FROM_EMAIL
|
||||
value: {{ .Values.oncall.smtp.fromEmail | quote }}
|
||||
{{- else -}}
|
||||
- name: FEATURE_EMAIL_INTEGRATION_ENABLED
|
||||
value: {{ .Values.oncall.smtp.enabled | toString | title | quote }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ spec:
|
|||
{{- include "snippet.oncall.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.slack.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.telegram.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.smtp.env" . | nindent 12 }}
|
||||
{{- include "snippet.mysql.env" . | nindent 12 }}
|
||||
{{- include "snippet.rabbitmq.env" . | nindent 12 }}
|
||||
{{- include "snippet.redis.env" . | nindent 12 }}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ spec:
|
|||
{{- include "snippet.oncall.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.slack.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.telegram.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.smtp.env" . | nindent 12 }}
|
||||
{{- include "snippet.mysql.env" . | nindent 12 }}
|
||||
{{- include "snippet.rabbitmq.env" . | nindent 12 }}
|
||||
{{- include "snippet.redis.env" . | nindent 12 }}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ spec:
|
|||
python manage.py migrate
|
||||
env:
|
||||
{{- include "snippet.oncall.env" . | nindent 12 }}
|
||||
{{- include "snippet.oncall.smtp.env" . | nindent 12 }}
|
||||
{{- include "snippet.mysql.env" . | nindent 12 }}
|
||||
{{- include "snippet.rabbitmq.env" . | nindent 12 }}
|
||||
{{- include "snippet.redis.env" . | nindent 12 }}
|
||||
|
|
|
|||
|
|
@ -40,4 +40,13 @@ type: Opaque
|
|||
data:
|
||||
redis-password: {{ required "externalRedis.password is required if not redis.enabled" .Values.externalRedis.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
|
||||
---
|
||||
{{ if .Values.oncall.smtp.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "oncall.fullname" . }}-smtp
|
||||
type: Opaque
|
||||
data:
|
||||
smtp-password: {{ required "oncall.smtp.password is required if oncall.smtp.enabled" .Values.oncall.smtp.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ oncall:
|
|||
enabled: false
|
||||
token: ~
|
||||
webhookUrl: ~
|
||||
smtp:
|
||||
enabled: false
|
||||
host: ~
|
||||
port: ~
|
||||
username: ~
|
||||
password: ~
|
||||
tls: ~
|
||||
fromEmail: ~
|
||||
|
||||
# Whether to run django database migrations automatically
|
||||
migrate:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue