diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..83ad455a --- /dev/null +++ b/.babelrc @@ -0,0 +1,18 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { "node": "10" } + } + ], + "@babel/preset-react", + "@babel/preset-typescript" + ], + "plugins": [ + "@babel/plugin-transform-modules-commonjs", + ["@babel/plugin-transform-runtime", { "regenerator": true }], + "@babel/plugin-proposal-class-properties", + "@babel/plugin-proposal-object-rest-spread" + ] +} \ No newline at end of file diff --git a/.cypress/fixtures/test_chime_channel.json b/.cypress/fixtures/test_chime_channel.json new file mode 100644 index 00000000..feb56cc1 --- /dev/null +++ b/.cypress/fixtures/test_chime_channel.json @@ -0,0 +1,11 @@ +{ + "config": { + "name": "Test chime channel", + "description": "A test chime channel", + "config_type": "chime", + "is_enabled": true, + "chime": { + "url": "https://sample-chime-webhook" + } + } +} \ No newline at end of file diff --git a/.cypress/fixtures/test_email_recipient_group.json b/.cypress/fixtures/test_email_recipient_group.json new file mode 100644 index 00000000..6f043b7c --- /dev/null +++ b/.cypress/fixtures/test_email_recipient_group.json @@ -0,0 +1,30 @@ +{ + "config": { + "name": "Test recipient group", + "description": "A test email recipient group", + "config_type": "email_group", + "is_enabled": true, + "email_group": { + "recipient_list": [ + { + "recipient": "custom.email.1@test.com" + }, + { + "recipient": "custom.email.2@test.com" + }, + { + "recipient": "custom.email.3@test.com" + }, + { + "recipient": "custom.email.4@test.com" + }, + { + "recipient": "custom.email.5@test.com" + }, + { + "recipient": "custom.email.6@test.com" + } + ] + } + } +} \ No newline at end of file diff --git a/.cypress/fixtures/test_ses_sender.json b/.cypress/fixtures/test_ses_sender.json new file mode 100644 index 00000000..1395b936 --- /dev/null +++ b/.cypress/fixtures/test_ses_sender.json @@ -0,0 +1,13 @@ +{ + "config": { + "name": "test-ses-sender", + "description": "A test SES sender", + "config_type": "ses_account", + "is_enabled": true, + "ses_account": { + "region": "us-east-1", + "role_arn": "arn:aws:iam::012345678912:role/NotificationsSESRole", + "from_address": "test@email.com" + } + } +} \ No newline at end of file diff --git a/.cypress/fixtures/test_slack_channel.json b/.cypress/fixtures/test_slack_channel.json new file mode 100644 index 00000000..7643b2b0 --- /dev/null +++ b/.cypress/fixtures/test_slack_channel.json @@ -0,0 +1,11 @@ +{ + "config": { + "name": "Test slack channel", + "description": "A test slack channel", + "config_type": "slack", + "is_enabled": true, + "slack": { + "url": "https://sample-slack-webhook" + } + } +} \ No newline at end of file diff --git a/.cypress/fixtures/test_smtp_email_channel.json b/.cypress/fixtures/test_smtp_email_channel.json new file mode 100644 index 00000000..f6ddc82a --- /dev/null +++ b/.cypress/fixtures/test_smtp_email_channel.json @@ -0,0 +1,17 @@ +{ + "config": { + "name": "Test email channel", + "description": "A test SMTP email channel", + "config_type": "email", + "is_enabled": true, + "email": { + "email_account_id": "test_smtp_sender_id", + "recipient_list": [ + { + "recipient": "custom.email@test.com" + } + ], + "email_group_id_list": [] + } + } +} \ No newline at end of file diff --git a/.cypress/fixtures/test_sns_channel.json b/.cypress/fixtures/test_sns_channel.json new file mode 100644 index 00000000..579eabd6 --- /dev/null +++ b/.cypress/fixtures/test_sns_channel.json @@ -0,0 +1,12 @@ +{ + "config": { + "name": "test-sns-channel", + "description": "A test SNS channel", + "config_type": "sns", + "is_enabled": true, + "sns": { + "topic_arn": "arn:aws:sns:us-west-2:123456789012:notifications-test", + "role_arn": "arn:aws:iam::012345678901:role/NotificationsSNSRole" + } + } +} \ No newline at end of file diff --git a/.cypress/fixtures/test_ssl_smtp_sender.json b/.cypress/fixtures/test_ssl_smtp_sender.json new file mode 100644 index 00000000..977369a0 --- /dev/null +++ b/.cypress/fixtures/test_ssl_smtp_sender.json @@ -0,0 +1,14 @@ +{ + "config": { + "name": "test-ssl-sender", + "description": "A test SSL SMTP sender", + "config_type": "smtp_account", + "is_enabled": true, + "smtp_account": { + "host": "test-host.com", + "port": 123, + "method": "ssl", + "from_address": "test@email.com" + } + } +} \ No newline at end of file diff --git a/.cypress/fixtures/test_tls_smtp_sender.json b/.cypress/fixtures/test_tls_smtp_sender.json new file mode 100644 index 00000000..cfa9870e --- /dev/null +++ b/.cypress/fixtures/test_tls_smtp_sender.json @@ -0,0 +1,15 @@ +{ + "config_id": "test_smtp_sender_id", + "config": { + "name": "test-tls-sender", + "description": "A test TLS SMTP sender", + "config_type": "smtp_account", + "is_enabled": true, + "smtp_account": { + "host": "test-host.com", + "port": 123, + "method": "start_tls", + "from_address": "test@email.com" + } + } +} \ No newline at end of file diff --git a/.cypress/fixtures/test_webhook_channel.json b/.cypress/fixtures/test_webhook_channel.json new file mode 100644 index 00000000..86d6ec10 --- /dev/null +++ b/.cypress/fixtures/test_webhook_channel.json @@ -0,0 +1,12 @@ +{ + "config": { + "name": "Test webhook channel", + "description": "A test webhook channel", + "config_type": "webhook", + "is_enabled": true, + "webhook": { + "url": "https://custom-webhook-test-url.com:8888/test-path?params1=value1¶ms2=value2¶ms3=value3¶ms4=value4¶ms5=values5¶ms6=values6¶ms7=values7" + } + } +} + diff --git a/.cypress/integration/channels.spec.js b/.cypress/integration/channels.spec.js new file mode 100644 index 00000000..f0fd65b6 --- /dev/null +++ b/.cypress/integration/channels.spec.js @@ -0,0 +1,319 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import { delay } from '../utils/constants'; +import testSlackChannel from '../fixtures/test_slack_channel'; +import testChimeChannel from '../fixtures/test_chime_channel'; +import testWebhookChannel from '../fixtures/test_webhook_channel.json'; +import testTlsSmtpSender from '../fixtures/test_tls_smtp_sender'; + +describe('Test create channels', () => { + before(() => { + // Delete all Notification configs + cy.deleteAllConfigs(); + + cy.createConfig(testTlsSmtpSender); + }); + + beforeEach(() => { + cy.visit( + `${Cypress.env( + 'opensearchDashboards' + )}/app/notifications-dashboards#create-channel` + ); + cy.wait(delay * 3); + }); + + it('creates a slack channel and send test message', () => { + cy.get('[data-test-subj="create-channel-create-button"]').click(); + cy.contains('Some fields are invalid.').should('exist'); + + cy.get('[placeholder="Enter channel name"]').type('Test slack channel'); + cy.get('[data-test-subj="create-channel-slack-webhook-input"]').type( + 'https://sample-slack-webhook' + ); + cy.wait(delay); + cy.get('[data-test-subj="create-channel-send-test-message-button"]').click({ + force: true, + }); + cy.wait(delay); + // This needs some time to appear as it will wait for backend call to timeout + cy.contains('test message.').should('exist'); + + cy.get('[data-test-subj="create-channel-create-button"]').click({ + force: true, + }); + cy.contains('successfully created.').should('exist'); + }); + + it('creates a chime channel', () => { + cy.get('[placeholder="Enter channel name"]').type('Test chime channel'); + + cy.get('.euiSuperSelectControl').contains('Slack').click({ force: true }); + cy.wait(delay); + cy.get('.euiContextMenuItem__text') + .contains('Chime') + .click({ force: true }); + cy.wait(delay); + + cy.get('[data-test-subj="create-channel-chime-webhook-input"]').type( + 'https://sample-chime-webhook' + ); + cy.wait(delay); + + cy.get('[data-test-subj="create-channel-create-button"]').click(); + cy.contains('successfully created.').should('exist'); + }); + + it('creates an email channel', () => { + cy.get('[placeholder="Enter channel name"]').type('Test email channel'); + + cy.get('.euiSuperSelectControl').contains('Slack').click({ force: true }); + cy.wait(delay); + cy.get('.euiContextMenuItem__text') + .contains('Email') + .click({ force: true }); + cy.wait(delay); + + // custom data-test-subj does not work on combo box + cy.get('[data-test-subj="comboBoxInput"]').eq(0).click({ force: true }); + cy.contains('test-tls-sender').click(); + + cy.get('.euiButton__text') + .contains('Create recipient group') + .click({ force: true }); + cy.get('[data-test-subj="create-recipient-group-form-name-input"]').type( + 'Test recipient group' + ); + cy.get( + '[data-test-subj="create-recipient-group-form-description-input"]' + ).type('Recipient group created while creating email channel.'); + cy.get('[data-test-subj="comboBoxInput"]') + .last() + .type('custom.email@test.com{enter}'); + cy.wait(delay); + cy.get( + '[data-test-subj="create-recipient-group-modal-create-button"]' + ).click(); + cy.contains('successfully created.').should('exist'); + + cy.get('[data-test-subj="create-channel-create-button"]').click(); + cy.contains('successfully created.').should('exist'); + }); + + it('creates an email channel with ses sender', () => { + cy.get('[placeholder="Enter channel name"]').type('Test email channel with ses'); + + cy.get('.euiSuperSelectControl').contains('Slack').click({ force: true }); + cy.wait(delay); + cy.get('.euiContextMenuItem__text') + .contains('Email') + .click({ force: true }); + cy.wait(delay); + + cy.get('input.euiRadio__input#ses_account').click({ force: true }); + cy.wait(delay); + + cy.get('.euiButton__text') + .contains('Create SES sender') + .click({ force: true }); + cy.get('[data-test-subj="create-ses-sender-form-name-input"]').type( + 'test-ses-sender' + ); + cy.get('[data-test-subj="create-ses-sender-form-email-input"]').type( + 'test@email.com' + ); + cy.get('[data-test-subj="create-ses-sender-form-role-arn-input"]').type( + 'arn:aws:iam::012345678912:role/NotificationsSESRole' + ); + cy.get('[data-test-subj="create-ses-sender-form-aws-region-input"]').type( + 'us-east-1' + ); + cy.get( + '[data-test-subj="create-ses-sender-modal-create-button"]' + ).click(); + cy.contains('successfully created.').should('exist'); + + // custom data-test-subj does not work on combo box + cy.get('[data-test-subj="comboBoxInput"]').eq(1).click({ force: true }); + cy.contains('Test recipient group').click(); + cy.wait(delay); + + cy.get('[data-test-subj="create-channel-create-button"]').click(); + cy.contains('successfully created.').should('exist'); + }); + + it('creates a webhook channel', () => { + cy.get('[placeholder="Enter channel name"]').type('Test webhook channel'); + + cy.get('.euiSuperSelectControl').contains('Slack').click({ force: true }); + cy.wait(delay); + cy.get('.euiContextMenuItem__text') + .contains('Custom webhook') + .click({ force: true }); + cy.wait(delay); + + cy.get('[data-test-subj="custom-webhook-url-input"]').type( + 'https://custom-webhook-test-url.com:8888/test-path?params1=value1¶ms2=value2¶ms3=value3¶ms4=value4¶ms5=values5¶ms6=values6¶ms7=values7' + ); + + cy.get('[data-test-subj="create-channel-create-button"]').click(); + cy.contains('successfully created.').should('exist'); + }); + + it('creates an sns channel', () => { + cy.get('[placeholder="Enter channel name"]').type('test-sns-channel'); + + cy.get('.euiSuperSelectControl').contains('Slack').click({ force: true }); + cy.wait(delay); + cy.get('.euiContextMenuItem__text') + .contains('Amazon SNS') + .click({ force: true }); + cy.wait(delay); + + cy.get('[data-test-subj="sns-settings-topic-arn-input"]').type( + 'arn:aws:sns:us-west-2:123456789012:notifications-test' + ); + cy.get('[data-test-subj="sns-settings-role-arn-input"]').type( + 'arn:aws:iam::012345678901:role/NotificationsSNSRole' + ); + + cy.get('[data-test-subj="create-channel-create-button"]').click(); + cy.contains('successfully created.').should('exist'); + }); +}); + +describe('Test channels table', () => { + before(() => { + // Delete all Notification configs + cy.deleteAllConfigs(); + + // Create test channels + cy.createConfig(testSlackChannel); + cy.createConfig(testChimeChannel); + cy.createConfig(testWebhookChannel); + cy.createTestEmailChannel(); + }); + + beforeEach(() => { + cy.visit( + `${Cypress.env( + 'opensearchDashboards' + )}/app/notifications-dashboards#channels` + ); + cy.wait(delay * 3); + }); + + it('displays channels', async () => { + cy.contains('Test slack channel').should('exist'); + cy.contains('Test email channel').should('exist'); + cy.contains('Test chime channel').should('exist'); + cy.contains('Test webhook channel').should('exist'); + }); + + it('mutes channels', async () => { + cy.get('.euiCheckbox__input[aria-label="Select this row"]').eq(0).click(); // chime channel + cy.get('.euiButton__text').contains('Actions').click({ force: true }); + cy.wait(delay); + cy.get('.euiContextMenuItem__text').contains('Mute').click({ force: true }); + cy.wait(delay); + cy.get('[data-test-subj="mute-channel-modal-mute-button"]').click({ + force: true, + }); + cy.wait(delay); + cy.contains('successfully muted.').should('exist'); + cy.contains('Muted').should('exist'); + }); + + it('filters channels', async () => { + cy.get('input[placeholder="Search"]').type('chime{enter}'); + cy.wait(delay); + cy.contains('Test chime channel').should('exist'); + cy.contains('Test slack channel').should('not.exist'); + cy.contains('Test email channel').should('not.exist'); + cy.contains('Test webhook channel').should('not.exist'); + + cy.get('.euiButtonEmpty__text').contains('Source').click({ force: true }); + cy.get('.euiFilterSelectItem__content') + .contains('ISM') + .click({ force: true }); + cy.wait(delay); + cy.contains('No channels to display').should('exist'); + }); +}); + +describe('Test channel details', () => { + // TODO: For some reason, the cleanup being done in the before() of this test + // is attempting to delete the same config twice causing 404 errors. + // The other tests don't seem to do it. We can add this back after root causing and fixing it. + // before(() => { + // // Delete all Notification configs + // cy.deleteAllConfigs(); + // cy.wait(delay * 3); + // + // // Create test channels + // cy.createConfig(testSlackChannel); + // cy.createConfig(testChimeChannel); + // cy.createConfig(testWebhookChannel); + // cy.createTestEmailChannel(); + // }); + + beforeEach(() => { + cy.visit( + `${Cypress.env( + 'opensearchDashboards' + )}/app/notifications-dashboards#channels` + ); + cy.contains('Test webhook channel').click(); + }); + + it('displays channel details', async () => { + cy.contains('custom-webhook-test-url.com').should('exist'); + cy.contains('test-path').should('exist'); + cy.contains('8888').should('exist'); + cy.contains('2 more').click(); + cy.contains('Query parameters (7)').should('exist'); + cy.contains('params7').should('exist'); + }); + + it('mutes and unmutes channels', async () => { + cy.contains('Mute channel').click({ force: true }); + cy.get('[data-test-subj="mute-channel-modal-mute-button"]').click({ + force: true, + }); + cy.contains('successfully muted.').should('exist'); + cy.contains('Muted').should('exist'); + + cy.contains('Unmute channel').click({ force: true }); + cy.contains('successfully unmuted.').should('exist'); + cy.contains('Active').should('exist'); + }); + + it('edits channels', () => { + cy.contains('Actions').click({ force: true }); + cy.contains('Edit').click({ force: true }); + cy.contains('Edit channel').should('exist'); + cy.get('.euiText').contains('Custom webhook').should('exist'); + cy.get( + '[data-test-subj="create-channel-description-input"]' + ).type('{selectall}{backspace}Updated custom webhook description'); + cy.wait(delay); + cy.contains('Save').click({ force: true }); + + cy.contains('successfully updated.').should('exist'); + cy.contains('Updated custom webhook description').should('exist'); + }) + + it('deletes channels', async () => { + cy.contains('Actions').click({ force: true }); + cy.contains('Delete').click({ force: true }); + cy.get('input[placeholder="delete"]').type('delete'); + cy.get('[data-test-subj="delete-channel-modal-delete-button"]').click({force: true}) + cy.contains('successfully deleted.').should('exist'); + cy.contains('Test slack channel').should('exist'); + }) +}); diff --git a/.cypress/integration/email_senders_and_groups.spec.js b/.cypress/integration/email_senders_and_groups.spec.js new file mode 100644 index 00000000..76155d93 --- /dev/null +++ b/.cypress/integration/email_senders_and_groups.spec.js @@ -0,0 +1,271 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import { delay } from '../utils/constants'; +import testSslSmtpSender from '../fixtures/test_ssl_smtp_sender'; +import testTlsSmtpSender from '../fixtures/test_tls_smtp_sender'; +import testSesSender from '../fixtures/test_ses_sender'; +import testEmailRecipientGroup from '../fixtures/test_email_recipient_group'; + +describe('Test create email senders', () => { + before(() => { + // Delete all Notification configs + cy.deleteAllConfigs(); + }); + + beforeEach(() => { + cy.visit( + `${Cypress.env( + 'opensearchDashboards' + )}/app/notifications-dashboards#email-senders` + ); + cy.reload(true); + cy.wait(delay * 5); + }); + + it('creates ssl sender', () => { + cy.get('.euiButton__text') + .contains('Create SMTP sender') + .click({ force: true }); + cy.get('[data-test-subj="create-sender-form-name-input"]').type( + 'test-ssl-sender' + ); + cy.get('.euiButton__text').contains('Create').click({ force: true }); + cy.contains('Some fields are invalid.').should('exist'); + + cy.get('[data-test-subj="create-sender-form-email-input"]').type( + 'test@email.com' + ); + cy.get('[data-test-subj="create-sender-form-host-input"]').type( + 'test-host.com' + ); + cy.get('[data-test-subj="create-sender-form-port-input"]').type('123'); + cy.get('.euiButton__text').contains('Create').click({ force: true }); + cy.contains('successfully created.').should('exist'); + cy.contains('test-ssl-sender').should('exist'); + }); + + it('creates tls sender', () => { + cy.get('.euiButton__text') + .contains('Create SMTP sender') + .click({ force: true }); + cy.get('[data-test-subj="create-sender-form-name-input"]').type( + 'test-tls-sender' + ); + cy.get('[data-test-subj="create-sender-form-email-input"]').type( + 'test@email.com' + ); + cy.get('[data-test-subj="create-sender-form-host-input"]').type( + 'test-host.com' + ); + cy.get('[data-test-subj="create-sender-form-port-input"]').type('123'); + cy.get('[data-test-subj="create-sender-form-encryption-input"]').click({ + force: true, + }); + cy.wait(delay); + cy.get('.euiContextMenuItem__text').contains('TLS').click({ force: true }); + cy.wait(delay); + + cy.get('.euiButton__text').contains('Create').click({ force: true }); + cy.contains('successfully created.').should('exist'); + cy.contains('test-tls-sender').should('exist'); + }); + + it('creates SES sender', () => { + cy.get('.euiButton__text') + .contains('Create SES sender') + .click({ force: true }); + cy.get('[data-test-subj="create-ses-sender-form-name-input"]').type( + 'test-ses-sender' + ); + cy.get('[data-test-subj="create-ses-sender-form-email-input"]').type( + 'test@email.com' + ); + cy.get('[data-test-subj="create-ses-sender-form-role-arn-input"]').type( + 'arn:aws:iam::012345678912:role/NotificationsSESRole' + ); + cy.get('[data-test-subj="create-ses-sender-form-aws-region-input"]').type( + 'us-east-1' + ); + + cy.get('.euiButton__text').contains('Create').click({ force: true }); + cy.contains('successfully created.').should('exist'); + cy.contains('test-ses-sender').should('exist'); + }); +}); + +describe('Test edit senders', () => { + before(() => { + // Delete all Notification configs + cy.deleteAllConfigs(); + + cy.createConfig(testSslSmtpSender); + cy.createConfig(testTlsSmtpSender); + cy.createConfig(testSesSender); + }); + + beforeEach(() => { + cy.visit( + `${Cypress.env( + 'opensearchDashboards' + )}/app/notifications-dashboards#email-senders` + ); + cy.reload(true); + cy.wait(delay * 5); + }); + + it('edits sender email address', () => { + cy.get('.euiCheckbox__input[aria-label="Select this row"]').eq(0).click(); // ssl sender + cy.get('[data-test-subj="senders-table-edit-button"]').click(); + cy.get('[data-test-subj="create-sender-form-email-input"]').type( + '{selectall}{backspace}editedtest@email.com' + ); + cy.wait(delay); + + cy.get('.euiButton__text').contains('Save').click({ force: true }); + cy.contains('successfully updated.').should('exist'); + }); + + it('edits ses sender region', () => { + cy.get('.euiCheckbox__input[aria-label="Select this row"]').eq(2).click(); // ses sender + cy.get('[data-test-subj="ses-senders-table-edit-button"]').click(); + cy.get('[data-test-subj="create-ses-sender-form-aws-region-input"]').type( + '{selectall}{backspace}us-west-2' + ); + cy.wait(delay); + + cy.get('.euiButton__text').contains('Save').click({ force: true }); + cy.contains('successfully updated.').should('exist'); + }); +}); + +describe('Test delete senders', () => { + before(() => { + // Delete all Notification configs + cy.deleteAllConfigs(); + + cy.createConfig(testSslSmtpSender); + cy.createConfig(testTlsSmtpSender); + cy.createConfig(testSesSender); + }); + + beforeEach(() => { + cy.visit( + `${Cypress.env( + 'opensearchDashboards' + )}/app/notifications-dashboards#email-senders` + ); + cy.reload(true); + cy.wait(delay * 5); + }); + + it('deletes smtp senders', () => { + cy.get('.euiCheckbox__input[aria-label="Select this row"]').eq(0).click(); // ssl sender + cy.get('[data-test-subj="senders-table-delete-button"]').click({ force: true }); + cy.get('input[placeholder="delete"]').type('delete'); + cy.wait(delay); + cy.get('[data-test-subj="delete-sender-modal-delete-button"]').click(); + cy.contains('successfully deleted.').should('exist'); + }); + + it('deletes ses senders', () => { + cy.get('.euiCheckbox__input[aria-label="Select this row"]').last().click(); // ses sender + cy.get('[data-test-subj="ses-senders-table-delete-button"]').click({ force: true }); + cy.get('input[placeholder="delete"]').type('delete'); + cy.wait(delay); + cy.get('[data-test-subj="delete-sender-modal-delete-button"]').click(); + cy.contains('successfully deleted.').should('exist'); + + cy.contains('No SES senders to display').should('exist'); + }); +}); + +describe('Test create, edit and delete recipient group', () => { + beforeEach(() => { + // Delete all Notification configs + cy.deleteAllConfigs(); + + cy.createConfig(testEmailRecipientGroup); + + cy.visit( + `${Cypress.env( + 'opensearchDashboards' + )}/app/notifications-dashboards#email-recipient-groups` + ); + cy.reload(true); + cy.wait(delay * 5); + }); + + it('creates recipient group', () => { + cy.get('.euiButton__text') + .contains('Create recipient group') + .click({ force: true }); + cy.get('[data-test-subj="create-recipient-group-form-name-input"]').type( + 'Test recipient group' + ); + cy.get('.euiButton__text').contains('Create').click({ force: true }); + cy.contains('Some fields are invalid.').should('exist'); + + cy.get( + '[data-test-subj="create-recipient-group-form-description-input"]' + ).type('Test group description'); + cy.get('[data-test-subj="comboBoxInput"]').type( + 'custom.email.1@test.com{enter}' + ); + cy.get('[data-test-subj="comboBoxInput"]').type( + 'custom.email.2@test.com{enter}' + ); + cy.get('[data-test-subj="comboBoxInput"]').type( + 'custom.email.3@test.com{enter}' + ); + cy.get('[data-test-subj="comboBoxInput"]').type( + 'custom.email.4@test.com{enter}' + ); + cy.get('[data-test-subj="comboBoxInput"]').type( + 'custom.email.5@test.com{enter}' + ); + cy.get('[data-test-subj="comboBoxInput"]').type( + 'custom.email.6@test.com{enter}' + ); + cy.wait(delay); + + cy.get('.euiButton__text').contains('Create').click({ force: true }); + cy.contains('successfully created.').should('exist'); + cy.contains('Test recipient group').should('exist'); + cy.wait(delay); + }); + + it('edits recipient group description', () => { + cy.get('.euiCheckbox__input[aria-label="Select this row"]').last().click({ force: true }); // recipient group + cy.get('[data-test-subj="recipient-groups-table-edit-button"]').click({ force: true }); + cy.get( + '[data-test-subj="create-recipient-group-form-description-input"]' + ).type('{selectall}{backspace}Updated group description'); + cy.wait(delay); + + cy.get('.euiButton__text').contains('Save').click({ force: true }); + cy.contains('successfully updated.').should('exist'); + }); + + it('opens email addresses popup', () => { + cy.get('.euiLink').contains('1 more').click({ force: true }); + cy.contains('custom.email.6@test.com').should('exist'); + }); + + it('deletes recipient groups', () => { + cy.get('[data-test-subj="checkboxSelectAll"]').last().click(); + cy.get('[data-test-subj="recipient-groups-table-delete-button"]').click(); + cy.get('input[placeholder="delete"]').type('delete'); + cy.wait(delay); + cy.get( + '[data-test-subj="delete-recipient-group-modal-delete-button"]' + ).click({ force: true }); + cy.contains('successfully deleted.').should('exist'); + + cy.contains('No recipient groups to display').should('exist'); + }); +}); diff --git a/.cypress/plugins/index.js b/.cypress/plugins/index.js new file mode 100644 index 00000000..8ac1f106 --- /dev/null +++ b/.cypress/plugins/index.js @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/.cypress/support/commands.js b/.cypress/support/commands.js new file mode 100644 index 00000000..994f8589 --- /dev/null +++ b/.cypress/support/commands.js @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import testTlsSmtpSender from '../fixtures/test_tls_smtp_sender'; +import testSmtpEmailChannel from '../fixtures/test_smtp_email_channel'; + +const { API, ADMIN_AUTH } = require('./constants'); + +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) + +Cypress.Commands.overwrite('visit', (originalFn, url, options) => { + // Add the basic auth header when security enabled in the OpenSearch cluster + // https://github.com/cypress-io/cypress/issues/1288 + if (Cypress.env('security_enabled')) { + if (options) { + options.auth = ADMIN_AUTH; + } else { + options = { auth: ADMIN_AUTH }; + } + // Add query parameters - select the default OpenSearch Dashboards tenant + options.qs = { security_tenant: 'private' }; + return originalFn(url, options); + } else { + return originalFn(url, options); + } +}); + + // Be able to add default options to cy.request(), https://github.com/cypress-io/cypress/issues/726 +Cypress.Commands.overwrite('request', (originalFn, ...args) => { + let defaults = {}; + // Add the basic authentication header when security enabled in the OpenSearch cluster + if (Cypress.env('security_enabled')) { + defaults.auth = ADMIN_AUTH; + } + + let options = {}; + if (typeof args[0] === 'object' && args[0] !== null) { + options = Object.assign({}, args[0]); + } else if (args.length === 1) { + [options.url] = args; + } else if (args.length === 2) { + [options.method, options.url] = args; + } else if (args.length === 3) { + [options.method, options.url, options.body] = args; + } + + return originalFn(Object.assign({}, defaults, options)); +}); + +Cypress.Commands.add('createConfig', (notificationConfigJSON) => { + cy.request('POST', `${Cypress.env('opensearch')}${API.CONFIGS_BASE}`, notificationConfigJSON); +}); + +Cypress.Commands.add('createTestEmailChannel', () => { + cy.createConfig(testTlsSmtpSender); + cy.createConfig(testSmtpEmailChannel); +}); + +Cypress.Commands.add('deleteAllConfigs', () => { + cy.request({ + method: 'GET', + url: `${Cypress.env('opensearch')}${API.CONFIGS_BASE}`, + }).then((response) => { + if (response.status === 200) { + for (let i = 0; i < response.body.total_hits; i++) { + cy.request( + 'DELETE', + `${Cypress.env('opensearch')}${API.CONFIGS_BASE}/${response.body.config_list[i].config_id}` + ); + } + } else { + cy.log('Failed to get configs.', response); + } + }); +}); diff --git a/.cypress/support/constants.js b/.cypress/support/constants.js new file mode 100644 index 00000000..624e559f --- /dev/null +++ b/.cypress/support/constants.js @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const API_ROUTE_PREFIX = '/_plugins/_notifications'; + +export const API = { + CONFIGS_BASE: `${API_ROUTE_PREFIX}/configs`, +}; + +export const ADMIN_AUTH = { + username: 'admin', + password: 'admin', +}; diff --git a/.cypress/support/index.js b/.cypress/support/index.js new file mode 100644 index 00000000..67988fc9 --- /dev/null +++ b/.cypress/support/index.js @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +// Switch the base URL of OpenSearch when security enabled in the cluster +if (Cypress.env('security_enabled')) { + Cypress.env('opensearch', 'https://localhost:9200'); +} + +Cypress.on('uncaught:exception', (err, runnable) => { + // returning false here prevents Cypress from + // failing the test on uncaught exception errors + return false +}) diff --git a/.cypress/utils/constants.js b/.cypress/utils/constants.js new file mode 100644 index 00000000..ebab3410 --- /dev/null +++ b/.cypress/utils/constants.js @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const delay = 1000; diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 00000000..d15cbc69 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,9 @@ +--- +extends: '@elastic/kibana' + +settings: + import/resolver: + '@osd/eslint-import-resolver-kibana': + rootPackageName: 'notifications-dashboards' + pluginPaths: + - . diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..ced27b55 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# This should match the owning team set up in https://github.com/orgs/opensearch-project/teams +* @opensearch-project/notifications \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..29eddb95 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: 🐛 Bug report +about: Create a report to help us improve +title: '[BUG]' +labels: 'bug, untriaged' +assignees: '' +--- + +**What is the bug?** +A clear and concise description of the bug. + +**How can one reproduce the bug?** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**What is the expected behavior?** +A clear and concise description of what you expected to happen. + +**What is your host/environment?** + - OS: [e.g. iOS] + - Version [e.g. 22] + - Plugins + +**Do you have any screenshots?** +If applicable, add screenshots to help explain your problem. + +**Do you have any additional context?** +Add any other context about the problem. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..a8199a10 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: OpenSearch Community Support + url: https://discuss.opendistrocommunity.dev/ + about: Please ask and answer questions here. + - name: AWS/Amazon Security + url: https://aws.amazon.com/security/vulnerability-reporting/ + about: Please report security vulnerabilities here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 00000000..9c4ca695 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,11 @@ +**Is your feature request related to a problem?** +A new feature has been added. + +**What solution would you like?** +Document the usage of the new feature. + +**What alternatives have you considered?** +N/A + +**Do you have any additional context?** +_Add any other context or screenshots about the feature request here._ diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..6198f338 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: 🎆 Feature request +about: Request a feature in this project +title: '[FEATURE]' +labels: 'enhancement, untriaged' +assignees: '' +--- +**Is your feature request related to a problem?** +A clear and concise description of what the problem is, e.g. _I'm always frustrated when [...]_ + +**What solution would you like?** +A clear and concise description of what you want to happen. + +**What alternatives have you considered?** +A clear and concise description of any alternative solutions or features you've considered. + +**Do you have any additional context?** +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..f88c9359 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +### Description +[Describe what this change achieves] + +### Issues Resolved +[List any issues this PR will resolve] + +### Check List +- [ ] Commits are signed per the DCO using --signoff + +By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. +For more information on following Developer Certificate of Origin and signing off your commits, please check [here](https://github.com/opensearch-project/OpenSearch/blob/main/CONTRIBUTING.md#developer-certificate-of-origin). diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 00000000..97b9c8a7 --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,33 @@ +## + # Copyright OpenSearch Contributors + # SPDX-License-Identifier: Apache-2.0 +## + +name: Backport +on: + pull_request_target: + types: + - closed + - labeled + +jobs: + backport: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + name: Backport + steps: + - name: GitHub App token + id: github_app_token + uses: tibdex/github-app-token@v1.5.0 + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + installation_id: 22958780 + + - name: Backport + uses: VachaShah/backport@v1.1.4 + with: + github_token: ${{ steps.github_app_token.outputs.token }} + branch_name: backport/backport-${{ github.event.number }} \ No newline at end of file diff --git a/.github/workflows/create-documentation-issue.yml b/.github/workflows/create-documentation-issue.yml new file mode 100644 index 00000000..e75eed7c --- /dev/null +++ b/.github/workflows/create-documentation-issue.yml @@ -0,0 +1,45 @@ +## +# Copyright OpenSearch Contributors +# SPDX-License-Identifier: Apache-2.0 +## + +name: Create Documentation Issue +on: + pull_request: + types: + - labeled +env: + PR_NUMBER: ${{ github.event.number }} + +jobs: + create-issue: + if: ${{ github.event.label.name == 'needs-documentation' }} + runs-on: ubuntu-latest + name: Create Documentation Issue + steps: + - name: GitHub App token + id: github_app_token + uses: tibdex/github-app-token@v1.5.0 + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + installation_id: 22958780 + + - name: Checkout code + uses: actions/checkout@v2 + + - name: Edit the issue template + run: | + echo "https://github.com/opensearch-project/notifications/pull/${{ env.PR_NUMBER }}." >> ./.github/ISSUE_TEMPLATE/documentation.md + - name: Create Issue From File + id: create-issue + uses: peter-evans/create-issue-from-file@v4 + with: + title: Add documentation related to new feature + content-filepath: ./.github/ISSUE_TEMPLATE/documentation.md + labels: documentation + repository: opensearch-project/documentation-website + token: ${{ steps.github_app_token.outputs.token }} + + - name: Print Issue + run: echo Created related documentation issue ${{ steps.create-issue.outputs.issue-number }} diff --git a/.github/workflows/dashboards-notifications-test-and-build-workflow.yml b/.github/workflows/dashboards-notifications-test-and-build-workflow.yml new file mode 100644 index 00000000..c28aea67 --- /dev/null +++ b/.github/workflows/dashboards-notifications-test-and-build-workflow.yml @@ -0,0 +1,196 @@ +## + # Copyright OpenSearch Contributors + # SPDX-License-Identifier: Apache-2.0 +## + +name: Test and Build Dashboards Notifications + +on: [pull_request, push] + +env: + PLUGIN_NAME: notifications-dashboards + OPENSEARCH_DASHBOARDS_VERSION: '2.4' + OPENSEARCH_VERSION: '2.4.0-SNAPSHOT' + +jobs: + tests: + env: + JEST_TEST_ARGS: ${{ matrix.jest_test_args }} + # prevents extra Cypress installation progress messages + CI: 1 + # avoid warnings like "tput: No value for $TERM and no -T specified" + TERM: xterm + WORKING_DIR: ${{ matrix.working_directory }}. + strategy: + # This setting says that all jobs should finish, even if one fails + fail-fast: false + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + include: + - os: windows-latest + working_directory: X:\ + os_java_options: -Xmx4096M + cypress_cache_folder: ~/AppData/Local/Cypress/Cache + - os: ubuntu-latest + jest_test_args: --coverage + cypress_cache_folder: ~/.cache/Cypress + - os: macos-latest + cypress_cache_folder: ~/Library/Caches/Cypress + + + name: Test and Build Dashboards Notifications on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + steps: + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: 11 + + - name: Checkout Plugin + uses: actions/checkout@v1 + + # This is a hack, but this step creates a link to the X: mounted drive, which makes the path + # short enough to work on Windows + - name: Shorten Path + if: ${{ matrix.os == 'windows-latest' }} + run: subst 'X:' . + + - name: Run Opensearch with plugin + working-directory: ${{ env.WORKING_DIR }} + run: | + # Install coreutils for macOS since timeout doesn't seem to available on that OS even when forcing bash shell + if [ "$RUNNER_OS" == "macOS" ]; then + brew install coreutils + fi + cd notifications + ./gradlew run -Dopensearch.version=${{ env.OPENSEARCH_VERSION }} & + timeout 300 bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' localhost:9200)" != "200" ]]; do sleep 5; done' + shell: bash + env: + _JAVA_OPTIONS: ${{ matrix.os_java_options }} + + - name: Checkout OpenSearch Dashboards + uses: actions/checkout@v1 + with: + repository: opensearch-project/Opensearch-Dashboards + ref: ${{ env.OPENSEARCH_DASHBOARDS_VERSION }} + path: notifications/OpenSearch-Dashboards + + - name: Get node and yarn versions + id: versions_step + run: | + echo "::set-output name=node_version::$(node -p "(require('./OpenSearch-Dashboards/package.json').engines.node).match(/[.0-9]+/)[0]")" + echo "::set-output name=yarn_version::$(node -p "(require('./OpenSearch-Dashboards/package.json').engines.yarn).match(/[.0-9]+/)[0]")" + + - name: Setup node + uses: actions/setup-node@v1 + with: + node-version: ${{ steps.versions_step.outputs.node_version }} + registry-url: 'https://registry.npmjs.org' + + - name: Install correct yarn version for OpenSearch Dashboards + run: | + npm uninstall -g yarn + echo "Installing yarn ${{ steps.versions_step.outputs.yarn_version }}" + npm i -g yarn@${{ steps.versions_step.outputs.yarn_version }} + + - name: Set npm to use bash for shell + if: ${{ matrix.os == 'windows-latest' }} + run: | + # Sets Windows to use bash for npm shell so the script commands work as intended + npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" + + - name: Move Notifications to Plugins Dir + run: mv dashboards-notifications OpenSearch-Dashboards/plugins/dashboards-notifications + + - name: OpenSearch Dashboards Plugin Bootstrap + run: | + cd OpenSearch-Dashboards/plugins/dashboards-notifications + yarn osd bootstrap + + - name: Build Artifact + run: | + cd OpenSearch-Dashboards/plugins/dashboards-notifications + yarn build + + - name: Run unit tests + uses: nick-fields/retry@v2 + with: + timeout_minutes: 30 + max_attempts: 1 + command: cd OpenSearch-Dashboards/plugins/dashboards-notifications; yarn test:jest ${{ env.JEST_TEST_ARGS }} + shell: bash + + - name: Run OpenSearch Dashboards server + run: | + cd OpenSearch-Dashboards + yarn start --no-base-path --no-watch & + timeout 400 bash -c 'while [[ "$(curl -s http://localhost:5601/api/status | jq -r '.status.overall.state')" != "green" ]]; do echo sleeping 5; sleep 5; done' + curl -sk localhost:5601/api/status | jq + netstat -anp tcp | grep LISTEN | grep 5601 || netstat -ntlp | grep 5601 + shell: bash + + - name: Install Cypress + run: | + cd OpenSearch-Dashboards/plugins/dashboards-notifications + # This will install Cypress in case the binary is missing which can happen on Windows and Mac + # If the binary exists, this will exit quickly so it should not be an expensive operation + npx cypress install + shell: bash + + - name: Get Cypress version + id: cypress_version + run: | + cd OpenSearch-Dashboards/plugins/dashboards-notifications + echo "::set-output name=cypress_version::$(cat ./package.json | jq '.dependencies.cypress' | tr -d '"')" + + - name: Cache Cypress + id: cache-cypress + uses: actions/cache@v2 + with: + path: ${{ matrix.cypress_cache_folder }} + key: cypress-cache-v2-${{ runner.os }}-${{ hashFiles('**/package.json') }} + + - name: Reset npm's script shell + if: ${{ matrix.os == 'windows-latest' }} + run: | + # Resetting npm's script shell for Windows so `yarn run cypress` doesn't have conflicts + npm config delete script-shell + + - name: Run Cypress tests + uses: cypress-io/github-action@v2 + with: + working-directory: OpenSearch-Dashboards/plugins/dashboards-notifications + command: yarn run cypress run --browser chrome + wait-on: 'http://localhost:5601' + env: + CYPRESS_CACHE_FOLDER: ${{ matrix.cypress_cache_folder }} + + # Screenshots are only captured on failure, will change this once we do visual regression tests + - uses: actions/upload-artifact@v1 + if: failure() + with: + name: cypress-screenshots-${{ matrix.os }} + path: OpenSearch-Dashboards/plugins/dashboards-notifications/.cypress/screenshots + + # Test run video was always captured, so this action uses "always()" condition + - uses: actions/upload-artifact@v1 + if: always() + with: + name: cypress-videos-${{ matrix.os }} + path: OpenSearch-Dashboards/plugins/dashboards-notifications/.cypress/videos + + - name: Upload coverage + if: ${{ matrix.os == 'ubuntu-latest' }} + uses: codecov/codecov-action@v1 + with: + flags: dashboards-notifications + directory: OpenSearch-Dashboards/plugins/ + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload Artifact + uses: actions/upload-artifact@v1 + with: + name: dashboards-notifications + path: OpenSearch-Dashboards/plugins/dashboards-notifications/build \ No newline at end of file diff --git a/.github/workflows/delete_backport_branch.yml b/.github/workflows/delete_backport_branch.yml new file mode 100644 index 00000000..1559adbc --- /dev/null +++ b/.github/workflows/delete_backport_branch.yml @@ -0,0 +1,20 @@ +## + # Copyright OpenSearch Contributors + # SPDX-License-Identifier: Apache-2.0 +## + +name: Delete merged branch of the backport PRs +on: + pull_request: + types: + - closed + +jobs: + delete-branch: + runs-on: ubuntu-latest + if: startsWith(github.event.pull_request.head.ref,'backport/') + steps: + - name: Delete merged branch + uses: SvanBoxel/delete-merged-branch@main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/link-checker.yml b/.github/workflows/link-checker.yml new file mode 100644 index 00000000..a11c7dcf --- /dev/null +++ b/.github/workflows/link-checker.yml @@ -0,0 +1,28 @@ +## + # Copyright OpenSearch Contributors + # SPDX-License-Identifier: Apache-2.0 +## + +name: Link Checker + +on: + push: + pull_request: + +jobs: + linkchecker: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: lychee Link Checker + id: lychee + uses: lycheeverse/lychee-action@master + with: + args: --accept=200,403,429 "**/*.html" "**/*.md" "**/*.txt" + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Fail if there were link errors + run: exit ${{ steps.lychee.outputs.exit_code }} + diff --git a/.github/workflows/notifications-test-and-build-workflow.yml b/.github/workflows/notifications-test-and-build-workflow.yml new file mode 100644 index 00000000..24ff9734 --- /dev/null +++ b/.github/workflows/notifications-test-and-build-workflow.yml @@ -0,0 +1,82 @@ +## + # Copyright OpenSearch Contributors + # SPDX-License-Identifier: Apache-2.0 +## + +name: Test and Build Notifications + +on: [push, pull_request] + +jobs: + build: + env: + BUILD_ARGS: ${{ matrix.os_build_args }} + WORKING_DIR: ${{ matrix.working_directory }}. + strategy: + # This setting says that all jobs should finish, even if one fails + fail-fast: false + matrix: + java: [11, 17] + os: [ ubuntu-latest, windows-latest, macos-latest ] + include: + - os: windows-latest + os_build_args: -x integTest -x jacocoTestReport + working_directory: X:\ + os_java_options: -Xmx4096M + - os: macos-latest + os_build_args: -x integTest -x jacocoTestReport + + # Job name + name: Build Notifications with JDK ${{ matrix.java }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + steps: + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v1 + with: + java-version: ${{ matrix.java }} + + # notifications + - name: Checkout Notifications + uses: actions/checkout@v2 + + # This is a hack, but this step creates a link to the X: mounted drive, which makes the path + # short enough to work on Windows + - name: Shorten Path + if: ${{ matrix.os == 'windows-latest' }} + run: subst 'X:' . + + - name: Build with Gradle + working-directory: ${{ env.WORKING_DIR }} + run: | + cd notifications + ./gradlew build ${{ env.BUILD_ARGS }} + env: + _JAVA_OPTIONS: ${{ matrix.os_java_options }} + + - name: Upload coverage + if: ${{ matrix.os == 'ubuntu-latest' }} + uses: codecov/codecov-action@v1 + with: + flags: opensearch-notifications + directory: notifications/ + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Create Artifact Path + run: | + mkdir -p notifications-build/{notifications,notifications-core} + cp -r ./notifications/notifications/build/distributions/*.zip notifications-build/notifications/ + cp -r ./notifications/core/build/distributions/*.zip notifications-build/notifications-core/ + shell: bash + + - name: Upload Artifacts for notifications plugin + uses: actions/upload-artifact@v1 + with: + name: notifications-plugin-${{ matrix.os }} + path: notifications-build/notifications + + - name: Upload Artifacts for notifications-core plugin + uses: actions/upload-artifact@v1 + with: + name: notifications-core-plugin-${{ matrix.os }} + path: notifications-build/notifications-core diff --git a/.github/workflows/security-notifications-test-workflow.yml b/.github/workflows/security-notifications-test-workflow.yml new file mode 100644 index 00000000..644fcd62 --- /dev/null +++ b/.github/workflows/security-notifications-test-workflow.yml @@ -0,0 +1,93 @@ +## + # Copyright OpenSearch Contributors + # SPDX-License-Identifier: Apache-2.0 +## + +name: Security Test and Build Notifications + +on: [push, pull_request] + +jobs: + build: + strategy: + # This setting says that all jobs should finish, even if one fails + fail-fast: false + matrix: + java: [11, 17] + + runs-on: ubuntu-latest + + steps: + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v1 + with: + java-version: ${{ matrix.java }} + + # notifications + - name: Checkout Notifications + uses: actions/checkout@v2 + + # Temporarily exclude tests which causing CI to fail. Tracking in #251 + - name: Build with Gradle + # Only assembling since the full build is governed by other workflows + run: | + cd notifications + ./gradlew assemble + + - name: Pull and Run Docker + run: | + plugin_core=`basename $(ls notifications/core/build/distributions/*.zip)` + plugin=`basename $(ls notifications/notifications/build/distributions/*.zip)` + list_of_files=`ls` + list_of_all_files=`ls notifications/core/build/distributions/` + version=`echo $plugin|awk -F- '{print $3}'| cut -d. -f 1-3` + plugin_version=`echo $plugin|awk -F- '{print $3}'| cut -d. -f 1-4` + qualifier=`echo $plugin|awk -F- '{print $4}'| cut -d. -f 1-1` + candidate_version=`echo $plugin|awk -F- '{print $5}'| cut -d. -f 1-1` + docker_version=$version + + [[ -z $candidate_version ]] && candidate_version=$qualifier && qualifier="" + + echo plugin version plugin_version qualifier candidate_version docker_version + echo "($plugin) ($version) ($plugin_version) ($qualifier) ($candidate_version) ($docker_version)" + echo $ls $list_of_all_files + + if docker pull opensearchstaging/opensearch:$docker_version + then + echo "FROM opensearchstaging/opensearch:$docker_version" >> Dockerfile + # Making the removal of the existing plugins in the docker image conditional in case this workflow is running before the new version of the plugins are published to the Docker image + echo "RUN if /usr/share/opensearch/bin/opensearch-plugin list | grep -q 'opensearch-notifications$'; then /usr/share/opensearch/bin/opensearch-plugin remove opensearch-notifications; fi" >> Dockerfile + echo "RUN if /usr/share/opensearch/bin/opensearch-plugin list | grep -q 'opensearch-notifications-core$'; then /usr/share/opensearch/bin/opensearch-plugin remove opensearch-notifications-core; fi" >> Dockerfile + echo "ADD notifications/core/build/distributions/$plugin_core /tmp/" >> Dockerfile + echo "RUN /usr/share/opensearch/bin/opensearch-plugin install --batch file:/tmp/$plugin_core" >> Dockerfile + echo "ADD notifications/notifications/build/distributions/$plugin /tmp/" >> Dockerfile + echo "RUN /usr/share/opensearch/bin/opensearch-plugin install --batch file:/tmp/$plugin" >> Dockerfile + docker build -t opensearch-notifications:test . + echo "imagePresent=true" >> $GITHUB_ENV + else + echo "imagePresent=false" >> $GITHUB_ENV + fi + + - name: Run Docker Image + if: env.imagePresent == 'true' + run: | + cd .. + docker run -p 9200:9200 -d -p 9600:9600 -e "discovery.type=single-node" opensearch-notifications:test + sleep 120 + + - name: Run Notification Test for security enabled test cases + if: env.imagePresent == 'true' + run: | + cluster_running=`curl -XGET https://localhost:9200/_cat/plugins -u admin:admin --insecure` + echo $cluster_running + security=`curl -XGET https://localhost:9200/_cat/plugins -u admin:admin --insecure |grep opensearch-security|wc -l` + echo $security + if [ $security -gt 0 ] + then + echo "Security plugin is available" + cd notifications + ./gradlew integTest -Dtests.rest.cluster=localhost:9200 -Dtests.cluster=localhost:9200 -Dtests.clustername=docker-cluster -Dhttps=true -Duser=admin -Dpassword=admin + else + echo "Security plugin is NOT available skipping this run as tests without security have already been run" + exit 1 + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d94ba8f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +npm-debug.log* +node_modules +/build/ +/public/app.css +.idea/ +yarn-error.log +/coverage/ +.DS_Store +/.cypress/screenshots/ +/.cypress/videos/ +target diff --git a/.lintstagedrc b/.lintstagedrc new file mode 100644 index 00000000..db600fbd --- /dev/null +++ b/.lintstagedrc @@ -0,0 +1,3 @@ +{ + "*.{ts,tsx,js,jsx,json,css,md}": ["prettier --write", "git add"] +} \ No newline at end of file diff --git a/.opensearch_dashboards-plugin-helpers.json b/.opensearch_dashboards-plugin-helpers.json new file mode 100644 index 00000000..5b079101 --- /dev/null +++ b/.opensearch_dashboards-plugin-helpers.json @@ -0,0 +1,10 @@ +{ + "serverSourcePatterns": [ + "package.json", + "tsconfig.json", + "yarn.lock", + ".yarnrc", + "{lib,public,server,webpackShims,translations,utils,models,test,common}/**/*", + "!__tests__" + ] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..7dc413d2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.vscode +build +coverage +node_modules +npm-debug.log +yarn.lock +*.md +*.lock \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..f443e3cf --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 80, + "bracketSpacing": true +} diff --git a/.whitesource b/.whitesource new file mode 100644 index 00000000..e34827bf --- /dev/null +++ b/.whitesource @@ -0,0 +1,22 @@ +{ + "scanSettings": { + "configMode": "AUTO", + "configExternalURL": "", + "projectToken": "", + "baseBranches": [] + }, + "checkRunSettings": { + "vulnerableCheckRunConclusionLevel": "failure", + "displayMode": "diff", + "useMendCheckNames": true + }, + "issueSettings": { + "minSeverityLevel": "LOW", + "issueType": "DEPENDENCY" + }, + "remediateSettings": { + "workflowRules": { + "enabled": true + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 847260ca..60476eae 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,65 @@ -## My Project +# OpenSearch Dashboards Notifications -TODO: Fill this README out! +Dashboards Notifications plugin provides an interface that helps users to manage and view notifications using the OpenSearch Notifications plugin. -Be sure to: +## Documentation -* Change the title in this README -* Edit your repository description on GitHub +Please see our technical [documentation](https://opensearch.org/docs/) to learn more about its features. -## Security +## Setup -See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. +1. Download OpenSearch for the version that matches the [OpenSearch Dashboards version specified in package.json](./package.json#L7). +1. Download the OpenSearch Dashboards source code for the [version specified in package.json](./package.json#L7) you want to set up. + +1. Change your node version to the version specified in `.node-version` inside the OpenSearch Dashboards root directory. +1. Create a `plugins` directory inside the OpenSearch Dashboards source code directory, if `plugins` directory doesn't exist. +1. Check out this package from version control into the `plugins` directory. + ``` + git clone git@github.com:opensearch-project/notifications.git plugins --no-checkout + cd plugins + echo 'dashboards-notifications/*' >> .git/info/sparse-checkout + git config core.sparseCheckout true + git checkout dev + ``` +1. Run `yarn osd bootstrap` inside `OpenSearch-Dashboards/plugins/dashboards-notifications`. + +Ultimately, your directory structure should look like this: + +```md +. +├── OpenSearch Dashboards +│ └── plugins +│ └── dashboards-notifications +``` + +## Build + +To build the plugin's distributable zip simply run `yarn build`. + +Example output: `./build/notificationsDashboards*.zip` + +## Run + +- `yarn start` + + Starts OpenSearch Dashboards and includes this plugin. OpenSearch Dashboards will be available on `localhost:5601`. + +- `yarn test` + + Runs the plugin unit tests. + +## Contributing to OpenSearch Dashboards Notifications + +We welcome you to get involved in development, documentation, testing the Notifications plugin. See our [CONTRIBUTING.md](./../CONTRIBUTING.md) and join in. + +## Bugs, Enhancements or Questions + +Please file an issue to report any bugs you may find, enhancements you may need or questions you may have [here](https://github.com/opensearch-project/notifications/issues). ## License -This project is licensed under the Apache-2.0 License. +This code is licensed under the Apache 2.0 License. + +## Copyright +Copyright OpenSearch Contributors. See [NOTICE](../NOTICE.txt) for details. diff --git a/common/index.ts b/common/index.ts new file mode 100644 index 00000000..0b77a5f3 --- /dev/null +++ b/common/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'notificationsDashboards'; +export const PLUGIN_NAME = 'notifications-dashboards'; + +// after delete operation returns, a delay is needed before the change reflects in another request +export const SERVER_DELAY = 1000; + +const NODE_API_BASE_PATH = '/api/notifications'; +export const NODE_API = Object.freeze({ + GET_CONFIGS: `${NODE_API_BASE_PATH}/get_configs`, + GET_CONFIG: `${NODE_API_BASE_PATH}/get_config`, + CREATE_CONFIG: `${NODE_API_BASE_PATH}/create_config`, + DELETE_CONFIGS: `${NODE_API_BASE_PATH}/delete_configs`, + UPDATE_CONFIG: `${NODE_API_BASE_PATH}/update_config`, + GET_EVENT: `${NODE_API_BASE_PATH}/get_event`, + GET_AVAILABLE_FEATURES: `${NODE_API_BASE_PATH}/features`, + SEND_TEST_MESSAGE: `${NODE_API_BASE_PATH}/test_message`, +}); + +const OPENSEARCH_API_BASE_PATH = '/_plugins/_notifications'; +export const OPENSEARCH_API = Object.freeze({ + CONFIGS: `${OPENSEARCH_API_BASE_PATH}/configs`, + EVENTS: `${OPENSEARCH_API_BASE_PATH}/events`, + TEST_MESSAGE: `${OPENSEARCH_API_BASE_PATH}/feature/test`, + FEATURES: `${OPENSEARCH_API_BASE_PATH}/features`, +}); + +export const REQUEST = Object.freeze({ + PUT: 'PUT', + DELETE: 'DELETE', + GET: 'GET', + POST: 'POST', + HEAD: 'HEAD', +}); diff --git a/cypress.json b/cypress.json new file mode 100644 index 00000000..4585c272 --- /dev/null +++ b/cypress.json @@ -0,0 +1,23 @@ +{ + "video": true, + "fixturesFolder": ".cypress/fixtures", + "integrationFolder": ".cypress/integration", + "pluginsFile": ".cypress/plugins/index.js", + "screenshotsFolder": ".cypress/screenshots", + "supportFile": ".cypress/support/index.js", + "videosFolder": ".cypress/videos", + "viewportWidth": 1000, + "viewportHeight": 1600, + "requestTimeout": 60000, + "responseTimeout": 60000, + "defaultCommandTimeout": 60000, + "env": { + "opensearch": "localhost:9200", + "opensearchDashboards": "localhost:5601", + "security_enabled": false + }, + "testFiles": [ + "email_senders_and_groups.spec.js", + "channels.spec.js" + ] +} diff --git a/models/interfaces.ts b/models/interfaces.ts new file mode 100644 index 00000000..780c7133 --- /dev/null +++ b/models/interfaces.ts @@ -0,0 +1,104 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Direction } from '@elastic/eui'; +import { WebhookMethodType } from '../public/pages/Channels/types'; +import { + CHANNEL_TYPE, + ENCRYPTION_TYPE, +} from '../public/utils/constants'; + +export interface ChannelStatus { + config_id: string; + config_name: string; + config_type: keyof typeof CHANNEL_TYPE; + email_recipient_status?: { + recipient: string; + delivery_status: DeliveryStatus; + }[]; + delivery_status: DeliveryStatus; +} + +interface DeliveryStatus { + status_code: string; + status_text: string; +} + +export type SenderType = 'smtp_account' | 'ses_account'; + +export interface ChannelItemType extends ConfigType { + config_type: keyof typeof CHANNEL_TYPE; + is_enabled: boolean; // active or muted + slack?: { + url: string; + }; + chime?: { + url: string; + }; + webhook?: { + url: string; + header_params: object; + method: WebhookMethodType; + }; + email?: { + email_account_id: string; + recipient_list: { [recipient: string]: string }[]; // custom email addresses + email_group_id_list: string[]; + // optional fields for displaying or editing email channel, needs more requests + sender_type?: SenderType; + email_account_name?: string; + email_group_id_map?: { + [id: string]: string; + }; + invalid_ids?: string[]; // invalid sender and/or recipient group ids, possible deleted + }; + sns?: { + topic_arn: string; + role_arn?: string; + } +} + +interface ConfigType { + config_id: string; + name: string; + description?: string; + created_time_ms: number; + last_updated_time_ms: number; +} + +export interface SenderItemType extends ConfigType { + smtp_account: { + from_address: string; // outbound email address + host: string; + port: string; + method: keyof typeof ENCRYPTION_TYPE; + }; +} + +export interface SESSenderItemType extends ConfigType { + ses_account: { + from_address: string; // outbound email address + region: string; + role_arn?: string; + }; +} + +export interface RecipientGroupItemType extends ConfigType { + email_group: { + recipient_list: { [recipient: string]: string }[]; + }; +} + +export interface TableState { + total: number; + from: number; + size: number; + search: string; + sortField: any; // keyof T + sortDirection: Direction; + selectedItems: T[]; + items: T[]; + loading: boolean; +} diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json new file mode 100644 index 00000000..0779a000 --- /dev/null +++ b/opensearch_dashboards.json @@ -0,0 +1,9 @@ +{ + "id": "notificationsDashboards", + "version": "2.4.0.0", + "opensearchDashboardsVersion": "2.4.0", + "requiredPlugins": ["navigation", "data"], + "optionalPlugins": ["share"], + "server": true, + "ui": true +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..f0108fdd --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "notifications-dashboards", + "version": "2.4.0.0", + "description": "OpenSearch Dashboards Notifications Plugin", + "license": "Apache-2.0", + "main": "index.ts", + "config": { + "id": "notificationsDashboards", + "zip_name": "notifications-dashboards" + }, + "scripts": { + "osd": "node ../../scripts/osd", + "opensearch": "node ../../scripts/opensearch", + "lint": "eslint .", + "start": "yarn plugin_helpers start", + "build": "yarn plugin_helpers build", + "test:jest": "TZ=UTC ../../node_modules/.bin/jest --config ./test/jest.config.js", + "cypress:run": "cypress run", + "cypress:open": "cypress open", + "plugin_helpers": "node ../../scripts/plugin_helpers", + "postbuild": "echo Renaming build artifact to [$npm_package_config_zip_name-$npm_package_version.zip] && mv build/$npm_package_config_id*.zip build/$npm_package_config_zip_name-$npm_package_version.zip" + }, + "dependencies": { + "cypress": "^6.0.0" + }, + "devDependencies": { + "@types/enzyme-adapter-react-16": "^1.0.6", + "@types/showdown": "^1.9.3", + "enzyme-adapter-react-16": "^1.15.5", + "jest": "^27.5.1", + "jest-dom": "^4.0.0" + }, + "resolutions": { + "async": "^3.2.3", + "minimist": "^1.2.6" + } +} diff --git a/public/application.tsx b/public/application.tsx new file mode 100644 index 00000000..7ce2fc75 --- /dev/null +++ b/public/application.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { HashRouter as Router, Route } from 'react-router-dom'; +import { AppMountParameters, CoreStart } from '../../../src/core/public'; +import { CoreServicesContext } from './components/coreServices'; +import Main from './pages/Main'; +import { NotificationService } from './services'; +import { ServicesContext } from './services/services'; + +export const renderApp = (coreStart: CoreStart, params: AppMountParameters) => { + const http = coreStart.http; + const notificationService = new NotificationService(http); + const services = { notificationService }; + + ReactDOM.render( + + ( + + +
+ + + )} + /> + , + params.element + ); + + return () => ReactDOM.unmountComponentAtNode(params.element); +}; diff --git a/public/components/ContentPanel/ContentPanel.tsx b/public/components/ContentPanel/ContentPanel.tsx new file mode 100644 index 00000000..a6660e2b --- /dev/null +++ b/public/components/ContentPanel/ContentPanel.tsx @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import React from 'react'; + +interface ContentPanelProps { + title?: string; + titleSize?: 'xxxs' | 'xxs' | 'xs' | 's' | 'm' | 'l'; + total?: number; + bodyStyles?: object; + panelStyles?: object; + horizontalRuleClassName?: string; + actions?: React.ReactNode | React.ReactNode[]; + children: React.ReactNode | React.ReactNode[]; +} + +const ContentPanel: React.SFC = ({ + title = '', + titleSize = 'l', + total = undefined, + bodyStyles = {}, + panelStyles = {}, + horizontalRuleClassName = '', + actions, + children, +}) => ( + + + + +

+ {title} + {total !== undefined ? ( + {` (${total})`} + ) : null} +

+
+
+ {actions ? ( + + + {Array.isArray(actions) ? ( + (actions as React.ReactNode[]).map( + (action: React.ReactNode, idx: number): React.ReactNode => ( + {action} + ) + ) + ) : ( + {actions} + )} + + + ) : null} +
+ + + +
{children}
+
+); + +export default ContentPanel; diff --git a/public/components/ContentPanel/ContentPanelActions.tsx b/public/components/ContentPanel/ContentPanelActions.tsx new file mode 100644 index 00000000..ca1aa29d --- /dev/null +++ b/public/components/ContentPanel/ContentPanelActions.tsx @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +interface ContentPanelActionsProps { + actions: { + component: React.ReactNode; + flexItemProps?: object; + }[]; +} + +const ContentPanelActions: React.SFC = ({ + actions, +}) => ( + + {actions.map(({ component, flexItemProps = {} }, index) => { + return ( + + {component} + + ); + })} + +); + +export default ContentPanelActions; diff --git a/public/components/ContentPanel/index.ts b/public/components/ContentPanel/index.ts new file mode 100644 index 00000000..e8b30113 --- /dev/null +++ b/public/components/ContentPanel/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import ContentPanel from './ContentPanel'; +import ContentPanelActions from './ContentPanelActions'; + +export { ContentPanel, ContentPanelActions }; diff --git a/public/components/Modal/Modal.tsx b/public/components/Modal/Modal.tsx new file mode 100644 index 00000000..5f906291 --- /dev/null +++ b/public/components/Modal/Modal.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component, createContext } from 'react'; + +const ModalContext = createContext({ + component: null, + props: {}, + onShow: (component: any, props: object) => {}, + onClose: () => {}, +}); + +const ModalConsumer = ModalContext.Consumer; + +class ModalProvider extends Component { + state = { component: null, props: {} }; + + onShow = (component: any, props: object): void => { + this.setState({ + component, + props, + }); + }; + + onClose = (): void => { + this.setState({ + component: null, + props: {}, + }); + }; + + render() { + return ( + + {this.props.children} + + ); + } +} + +export { ModalConsumer, ModalProvider }; diff --git a/public/components/Modal/ModalRoot.tsx b/public/components/Modal/ModalRoot.tsx new file mode 100644 index 00000000..e39040a3 --- /dev/null +++ b/public/components/Modal/ModalRoot.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { ComponentType } from 'react'; +import { BrowserServices } from '../../models/interfaces'; +import { ModalConsumer } from './Modal'; + +export interface ModalRootProps { + services: BrowserServices; +} + +// All modals will have access to the BrowserServices if they need it +const ModalRoot: React.FunctionComponent = ({ services }) => ( + + {({ + component: Komponent, + props, + onClose, + }: { + component: ComponentType<{ + onClose: () => void; + services: BrowserServices; + }> | null; + props: object; + onClose: () => void; + }) => + Komponent ? ( + + ) : null + } + +); + +export default ModalRoot; diff --git a/public/components/Modal/index.ts b/public/components/Modal/index.ts new file mode 100644 index 00000000..2e7b7d53 --- /dev/null +++ b/public/components/Modal/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ModalConsumer, ModalProvider } from './Modal'; +import ModalRoot from './ModalRoot'; + +export { ModalConsumer, ModalProvider, ModalRoot }; diff --git a/public/components/__tests__/ContentPanel.test.tsx b/public/components/__tests__/ContentPanel.test.tsx new file mode 100644 index 00000000..f1c35e23 --- /dev/null +++ b/public/components/__tests__/ContentPanel.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import ContentPanel from '../ContentPanel/ContentPanel'; + +describe(' spec', () => { + it('renders the component', () => { + const { container } = render( + one}> +
Testing ContentPanel
+
+ ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders actions', () => { + const { getByText } = render( + one,
two
]}> +
Testing ContentPanel
+
+ ); + getByText('one'); + getByText('two'); + }); + + it('renders with empty actions', () => { + const { container } = render( + +
Testing ContentPanel
+
+ ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/public/components/__tests__/ContentPanelActions.test.tsx b/public/components/__tests__/ContentPanelActions.test.tsx new file mode 100644 index 00000000..58a55e9c --- /dev/null +++ b/public/components/__tests__/ContentPanelActions.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import ContentPanelActions from '../ContentPanel/ContentPanelActions'; + +describe(' spec', () => { + it('renders the component', () => { + const actions = [{ component: 'ContentPanelActions' }]; + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders a button to click', () => { + const spy = jest.fn(); + const actions = [ + { + component: ( + + ), + }, + ]; + const { getByTestId } = render(); + fireEvent.click(getByTestId('ContentPanelActionsButton')); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/public/components/__tests__/Modal.test.tsx b/public/components/__tests__/Modal.test.tsx new file mode 100644 index 00000000..9b74e2c4 --- /dev/null +++ b/public/components/__tests__/Modal.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiButton, EuiOverlayMask, EuiModal } from '@elastic/eui'; +import { render, fireEvent } from '@testing-library/react'; +import ModalRoot from '../Modal/ModalRoot'; +import { ModalConsumer, ModalProvider } from '../Modal/Modal'; +import { ServicesConsumer, ServicesContext } from '../../services/services'; +import { notificationServiceMock } from '../../../test/mocks/serviceMock'; + +describe(' spec', () => { + it('renders nothing when not used', () => { + const { container } = render( + + + + {(services) => services && } + + + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders a modal that can close and open', () => { + const Modal = ({ onClose, text }: { onClose: () => {}; text: string }) => ( + + A modal that has {text} + + ); + const { queryByText, getByTestId, getByLabelText } = render( +
+ + + + {(services) => services && } + + + {({ onShow }) => ( + onShow(Modal, { text: 'interesting text' })} + > + Show Modal + + )} + + + +
+ ); + + expect(queryByText('A modal that has interesting text')).toBeNull(); + + fireEvent.click(getByTestId('showModal')); + + expect(queryByText('A modal that has interesting text')).not.toBeNull(); + + fireEvent.click(getByLabelText('Closes this modal window')); + + expect(queryByText('A modal that has interesting text')).toBeNull(); + }); +}); diff --git a/public/components/__tests__/__snapshots__/ContentPanel.test.tsx.snap b/public/components/__tests__/__snapshots__/ContentPanel.test.tsx.snap new file mode 100644 index 00000000..4a277201 --- /dev/null +++ b/public/components/__tests__/__snapshots__/ContentPanel.test.tsx.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+

+ Testing +

+
+
+
+
+
+ one +
+
+
+
+
+
+
+
+ Testing ContentPanel +
+
+
+`; + +exports[` spec renders with empty actions 1`] = ` +
+
+
+

+ Testing +

+
+
+
+
+
+ Testing ContentPanel +
+
+
+`; diff --git a/public/components/__tests__/__snapshots__/ContentPanelActions.test.tsx.snap b/public/components/__tests__/__snapshots__/ContentPanelActions.test.tsx.snap new file mode 100644 index 00000000..00026188 --- /dev/null +++ b/public/components/__tests__/__snapshots__/ContentPanelActions.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+ ContentPanelActions +
+
+`; diff --git a/public/components/coreServices.tsx b/public/components/coreServices.tsx new file mode 100644 index 00000000..de1d49c6 --- /dev/null +++ b/public/components/coreServices.tsx @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createContext } from 'react'; +import { CoreStart } from '../../../../src/core/public'; + +const CoreServicesContext = createContext(null); + +const CoreServicesConsumer = CoreServicesContext.Consumer; + +export { CoreServicesContext, CoreServicesConsumer }; diff --git a/public/index.scss b/public/index.scss new file mode 100644 index 00000000..e69de29b diff --git a/public/index.ts b/public/index.ts new file mode 100644 index 00000000..36345985 --- /dev/null +++ b/public/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './index.scss'; + +import { notificationsDashboardsPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. +export function plugin() { + return new notificationsDashboardsPlugin(); +} +export { + notificationsDashboardsPluginSetup, + notificationsDashboardsPluginStart, +} from './types'; diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts new file mode 100644 index 00000000..ae48e39a --- /dev/null +++ b/public/models/interfaces.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NotificationService } from '../services'; + +export interface BrowserServices { + notificationService: NotificationService; +} diff --git a/public/pages/Channels/Channels.tsx b/public/pages/Channels/Channels.tsx new file mode 100644 index 00000000..1e3ca6bd --- /dev/null +++ b/public/pages/Channels/Channels.tsx @@ -0,0 +1,273 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBasicTable, + EuiButton, + EuiEmptyPrompt, + EuiHealth, + EuiHorizontalRule, + EuiLink, + EuiTableFieldDataColumnType, + EuiTableSortingType, + SortDirection, +} from '@elastic/eui'; +import { Criteria } from '@elastic/eui/src/components/basic_table/basic_table'; +import { Pagination } from '@elastic/eui/src/components/basic_table/pagination_bar'; +import _ from 'lodash'; +import React, { Component } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { ChannelItemType, TableState } from '../../../models/interfaces'; +import { + ContentPanel, + ContentPanelActions, +} from '../../components/ContentPanel'; +import { CoreServicesContext } from '../../components/coreServices'; +import { NotificationService } from '../../services'; +import { + BREADCRUMBS, + CHANNEL_TYPE, + ROUTES, +} from '../../utils/constants'; +import { getErrorMessage } from '../../utils/helpers'; +import { DEFAULT_PAGE_SIZE_OPTIONS } from '../Notifications/utils/constants'; +import { ChannelActions } from './components/ChannelActions'; +import { ChannelControls } from './components/ChannelControls'; +import { ChannelFiltersType } from './types'; + +interface ChannelsProps extends RouteComponentProps { + notificationService: NotificationService; +} + +interface ChannelsState extends TableState { + filters: ChannelFiltersType; +} + +export class Channels extends Component { + static contextType = CoreServicesContext; + columns: EuiTableFieldDataColumnType[]; + + constructor(props: ChannelsProps) { + super(props); + + this.state = { + total: 0, + from: 0, + size: 10, + search: '', + filters: {}, + sortField: 'name', + sortDirection: SortDirection.ASC, + items: [], + selectedItems: [], + loading: true, + }; + + this.columns = [ + { + field: 'name', + name: 'Name', + sortable: true, + truncateText: true, + render: (name: string, item: ChannelItemType) => ( + + {name} + + ), + }, + { + field: 'is_enabled', + name: 'Notification status', + sortable: true, + render: (enabled: boolean) => { + const color = enabled ? 'success' : 'subdued'; + const label = enabled ? 'Active' : 'Muted'; + return {label}; + }, + }, + { + field: 'config_type', + name: 'Type', + sortable: true, + truncateText: false, + render: (type: string) => _.get(CHANNEL_TYPE, type, '-'), + }, + { + field: 'description', + name: 'Description', + sortable: true, + truncateText: true, + render: (description: string) => description || '-', + }, + ]; + + this.refresh = this.refresh.bind(this); + } + + async componentDidMount() { + this.context.chrome.setBreadcrumbs([ + BREADCRUMBS.NOTIFICATIONS, + BREADCRUMBS.CHANNELS, + ]); + window.scrollTo(0, 0); + await this.refresh(); + } + + async componentDidUpdate(prevProps: ChannelsProps, prevState: ChannelsState) { + const prevQuery = Channels.getQueryObjectFromState(prevState); + const currQuery = Channels.getQueryObjectFromState(this.state); + if (!_.isEqual(prevQuery, currQuery)) { + await this.refresh(); + } + } + + static getQueryObjectFromState(state: ChannelsState) { + const config_type = _.isEmpty(state.filters.type) + ? Object.keys(CHANNEL_TYPE) // by default get all channels but not email senders/groups + : state.filters.type; + const queryObject: any = { + from_index: state.from, + max_items: state.size, + query: state.search, + config_type, + sort_field: state.sortField, + sort_order: state.sortDirection, + }; + if (state.filters.state != undefined) + queryObject.is_enabled = state.filters.state; + return queryObject; + } + + async refresh() { + this.setState({ loading: true }); + try { + const queryObject = Channels.getQueryObjectFromState(this.state); + const channels = await this.props.notificationService.getChannels( + queryObject + ); + this.setState({ items: channels.items, total: channels.total }); + } catch (error) { + this.context.notifications.toasts.addDanger( + getErrorMessage(error, 'There was a problem loading channels.') + ); + } + this.setState({ loading: false }); + } + + onTableChange = ({ + page: tablePage, + sort, + }: Criteria): void => { + const { index: page, size } = tablePage!; + const { field: sortField, direction: sortDirection } = sort!; + this.setState({ from: page * size, size, sortField, sortDirection }); + }; + + onSelectionChange = (selectedItems: ChannelItemType[]): void => { + this.setState({ selectedItems }); + }; + + onSearchChange = (search: string): void => { + this.setState({ from: 0, search }); + }; + + onFiltersChange = (filters: ChannelFiltersType): void => { + this.setState({ from: 0, filters }); + }; + + render() { + const filterIsApplied = !!this.state.search; + const page = Math.floor(this.state.from / this.state.size); + + const pagination: Pagination = { + pageIndex: page, + pageSize: this.state.size, + pageSizeOptions: DEFAULT_PAGE_SIZE_OPTIONS, + totalItemCount: this.state.total, + }; + + const sorting: EuiTableSortingType = { + sort: { + direction: this.state.sortDirection, + field: this.state.sortField, + }, + }; + + const selection = { + selectable: () => true, + onSelectionChange: this.onSelectionChange, + }; + + return ( + <> + + this.setState({ selectedItems }) + } + items={this.state.items} + setItems={(items: ChannelItemType[]) => + this.setState({ items }) + } + refresh={this.refresh} + /> + ), + }, + { + component: ( + + Create channel + + ), + }, + ]} + /> + } + bodyStyles={{ padding: 'initial' }} + title="Channels" + titleSize="m" + total={this.state.total} + > + + + + No channels to display} + body="To send or receive notifications, you will need to create a notification channel." + actions={ + + Create channel + + } + /> + } + onChange={this.onTableChange} + pagination={pagination} + sorting={sorting} + tableLayout="auto" + loading={this.state.loading} + /> + + + ); + } +} diff --git a/public/pages/Channels/__tests__/ChannelActions.test.tsx b/public/pages/Channels/__tests__/ChannelActions.test.tsx new file mode 100644 index 00000000..a39acd41 --- /dev/null +++ b/public/pages/Channels/__tests__/ChannelActions.test.tsx @@ -0,0 +1,132 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { + coreServicesMock, + notificationServiceMock, +} from '../../../../test/mocks/serviceMock'; +import { CoreServicesContext } from '../../../components/coreServices'; +import { ServicesContext } from '../../../services'; +import { ChannelActions } from '../components/ChannelActions'; + +describe(' spec', () => { + it('renders the action button disabled by default', () => { + const channels = [jest.fn() as any]; + const utils = render( + + + {}} + items={[]} + setItems={() => {}} + refresh={() => {}} + /> + + + ); + expect(utils.container.firstChild).toMatchSnapshot(); + + const button = utils.getByText('Actions'); + expect(button).toBeDisabled; + }); + + it('renders the popover', () => { + const channels = [jest.fn() as any]; + const utils = render( + + + {}} + items={[]} + setItems={() => {}} + refresh={() => {}} + /> + + + ); + const button = utils.getByText('Actions'); + fireEvent.click(button); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('renders the popover with multiple selected channels', () => { + const channels = [jest.fn() as any, jest.fn() as any]; + const utils = render( + + + {}} + items={[]} + setItems={() => {}} + refresh={() => {}} + /> + + + ); + const button = utils.getByText('Actions'); + fireEvent.click(button); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('clicks in the popover', () => { + const channel = jest.fn() as any; + channel.is_enabled = false; + const channels = [channel]; + const utils = render( + + + {}} + items={[]} + setItems={() => {}} + refresh={() => {}} + /> + + + ); + const button = utils.getByText('Actions'); + fireEvent.click(button); + + const muteButton = utils.getByText('Mute'); + fireEvent.click(muteButton); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('clicks unmute', () => { + const notificationServiceMock = jest.fn() as any; + const updateConfig = jest.fn(async () => Promise.resolve()); + notificationServiceMock.notificationService = { + updateConfig, + }; + const channel = jest.fn() as any; + channel.enabled = true; + const channels = [channel]; + const utils = render( + + + {}} + items={[]} + setItems={() => {}} + refresh={() => {}} + /> + + + ); + const button = utils.getByText('Actions'); + fireEvent.click(button); + + const muteButton = utils.getByText('Unmute'); + fireEvent.click(muteButton); + expect(updateConfig).toBeCalled(); + }); +}); diff --git a/public/pages/Channels/__tests__/ChannelControls.test.tsx b/public/pages/Channels/__tests__/ChannelControls.test.tsx new file mode 100644 index 00000000..7d5038a7 --- /dev/null +++ b/public/pages/Channels/__tests__/ChannelControls.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { mainStateMock } from '../../../../test/mocks/serviceMock'; +import { MainContext } from '../../Main/Main'; +import { ChannelControls } from '../components/ChannelControls'; + +describe(' spec', () => { + it('renders the component', () => { + const onSearchChange = jest.fn(); + const onFiltersChange = jest.fn(); + const { container } = render( + + + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('searches with input query', () => { + const onSearchChange = jest.fn(); + const onFiltersChange = jest.fn(); + const utils = render( + + + + ); + const input = utils.getByPlaceholderText('Search'); + + fireEvent.change(input, { target: { value: 'test' } }); + expect(onSearchChange).toBeCalledWith('test'); + }); + + it('changes filters', () => { + const onSearchChange = jest.fn(); + const onFiltersChange = jest.fn(); + const utils = render( + + + + ); + fireEvent.click(utils.getByText('Status')); + fireEvent.click(utils.getByText('Active')); + expect(onFiltersChange).toBeCalledWith({ state: 'true' }); + + fireEvent.click(utils.getByText('Type')); + fireEvent.click(utils.getByText('Email')); + fireEvent.click(utils.getByText('Chime')); + expect(onFiltersChange).toBeCalledWith({ type: ['email', 'chime'] }); + + expect(onFiltersChange).toBeCalledTimes(3); + }); +}); diff --git a/public/pages/Channels/__tests__/ChannelDetails.test.tsx b/public/pages/Channels/__tests__/ChannelDetails.test.tsx new file mode 100644 index 00000000..5ebcacb1 --- /dev/null +++ b/public/pages/Channels/__tests__/ChannelDetails.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { act } from 'react-dom/test-utils'; +import { RouteComponentProps } from 'react-router-dom'; +import { MOCK_DATA } from '../../../../test/mocks/mockData'; +import { + coreServicesMock, + notificationServiceMock, +} from '../../../../test/mocks/serviceMock'; +import { CoreServicesContext } from '../../../components/coreServices'; +import { ServicesContext } from '../../../services'; +import { ChannelDetails } from '../components/details/ChannelDetails'; + +describe(' spec', () => { + configure({ adapter: new Adapter() }); + + it('renders the component', () => { + const props = { match: { params: { id: 'test' } } }; + const utils = render( + + + )} /> + + + ); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('renders a specific channel', async () => { + const props = { match: { params: { id: 'test' } } }; + const notificationServiceMock = jest.fn() as any; + notificationServiceMock.notificationService = { + getChannel: async (id: string) => { + return MOCK_DATA.chime; + }, + }; + let container = document.createElement('div'); + + act(() => { + ReactDOM.render( + + + )} + /> + + , + container + ); + }); + await waitFor(() => { + expect(container).toMatchSnapshot(); + }); + }); + + it('handles a non-existing channel', async () => { + const props = { match: { params: { id: 'test' } } }; + const notificationServiceMock = jest.fn() as any; + notificationServiceMock.notificationService = { + getChannel: async (id: string) => { + throw "non existing channel" + }, + }; + let container = document.createElement('div'); + + act(() => { + ReactDOM.render( + + + )} + /> + + , + container + ); + }); + await waitFor(() => { + expect(container).toMatchSnapshot(); + }); + }); + + it('clicks mute button with channel', async () => { + const props = { match: { params: { id: 'test' } } }; + const notificationServiceMock = jest.fn() as any; + notificationServiceMock.notificationService = { + getChannel: async (id: string) => { + return MOCK_DATA.chime; + }, + updateConfig: jest.fn(), + }; + + const utils = render( + + + )} /> + + + ); + + await waitFor(() => { + utils.getByTestId('channel-details-mute-button').click(); + }); + }); + + it('clicks unmute button with channel', async () => { + const props = { match: { params: { id: 'test' } } }; + const notificationServiceMock = jest.fn() as any; + const updateConfig = jest.fn(async () => Promise.resolve()); + notificationServiceMock.notificationService = { + getChannel: async (id: string) => { + return MOCK_DATA.slack; + }, + updateConfig, + }; + + const utils = render( + + + )} /> + + + ); + + await waitFor(() => { + utils.getByTestId('channel-details-mute-button').click(); + }); + }); +}); diff --git a/public/pages/Channels/__tests__/ChannelDetailsActions.test.tsx b/public/pages/Channels/__tests__/ChannelDetailsActions.test.tsx new file mode 100644 index 00000000..f1224188 --- /dev/null +++ b/public/pages/Channels/__tests__/ChannelDetailsActions.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '@testing-library/react'; +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { MOCK_DATA } from '../../../../test/mocks/mockData'; +import { + coreServicesMock, + notificationServiceMock, +} from '../../../../test/mocks/serviceMock'; +import { CoreServicesContext } from '../../../components/coreServices'; +import { ServicesContext } from '../../../services'; +import { ChannelDetailsActions } from '../components/details/ChannelDetailsActions'; + +describe(' spec', () => { + configure({ adapter: new Adapter() }); + + it('renders the component', () => { + const channel = MOCK_DATA.chime; + const utils = render( + + + + + + ); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('opens popover', () => { + const channel = MOCK_DATA.chime; + const utils = render( + + + + + + ); + utils.getByText('Actions').click(); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('clicks buttons in popover', () => { + const channel = MOCK_DATA.chime; + const utils = render( + + + + + + ); + utils.getByText('Actions').click(); + utils.getByText('Edit').click(); + utils.getByText('Actions').click(); + utils.getByText('Send test message').click(); + utils.getByText('Actions').click(); + utils.getByText('Delete').click(); + expect(utils.container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/public/pages/Channels/__tests__/ChannelSettingsDetails.test.tsx b/public/pages/Channels/__tests__/ChannelSettingsDetails.test.tsx new file mode 100644 index 00000000..6d0abda9 --- /dev/null +++ b/public/pages/Channels/__tests__/ChannelSettingsDetails.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '@testing-library/react'; +import { configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { MOCK_DATA } from '../../../../test/mocks/mockData'; +import { ChannelSettingsDetails } from '../components/details/ChannelSettingsDetails'; + +describe(' spec', () => { + configure({ adapter: new Adapter() }); + + it('renders the empty component', () => { + const utils = render(); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('renders Chime channel', () => { + const utils = render( + + ); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('renders Slack channel', () => { + const utils = render( + + ); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('renders Email channel', () => { + const utils = render( + + ); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('renders Webhook channel', () => { + const utils = render( + + ); + expect(utils.container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/public/pages/Channels/__tests__/Channels.test.tsx b/public/pages/Channels/__tests__/Channels.test.tsx new file mode 100644 index 00000000..b4ef044e --- /dev/null +++ b/public/pages/Channels/__tests__/Channels.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { MOCK_DATA } from '../../../../test/mocks/mockData'; +import { routerComponentPropsMock } from '../../../../test/mocks/routerPropsMock'; +import { + coreServicesMock, + mainStateMock, +} from '../../../../test/mocks/serviceMock'; +import { CoreServicesContext } from '../../../components/coreServices'; +import { MainContext } from '../../Main/Main'; +import { Channels } from '../Channels'; + +describe(' spec', () => { + it('renders the empty component', () => { + const notificationServiceMock = jest.fn() as any; + const getChannels = jest.fn(async (queryObject: object) => []); + notificationServiceMock.notificationService = { getChannels }; + const utils = render( + + + + + + ); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('renders the component', async () => { + const getChannels = jest.fn( + async (queryObject: object) => MOCK_DATA.channels + ); + const notificationService = jest.fn() as any; + notificationService.getChannels = getChannels; + const utils = render( + + + + + + ); + + await waitFor(() => expect(getChannels).toBeCalled()); + + const input = utils.getByPlaceholderText('Search'); + fireEvent.change(input, { target: { value: 'test-query' } }); + + await waitFor(() => + expect(getChannels).toBeCalledWith( + expect.objectContaining({ query: 'test-query' }) + ) + ); + }); +}); diff --git a/public/pages/Channels/__tests__/DeleteChannelModal.test.tsx b/public/pages/Channels/__tests__/DeleteChannelModal.test.tsx new file mode 100644 index 00000000..63aac918 --- /dev/null +++ b/public/pages/Channels/__tests__/DeleteChannelModal.test.tsx @@ -0,0 +1,96 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { + coreServicesMock, + notificationServiceMock, +} from '../../../../test/mocks/serviceMock'; +import { CoreServicesContext } from '../../../components/coreServices'; +import { DeleteChannelModal } from '../components/modals/DeleteChannelModal'; + +describe(' spec', () => { + it('returns if no channels', () => { + const { container } = render( + {}} + services={notificationServiceMock} + /> + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders the component', () => { + const channels = [jest.fn() as any]; + const { container } = render( + {}} + services={notificationServiceMock} + /> + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders multiple channels', () => { + const channels = [jest.fn() as any, jest.fn() as any]; + const { container } = render( + {}} + services={notificationServiceMock} + /> + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('deletes channels', () => { + const channels = [jest.fn() as any, jest.fn() as any]; + const onClose = jest.fn(); + const notificationServiceMock = jest.fn() as any; + notificationServiceMock.notificationService = { + deleteConfigs: async (ids: string[]) => Promise.resolve(), + }; + const utils = render( + + + + ); + const input = utils.getByPlaceholderText('delete'); + fireEvent.change(input, { target: { value: 'delete' } }); + const deleteButton = utils.getByText('Delete'); + fireEvent.click(deleteButton); + expect(utils.container.firstChild).toMatchSnapshot(); + }); + + it('handles failures when deleting channels', () => { + const channels = [jest.fn() as any, jest.fn() as any]; + const onClose = jest.fn(); + const notificationServiceMock = jest.fn() as any; + notificationServiceMock.notificationService = { + deleteConfigs: async (ids: string[]) => Promise.reject(), + }; + const utils = render( + + + + ); + const input = utils.getByPlaceholderText('delete'); + fireEvent.change(input, { target: { value: 'delete' } }); + const deleteButton = utils.getByText('Delete'); + fireEvent.click(deleteButton); + expect(utils.container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/public/pages/Channels/__tests__/DetailsListModal.test.tsx b/public/pages/Channels/__tests__/DetailsListModal.test.tsx new file mode 100644 index 00000000..0e97ffb5 --- /dev/null +++ b/public/pages/Channels/__tests__/DetailsListModal.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '@testing-library/react'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { notificationServiceMock } from '../../../../test/mocks/serviceMock'; +import { DetailsListModal } from '../components/modals/DetailsListModal'; + +describe(' spec', () => { + configure({ adapter: new Adapter() }); + + it('renders the component', () => { + const items = [ + 'test item 1', + 'test item 2', + 'test item 3', + 'test item 4', + 'test item 5', + 'test item 6', + 'test item 7', + 'test item 8', + ]; + const onClose = jest.fn(); + const wrap = mount( + + ); + expect(wrap).toMatchSnapshot(); + }); +}); diff --git a/public/pages/Channels/__tests__/DetailsTableModal.test.tsx b/public/pages/Channels/__tests__/DetailsTableModal.test.tsx new file mode 100644 index 00000000..5a455a10 --- /dev/null +++ b/public/pages/Channels/__tests__/DetailsTableModal.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { notificationServiceMock } from '../../../../test/mocks/serviceMock'; +import { DetailsTableModal } from '../components/modals/DetailsTableModal'; + +describe(' spec', () => { + configure({ adapter: new Adapter() }); + + it('renders parameters', () => { + const onClose = jest.fn(); + const items = [ + { key: 'test key', value: 'test value' }, + { key: null, value: '' }, + ]; + const wrap = mount( + + ); + expect(wrap).toMatchSnapshot(); + }); + + it('renders headers', () => { + const onClose = jest.fn(); + const items = [ + { key: 'test key', value: 'test value' }, + { key: undefined, value: '' }, + ]; + const wrap = mount( + + ); + expect(wrap).toMatchSnapshot(); + }); +}); diff --git a/public/pages/Channels/__tests__/MuteChannelModal.test.tsx b/public/pages/Channels/__tests__/MuteChannelModal.test.tsx new file mode 100644 index 00000000..19eaf7e5 --- /dev/null +++ b/public/pages/Channels/__tests__/MuteChannelModal.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MOCK_DATA } from '../../../../test/mocks/mockData'; +import { act, render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { + coreServicesMock, + notificationServiceMock, +} from '../../../../test/mocks/serviceMock'; +import { CoreServicesContext } from '../../../components/coreServices'; +import { MuteChannelModal } from '../components/modals/MuteChannelModal'; + +describe(' spec', () => { + it('returns if no channels', () => { + const { container } = render( + + {}} + onClose={() => {}} + services={notificationServiceMock} + /> + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders the component', () => { + const channels = [jest.fn() as any]; + const setSelected = jest.fn(); + const { container } = render( + + {}} + services={notificationServiceMock} + /> + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('clicks mute button', async () => { + const setSelected = jest.fn(); + const notificationServiceMock = jest.fn() as any; + notificationServiceMock.notificationService = { + updateConfig: async (id: string, config: any) => { + return Promise.resolve(); + }, + }; + const utils = render( + + {}} + refresh={jest.fn()} + services={notificationServiceMock} + /> + + ); + utils.getByText('Mute').click(); + await waitFor(() => expect(setSelected).toBeCalled()) + }); + + it('handles failures', async () => { + const setSelected = jest.fn(); + const notificationServiceMock = jest.fn() as any; + notificationServiceMock.notificationService = { + updateConfig: async (id: string, config: any) => { + return Promise.reject(); + }, + }; + const utils = render( + + {}} + services={notificationServiceMock} + /> + + ); + utils.getByText('Mute').click(); + expect(utils.container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/public/pages/Channels/__tests__/__snapshots__/ChannelActions.test.tsx.snap b/public/pages/Channels/__tests__/__snapshots__/ChannelActions.test.tsx.snap new file mode 100644 index 00000000..e87e8d63 --- /dev/null +++ b/public/pages/Channels/__tests__/__snapshots__/ChannelActions.test.tsx.snap @@ -0,0 +1,156 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec clicks in the popover 1`] = ` +
+
+ +
+
+`; + +exports[` spec renders the action button disabled by default 1`] = ` +
+
+ +
+
+`; + +exports[` spec renders the popover 1`] = ` +
+
+ +
+
+`; + +exports[` spec renders the popover with multiple selected channels 1`] = ` +
+
+ +
+
+`; diff --git a/public/pages/Channels/__tests__/__snapshots__/ChannelControls.test.tsx.snap b/public/pages/Channels/__tests__/__snapshots__/ChannelControls.test.tsx.snap new file mode 100644 index 00000000..4223cbb7 --- /dev/null +++ b/public/pages/Channels/__tests__/__snapshots__/ChannelControls.test.tsx.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+`; diff --git a/public/pages/Channels/__tests__/__snapshots__/ChannelDetails.test.tsx.snap b/public/pages/Channels/__tests__/__snapshots__/ChannelDetails.test.tsx.snap new file mode 100644 index 00000000..2cdaf728 --- /dev/null +++ b/public/pages/Channels/__tests__/__snapshots__/ChannelDetails.test.tsx.snap @@ -0,0 +1,351 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec handles a non-existing channel 1`] = ` +
+
+
+
+
+
+

+ - +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Name and description +

+
+
+
+
+
+
+
+
+
+
+ Channel name +
+
+ - +
+
+
+
+
+
+ Description +
+
+ - +
+
+
+
+
+
+ Last updated +
+
+ - +
+
+
+
+
+
+
+
+
+
+
+
+

+ Configurations +

+
+
+
+
+
+
+`; + +exports[` spec renders a specific channel 1`] = ` +
+
+
+
+
+
+

+ - +

+
+
+
+
+
+
+
+
+
+
+
+
+

+ Name and description +

+
+
+
+
+
+
+
+
+
+
+ Channel name +
+
+ - +
+
+
+
+
+
+ Description +
+
+ - +
+
+
+
+
+
+ Last updated +
+
+ - +
+
+
+
+
+
+
+
+
+
+
+
+

+ Configurations +

+
+
+
+
+
+
+`; + +exports[` spec renders the component 1`] = ` +
+`; diff --git a/public/pages/Channels/__tests__/__snapshots__/ChannelDetailsActions.test.tsx.snap b/public/pages/Channels/__tests__/__snapshots__/ChannelDetailsActions.test.tsx.snap new file mode 100644 index 00000000..b81abd62 --- /dev/null +++ b/public/pages/Channels/__tests__/__snapshots__/ChannelDetailsActions.test.tsx.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec clicks buttons in popover 1`] = ` +
+
+ +
+
+`; + +exports[` spec opens popover 1`] = ` +
+
+ +
+
+`; + +exports[` spec renders the component 1`] = ` +
+
+ +
+
+`; diff --git a/public/pages/Channels/__tests__/__snapshots__/ChannelSettingsDetails.test.tsx.snap b/public/pages/Channels/__tests__/__snapshots__/ChannelSettingsDetails.test.tsx.snap new file mode 100644 index 00000000..29639d5b --- /dev/null +++ b/public/pages/Channels/__tests__/__snapshots__/ChannelSettingsDetails.test.tsx.snap @@ -0,0 +1,271 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders Chime channel 1`] = ` +
+
+
+
+
+
+ Channel type +
+
+ Chime +
+
+
+
+
+
+ Webhook URL +
+
+ https://chimehook +
+
+
+
+
+
+
+`; + +exports[` spec renders Email channel 1`] = ` +
+
+
+
+
+
+ Channel type +
+
+ Email +
+
+
+
+
+
+ Sender +
+
+ name1 +
+
+
+
+
+
+ Default recipients +
+
+
+ name1, name2, name3, name4, name5 +
+ + +
+
+
+
+
+
+`; + +exports[` spec renders Slack channel 1`] = ` +
+
+
+
+
+
+ Channel type +
+
+ Slack +
+
+
+
+
+
+ Webhook URL +
+
+ https://chimehook +
+
+
+
+
+
+
+`; + +exports[` spec renders Webhook channel 1`] = ` +
+
+
+
+
+
+ Channel type +
+
+ Custom webhook +
+
+
+
+
+
+ Method +
+
+ POST +
+
+
+
+
+
+ Type +
+
+ HTTPS +
+
+
+
+
+
+`; + +exports[` spec renders the empty component 1`] = `null`; diff --git a/public/pages/Channels/__tests__/__snapshots__/Channels.test.tsx.snap b/public/pages/Channels/__tests__/__snapshots__/Channels.test.tsx.snap new file mode 100644 index 00000000..9395f2af --- /dev/null +++ b/public/pages/Channels/__tests__/__snapshots__/Channels.test.tsx.snap @@ -0,0 +1,515 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the empty component 1`] = ` +
+
+
+

+ Channels + + (0) + +

+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+
+ + + + + + + +
+
+ +
+

+ No channels to display +

+ +
+
+ To send or receive notifications, you will need to create a notification channel. +
+ + + +
+
+
+
+
+
+`; diff --git a/public/pages/Channels/__tests__/__snapshots__/DeleteChannelModal.test.tsx.snap b/public/pages/Channels/__tests__/__snapshots__/DeleteChannelModal.test.tsx.snap new file mode 100644 index 00000000..42ed30fe --- /dev/null +++ b/public/pages/Channels/__tests__/__snapshots__/DeleteChannelModal.test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec deletes channels 1`] = `null`; + +exports[` spec handles failures when deleting channels 1`] = `null`; + +exports[` spec renders multiple channels 1`] = `null`; + +exports[` spec renders the component 1`] = `null`; diff --git a/public/pages/Channels/__tests__/__snapshots__/DetailsListModal.test.tsx.snap b/public/pages/Channels/__tests__/__snapshots__/DetailsListModal.test.tsx.snap new file mode 100644 index 00000000..3be819a4 --- /dev/null +++ b/public/pages/Channels/__tests__/__snapshots__/DetailsListModal.test.tsx.snap @@ -0,0 +1,1266 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` + + +
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + > + +
+ +
+
+
+ test header +
+
+
+
+

+ Email addresses +

+
+
+

+ test item 1 +

+
+
+
+

+ test item 2 +

+
+
+
+

+ test item 3 +

+
+
+
+

+ test item 4 +

+
+
+
+

+ test item 5 +

+
+
+
+

+ test item 6 +

+
+
+
+

+ test item 7 +

+
+
+
+

+ test item 8 +

+
+
+
+
+ +
+
+
+
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + /> + + + + +
+
+ + + + + +
+ +
+ +
+ test header +
+
+
+
+ +
+
+ +

+ Email addresses +

+
+
+ +
+
+

+ test item 1 +

+
+
+ +
+
+

+ test item 2 +

+
+
+ +
+
+

+ test item 3 +

+
+
+ +
+
+

+ test item 4 +

+
+
+ +
+
+

+ test item 5 +

+
+
+ +
+
+

+ test item 6 +

+
+
+ +
+
+

+ test item 7 +

+
+
+ +
+
+

+ test item 8 +

+
+
+
+
+ +
+ + + + + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +`; diff --git a/public/pages/Channels/__tests__/__snapshots__/DetailsTableModal.test.tsx.snap b/public/pages/Channels/__tests__/__snapshots__/DetailsTableModal.test.tsx.snap new file mode 100644 index 00000000..44fe3039 --- /dev/null +++ b/public/pages/Channels/__tests__/__snapshots__/DetailsTableModal.test.tsx.snap @@ -0,0 +1,3715 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders headers 1`] = ` + + +