Add webhooks (#447)

This commit is contained in:
Riccardo Graziosi
2024-12-20 14:06:48 +01:00
committed by GitHub
parent 2290cff507
commit a12a95eccc
63 changed files with 2914 additions and 64 deletions

View File

@@ -3,7 +3,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '3.0.6'
gem 'rake', '12.3.3'
gem 'rake', '13.2.1'
gem 'rails', '6.1.7.9'
@@ -67,6 +67,9 @@ gem 'sidekiq', '7.3.5'
# Cron jobs with sidekiq
gem 'sidekiq-cron', '2.0.1'
# Template language
gem 'liquid', '5.5.1'
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]

View File

@@ -141,6 +141,7 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
liquid (5.5.1)
listen (3.5.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
@@ -218,7 +219,7 @@ GEM
method_source
rake (>= 12.2)
thor (~> 1.0)
rake (12.3.3)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
@@ -330,6 +331,7 @@ DEPENDENCIES
jbuilder (= 2.11.5)
jsbundling-rails (= 1.1.1)
kaminari (= 1.2.2)
liquid (= 5.5.1)
listen (= 3.5.1)
pg (= 1.3.5)
puma (= 5.6.9)
@@ -337,7 +339,7 @@ DEPENDENCIES
rack-attack (= 6.7.0)
rack-cors (= 2.0.2)
rails (= 6.1.7.9)
rake (= 12.3.3)
rake (= 13.2.1)
react-rails (= 2.6.2)
rspec-rails (= 4.0.2)
rspec-retry (= 0.6.2)

View File

@@ -31,6 +31,7 @@
@import 'components/SiteSettings/Authentication';
@import 'components/SiteSettings/Appearance/';
@import 'components/SiteSettings/Invitations';
@import 'components/SiteSettings/Webhooks';
/* Moderation Components */
@import 'components/Moderation/Feedback';

View File

@@ -173,6 +173,7 @@ body {
}
.badgeWarning { @extend .badge-warning; }
.badgeDanger { @extend .badge-danger; }
.badgeSuccess { @extend .badge-success; }
.container {
max-width: 960px;

View File

@@ -53,6 +53,8 @@
}
.editCommentForm {
.commentFormContainer { @extend .d-block; }
textarea {
@extend .my-2;
}

View File

@@ -59,7 +59,7 @@
.pl-0;
list-style: none;
height: 500px;
max-height: 500px;
overflow-y: scroll;
li.invitationListItem {

View File

@@ -0,0 +1,126 @@
.previewStyling {
@extend .mt-4, .p-3;
max-height: 400px;
max-width: 600px;
background-color: #f4f4f4;
border-radius: 8px;
}
a.backButton {
@extend .mb-2, .align-self-start;
font-size: 18px;
}
.webhooksIndexPage {
.webhooksTitle {
@extend
.d-flex,
.mb-2;
button {
@extend .ml-2;
height: min-content;
}
}
.webhooksList {
@extend .pl-1;
list-style: none;
h4 { @extend .mb-0, .mt-2; }
ul { @extend .pl-0; }
.webhookListItem {
@extend
.d-flex,
.justify-content-between,
.mb-2,
.p-2;
.webhookInfo {
@extend .d-flex;
column-gap: 32px;
.webhookName { font-size: 18px; }
.webhookDescription {
@extend .mutedText, .mb-1;
font-size: smaller;
}
}
.webhookActions {
@extend .d-flex;
align-self: center;
}
}
}
}
.webhookFormPage {
#httpBody {
min-height: 200px;
}
.httpBodyActions {
@extend
.d-flex,
.justify-content-between,
.mt-1;
div:first-child {
min-width: 250px;
}
}
.previewHttpBody {
display: block;
text-align: right;
}
.urlAndHttpBodyPreview {
@extend .mt-3;
#preview {
@extend .previewStyling;
margin-top: 0 !important;
}
}
.formGroupHttpHeaders {
@extend .mb-0;
div.formRow { @extend .mb-0; }
div.formRow:last-child {
div.formGroup { @extend .mb-1; }
}
}
.deleteHeaderActionLinkContainer {
@extend
.d-flex,
.align-self-end;
}
.submitWebhookFormButton { @extend .mt-4; }
}
.webhookTestPage {
.webhookActions {
@extend .d-flex;
}
.webhookTestResponse {
@extend .mt-4;
#testHttpResponse { @extend .previewStyling; }
}
}

View File

@@ -19,6 +19,9 @@ class SiteSettingsController < ApplicationController
def roadmap
end
def webhooks
end
def invitations
@invitations = Invitation.all.order(updated_at: :desc)
end

View File

@@ -0,0 +1,97 @@
class WebhooksController < ApplicationController
def index
authorize Webhook
@webhooks = Webhook.order(trigger: :asc, created_at: :desc)
render json: @webhooks
end
def create
@webhook = Webhook.new
@webhook.assign_attributes(webhook_params)
authorize @webhook
if @webhook.save
render json: @webhook, status: :created
else
render json: {
error: @webhook.errors.full_messages
}, status: :unprocessable_entity
end
end
def update
@webhook = Webhook.find(params[:id])
authorize @webhook
if @webhook.update(webhook_params)
render json: @webhook
else
render json: {
error: @webhook.errors.full_messages
}, status: :unprocessable_entity
end
end
def destroy
@webhook = Webhook.find(params[:id])
authorize @webhook
if @webhook.destroy
render json: {
id: params[:id]
}, status: :accepted
else
render json: {
error: @webhook.errors.full_messages
}, status: :unprocessable_entity
end
end
def preview
context = CreateLiquidTemplateContextWorkflow.new(
webhook_trigger: params[:webhook][:trigger],
is_test: true,
).run
url_template = Liquid::Template.parse(params[:webhook][:url])
url_preview = url_template.render(context)
http_body_template = Liquid::Template.parse(params[:webhook][:http_body])
http_body_preview = http_body_template.render(context)
render json: {
url_preview: url_preview,
http_body_preview: http_body_preview,
}, status: :ok
rescue => e
render json: {
error: e.message
}, status: :unprocessable_entity
end
def test
response = RunWebhook.perform_now(
webhook_id: params[:id],
current_tenant_id: Current.tenant.id,
is_test: true
)
render json: {
response: response,
}, status: :ok
rescue => e
render json: {
error: e.message
}, status: :unprocessable_entity
end
private
def webhook_params
params
.require(:webhook)
.permit(policy(@webhook).permitted_attributes)
end
end

View File

@@ -1,5 +1,6 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import { ISiteSettingsOAuthForm } from "../../components/SiteSettings/Authentication/OAuthForm";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";

View File

@@ -0,0 +1,69 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { State } from "../../reducers/rootReducer";
export const WEBHOOK_DELETE_START = 'WEBHOOK_DELETE_START';
interface WebhookDeleteStartAction {
type: typeof WEBHOOK_DELETE_START;
}
export const WEBHOOK_DELETE_SUCCESS = 'WEBHOOK_DELETE_SUCCESS';
interface WebhookDeleteSuccessAction {
type: typeof WEBHOOK_DELETE_SUCCESS;
id: number;
}
export const WEBHOOK_DELETE_FAILURE = 'WEBHOOK_DELETE_FAILURE';
interface WebhookDeleteFailureAction {
type: typeof WEBHOOK_DELETE_FAILURE;
error: string;
}
export type WebhookDeleteActionTypes =
WebhookDeleteStartAction |
WebhookDeleteSuccessAction |
WebhookDeleteFailureAction;
const webhookDeleteStart = (): WebhookDeleteStartAction => ({
type: WEBHOOK_DELETE_START,
});
const webhookDeleteSuccess = (
id: number,
): WebhookDeleteSuccessAction => ({
type: WEBHOOK_DELETE_SUCCESS,
id,
});
const webhookDeleteFailure = (error: string): WebhookDeleteFailureAction => ({
type: WEBHOOK_DELETE_FAILURE,
error,
});
export const deleteWebhook = (
id: number,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(webhookDeleteStart());
try {
const res = await fetch(`/webhooks/${id}`, {
method: 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
if (res.status === HttpStatus.Accepted) {
dispatch(webhookDeleteSuccess(id));
} else {
dispatch(webhookDeleteFailure(json.error));
}
} catch (error) {
dispatch(webhookDeleteFailure(error.message));
}
}
);

View File

@@ -0,0 +1,59 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import { IWebhookJSON } from '../../interfaces/IWebhook';
import { State } from '../../reducers/rootReducer';
export const WEBHOOKS_REQUEST_START = 'WEBHOOKS_REQUEST_START';
interface WebhooksRequestStartAction {
type: typeof WEBHOOKS_REQUEST_START;
}
export const WEBHOOKS_REQUEST_SUCCESS = 'WEBHOOKS_REQUEST_SUCCESS';
interface WebhooksRequestSuccessAction {
type: typeof WEBHOOKS_REQUEST_SUCCESS;
webhooks: Array<IWebhookJSON>;
}
export const WEBHOOKS_REQUEST_FAILURE = 'WEBHOOKS_REQUEST_FAILURE';
interface WebhooksRequestFailureAction {
type: typeof WEBHOOKS_REQUEST_FAILURE;
error: string;
}
export type WebhooksRequestActionTypes =
WebhooksRequestStartAction |
WebhooksRequestSuccessAction |
WebhooksRequestFailureAction;
const webhooksRequestStart = (): WebhooksRequestActionTypes => ({
type: WEBHOOKS_REQUEST_START,
});
const webhooksRequestSuccess = (
webhooks: Array<IWebhookJSON>
): WebhooksRequestActionTypes => ({
type: WEBHOOKS_REQUEST_SUCCESS,
webhooks,
});
const webhooksRequestFailure = (error: string): WebhooksRequestActionTypes => ({
type: WEBHOOKS_REQUEST_FAILURE,
error,
});
export const requestWebhooks = (): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(webhooksRequestStart());
try {
const response = await fetch('/webhooks');
const json = await response.json();
dispatch(webhooksRequestSuccess(json));
} catch (e) {
dispatch(webhooksRequestFailure(e));
}
}
)

View File

@@ -0,0 +1,78 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import { IWebhook, IWebhookJSON, webhookJS2JSON } from "../../interfaces/IWebhook";
import { State } from "react-joyride";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import HttpStatus from "../../constants/http_status";
export const WEBHOOK_SUBMIT_START = 'WEBHOOK_SUBMIT_START';
interface WebhookSubmitStartAction {
type: typeof WEBHOOK_SUBMIT_START;
}
export const WEBHOOK_SUBMIT_SUCCESS = 'WEBHOOK_SUBMIT_SUCCESS';
interface WebhookSubmitSuccessAction {
type: typeof WEBHOOK_SUBMIT_SUCCESS;
webhook: IWebhookJSON;
}
export const WEBHOOK_SUBMIT_FAILURE = 'WEBHOOK_SUBMIT_FAILURE';
interface WebhookSubmitFailureAction {
type: typeof WEBHOOK_SUBMIT_FAILURE;
error: string;
}
export type WebhookSubmitActionTypes =
WebhookSubmitStartAction |
WebhookSubmitSuccessAction |
WebhookSubmitFailureAction;
const webhookSubmitStart = (): WebhookSubmitStartAction => ({
type: WEBHOOK_SUBMIT_START,
});
const webhookSubmitSuccess = (
webhookJSON: IWebhookJSON,
): WebhookSubmitSuccessAction => ({
type: WEBHOOK_SUBMIT_SUCCESS,
webhook: webhookJSON,
});
const webhookSubmitFailure = (error: string): WebhookSubmitFailureAction => ({
type: WEBHOOK_SUBMIT_FAILURE,
error,
});
export const submitWebhook = (
webhook: IWebhook,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(webhookSubmitStart());
try {
const res = await fetch(`/webhooks`, {
method: 'POST',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
webhook: {
...webhookJS2JSON(webhook),
is_enabled: false,
},
}),
});
const json = await res.json();
if (res.status === HttpStatus.Created) {
dispatch(webhookSubmitSuccess(json));
} else {
dispatch(webhookSubmitFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(webhookSubmitFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -0,0 +1,96 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { State } from "../../reducers/rootReducer";
import { IWebhookJSON } from "../../interfaces/IWebhook";
import { ISiteSettingsWebhookFormUpdate } from "../../components/SiteSettings/Webhooks/WebhookForm";
export const WEBHOOK_UPDATE_START = 'WEBHOOK_UPDATE_START';
interface WebhookUpdateStartAction {
type: typeof WEBHOOK_UPDATE_START;
}
export const WEBHOOK_UPDATE_SUCCESS = 'WEBHOOK_UPDATE_SUCCESS';
interface WebhookUpdateSuccessAction {
type: typeof WEBHOOK_UPDATE_SUCCESS;
webhook: IWebhookJSON;
}
export const WEBHOOK_UPDATE_FAILURE = 'WEBHOOK_UPDATE_FAILURE';
interface WebhookUpdateFailureAction {
type: typeof WEBHOOK_UPDATE_FAILURE;
error: string;
}
export type WebhookUpdateActionTypes =
WebhookUpdateStartAction |
WebhookUpdateSuccessAction |
WebhookUpdateFailureAction;
const webhookUpdateStart = (): WebhookUpdateStartAction => ({
type: WEBHOOK_UPDATE_START,
});
const webhookUpdateSuccess = (
webhookJSON: IWebhookJSON,
): WebhookUpdateSuccessAction => ({
type: WEBHOOK_UPDATE_SUCCESS,
webhook: webhookJSON,
});
const webhookUpdateFailure = (error: string): WebhookUpdateFailureAction => ({
type: WEBHOOK_UPDATE_FAILURE,
error,
});
interface UpdateWebhookParams {
id: number;
form?: ISiteSettingsWebhookFormUpdate;
isEnabled?: boolean;
authenticityToken: string;
}
export const updateWebhook = ({
id,
form = null,
isEnabled = null,
authenticityToken,
}: UpdateWebhookParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(webhookUpdateStart());
const webhook = Object.assign({},
form !== null ? {
name: form.name,
description: form.description,
trigger: form.trigger,
http_method: form.httpMethod,
url: form.url,
http_body: form.httpBody,
http_headers: form.httpHeaders,
} : null,
isEnabled !== null ? { is_enabled: isEnabled } : null,
);
try {
const res = await fetch(`/webhooks/${id}`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({ webhook }),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
dispatch(webhookUpdateSuccess(json));
} else {
dispatch(webhookUpdateFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(webhookUpdateFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -3,6 +3,7 @@ import I18n from 'i18n-js';
import Button from '../common/Button';
import { SmallMutedText } from '../common/CustomTexts';
import { MarkdownIcon } from '../common/Icons';
interface Props {
title: string;
@@ -93,6 +94,9 @@ const NewPostForm = ({
className="form-control"
id="postDescription"
></textarea>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div>
<Button onClick={e => handleSubmit(e)} className="submitBtn d-block mx-auto">

View File

@@ -3,7 +3,7 @@ import I18n from 'i18n-js';
import Button from '../common/Button';
import Switch from '../common/Switch';
import ActionLink from '../common/ActionLink';
import { CancelIcon } from '../common/Icons';
import { CancelIcon, MarkdownIcon } from '../common/Icons';
interface Props {
id: number;
@@ -55,13 +55,19 @@ class CommentEditForm extends React.Component<Props, State> {
return (
<div className="editCommentForm">
<textarea
value={body}
onChange={e => this.handleCommentBodyChange(e.target.value)}
rows={3}
autoFocus
className="commentForm"
/>
<div className="commentFormContainer">
<textarea
value={body}
onChange={e => this.handleCommentBodyChange(e.target.value)}
rows={3}
autoFocus
className="commentForm"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-36px'}} />
</div>
</div>
<div>
<div>

View File

@@ -7,6 +7,7 @@ import NewCommentUpdateSection from './NewCommentUpdateSection';
import Button from '../common/Button';
import Spinner from '../common/Spinner';
import { DangerText } from '../common/CustomTexts';
import { MarkdownIcon } from '../common/Icons';
interface Props {
body: string;
@@ -48,13 +49,20 @@ const NewComment = ({
<>
<div className="commentBodyForm">
<Gravatar email={userEmail} size={48} className="currentUserAvatar" />
<textarea
value={body}
onChange={handleChange}
autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm"
/>
<div style={{width: '100%', marginRight: '8px'}}>
<textarea
value={body}
onChange={handleChange}
autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div>
<Button
onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}
className="submitCommentButton">

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import { AuthenticationPages } from './AuthenticationSiteSettingsP';

View File

@@ -79,7 +79,7 @@ const AuthenticationIndexPage = ({
<h2>{ I18n.t('site_settings.authentication.title') }</h2>
<div className="emailRegistrationPolicy">
<h3>{ I18n.t('site_settings.authentication.email_registration_subtitle') }</h3>
<h4>{ I18n.t('site_settings.authentication.email_registration_subtitle') }</h4>
<form onSubmit={handleSubmit(onSubmit)} onChange={handleSubmit(onSubmit)}>
<div className="formGroup">

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { SubmitHandler, useForm } from 'react-hook-form';
import { DangerText } from '../../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import Button from '../../common/Button';
@@ -102,6 +103,7 @@ const OAuthForm = ({
>
{I18n.t('common.buttons.back')}
</ActionLink>
<h2>{ I18n.t(`site_settings.authentication.form.title_${page}`) }</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="formRow">

View File

@@ -27,7 +27,7 @@ const OAuthProvidersList = ({
}: Props) => (
<>
<div className="oauthProvidersTitle">
<h3>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h3>
<h4>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h4>
<Button onClick={() => setPage('new')}>
{ I18n.t('common.buttons.new') }
</Button>

View File

@@ -4,6 +4,7 @@ import I18n from 'i18n-js';
import Button from '../../common/Button';
import { DangerText } from '../../common/CustomTexts';
import { MarkdownIcon } from '../../common/Icons';
interface Props {
mode: 'create' | 'update';
@@ -95,11 +96,17 @@ const BoardForm = ({
</Button>
</div>
<textarea
{...register('description')}
placeholder={I18n.t('site_settings.boards.form.description')}
className="boardDescriptionTextArea formControl"
/>
<div>
<textarea
{...register('description')}
placeholder={I18n.t('site_settings.boards.form.description')}
className="boardDescriptionTextArea formControl"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div>
{mode === 'update' && (
<>

View File

@@ -50,6 +50,7 @@ const RoadmapEmbedding: React.FC<Props> = ({ embeddedRoadmapUrl }) => {
onChange={event => setEmbedCode(event.target.value)}
rows={5}
id="roadmapEmbedCode"
className="formControl"
>
</textarea>

View File

@@ -0,0 +1,241 @@
import React, { useState } from 'react';
import I18n from 'i18n-js';
import Select from 'react-select';
// To keep in sync with app/workflows/create_liquid_template_context_workflow.rb
const tenantOptions = [
{ value: '{{ tenant.site_name }}', label: 'Feedback space name' },
{ value: '{{ tenant.subdomain }}', label: 'Feedback space subdomain' },
{ value: '{{ tenant.custom_domain }}', label: 'Feedback space custom domain' },
];
const boardOptions = [
{ value: '{{ board.id }}', label: 'Board ID' },
{ value: '{{ board.name }}', label: 'Board name' },
{ value: '{{ board.description }}', label: 'Board description' },
{ value: '{{ board.slug }}', label: 'Board slug' },
{ value: '{{ board.created_at }}', label: 'Board created at datetime' },
{ value: '{{ board.updated_at }}', label: 'Board updated at datetime' },
];
const postStatusOptions = [
{ value: '{{ post_status.id }}', label: 'Post status ID' },
{ value: '{{ post_status.name }}', label: 'Post status name' },
{ value: '{{ post_status.color }}', label: 'Post status color' },
{ value: '{{ post_status.show_in_roadmap }}', label: 'Post status show in roadmap flag' },
{ value: '{{ post_status.created_at }}', label: 'Post status created at datetime' },
{ value: '{{ post_status.updated_at }}', label: 'Post status updated at datetime' },
];
const userOptions = (userKeyValue: string, userKeyLabel: string) => [
{ value: `{{ ${userKeyValue}.id }}`, label: `${userKeyLabel} ID` },
{ value: `{{ ${userKeyValue}.email }}`, label: `${userKeyLabel} email` },
{ value: `{{ ${userKeyValue}.full_name }}`, label: `${userKeyLabel} full name` },
{ value: `{{ ${userKeyValue}.role }}`, label: `${userKeyLabel} role` },
{ value: `{{ ${userKeyValue}.status }}`, label: `${userKeyLabel} status` },
{ value: `{{ ${userKeyValue}.created_at }}`, label: `${userKeyLabel} created at datetime` },
{ value: `{{ ${userKeyValue}.updated_at }}`, label: `${userKeyLabel} updated at datetime` },
];
const postOptions = [
{ value: '{{ post.id }}', label: 'Post ID' },
{ value: '{{ post.title }}', label: 'Post title' },
{ value: '{{ post.description }}', label: 'Post description' },
{ value: '{{ post.slug }}', label: 'Post slug' },
{ value: '{{ post.created_at }}', label: 'Post created at datetime' },
{ value: '{{ post.updated_at }}', label: 'Post updated at datetime' },
{ value: '{{ post.url }}', label: 'Post URL' },
];
const commentOptions = [
{ value: '{{ comment.id }}', label: 'Comment ID' },
{ value: '{{ comment.body }}', label: 'Comment body' },
{ value: '{{ comment.created_at }}', label: 'Comment created at datetime' },
{ value: '{{ comment.updated_at }}', label: 'Comment updated at datetime' },
];
const optionsByWebhookTrigger = {
'new_post': [
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'new_post_pending_approval': [
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'delete_post': [
// only post.id is available on delete_post
postOptions.find(option => option.value === '{{ post.id }}'),
...tenantOptions,
],
'post_status_change': [
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...postStatusOptions,
...tenantOptions,
],
'new_comment': [
...commentOptions,
...userOptions('comment_author', 'Comment author'),
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'new_vote': [
...userOptions('vote_author', 'Vote author'),
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'new_user': [
...userOptions('user', 'User'),
...tenantOptions,
],
};
// Non-exhaustive list of Liquid tags
const liquidTagsOptions = {
label: 'Liquid tags',
options: [
{ value: '{% if <condition> %}\n\n{% endif %}', label: 'If condition' },
{ value: '{% for <item> in <collection> %}\n\n{% endfor %}', label: 'For loop' },
{ value: '{% tablerow <item> in <collection> %}\n\n{% endtablerow %}', label: 'Tablerow loop' },
{ value: '{% assign <variable> = <value> %}', label: 'Assign variable' },
]
};
// Non-exhaustive list of Liquid filters
const liquidFiltersOptions = {
label: 'Liquid filters',
options: [
{ value: ' | abs', label: 'Absolute value' },
{ value: ' | append: <value>', label: 'Append' },
{ value: ' | capitalize', label: 'Capitalize' },
{ value: ' | ceil', label: 'Ceil' },
{ value: ' | compact', label: 'Compact' },
{ value: ' | concat: <array>', label: 'Concat' },
{ value: ' | date: <format>', label: 'Date' },
{ value: ' | default: <value>', label: 'Default' },
{ value: ' | divided_by: <value>', label: 'Divided by' },
{ value: ' | downcase', label: 'Downcase' },
{ value: ' | escape', label: 'Escape' },
{ value: ' | escape_once', label: 'Escape once' },
{ value: ' | first', label: 'First' },
{ value: ' | floor', label: 'Floor' },
{ value: ' | join: <value>', label: 'Join' },
{ value: ' | last', label: 'Last' },
{ value: ' | lstrip', label: 'Lstrip' },
{ value: ' | map: <value>', label: 'Map' },
{ value: ' | minus: <value>', label: 'Minus' },
{ value: ' | modulo: <value>', label: 'Modulo' },
{ value: ' | newline_to_br', label: 'Newline to br' },
{ value: ' | plus: <value>', label: 'Plus' },
{ value: ' | prepend: <value>', label: 'Prepend' },
{ value: ' | remove: <value>', label: 'Remove' },
{ value: ' | remove_first: <value>', label: 'Remove first' },
{ value: ' | replace: <value>, <new_value>', label: 'Replace' },
{ value: ' | replace_first: <value>, <new_value>', label: 'Replace first' },
{ value: ' | replace_last: <value>, <new_value>', label: 'Replace last' },
{ value: ' | reverse', label: 'Reverse' },
{ value: ' | round', label: 'Round' },
{ value: ' | rstrip', label: 'Rstrip' },
{ value: ' | size', label: 'Size' },
{ value: ' | slice: <value>', label: 'Slice' },
{ value: ' | sort', label: 'Sort' },
{ value: ' | sort_natural', label: 'Sort natural' },
{ value: ' | split: <value>', label: 'Split' },
{ value: ' | strip', label: 'Strip' },
{ value: ' | strip_html', label: 'Strip html' },
{ value: ' | strip_newlines', label: 'Strip newlines' },
{ value: ' | times: <value>', label: 'Times' },
{ value: ' | truncate: <value>', label: 'Truncate' },
{ value: ' | truncatewords: <value>', label: 'Truncate words' },
{ value: ' | uniq', label: 'Uniq' },
{ value: ' | upcase', label: 'Upcase' },
{ value: ' | url_decode', label: 'Url decode' },
{ value: ' | url_encode', label: 'Url encode' },
{ value: ' | where: <value>', label: 'Where' },
]
};
// Custom Liquid filters
const customLiquidFiltersOptions = {
label: 'Custom Liquid filters',
options: [
{ value: ' | escape_json', label: 'Escape JSON' },
]
};
interface Props {
webhookTrigger: string;
onChange: (option: any) => void;
}
const TemplateVariablesSelector = ({
webhookTrigger,
onChange,
}: Props) => {
const options = [
{
label: 'Astuto variables',
options: optionsByWebhookTrigger[webhookTrigger] || [],
},
{
label: 'Liquid tags',
options: liquidTagsOptions.options,
},
{
label: 'Liquid filters',
options: liquidFiltersOptions.options,
},
{
label: 'Custom Liquid filters',
options: customLiquidFiltersOptions.options,
},
];
const [selectedOption, setSelectedOption] = useState(null);
const handleChange = (option) => {
onChange(option.value);
// Reset the selection
setSelectedOption(null);
};
return (
<Select
options={options}
value={selectedOption}
onChange={handleChange}
isClearable={false}
isSearchable
placeholder={I18n.t('site_settings.webhooks.form.template_variables_selector_placeholder')}
styles={{
control: (provided, state) => ({
...provided,
boxShadow: 'none',
borderColor: state.isFocused ? '#333333' : '#cdcdcd',
'&:hover': {
boxShadow: 'none',
borderColor: '#333333',
},
}),
option: (provided, state) => ({
...provided,
color: 'inherit',
backgroundColor: state.isFocused ? '#f2f2f2' : 'white',
}),
}}
/>
);
};
export default TemplateVariablesSelector;

View File

@@ -0,0 +1,415 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
import { IWebhook, WEBHOOK_HTTP_METHOD_DELETE, WEBHOOK_HTTP_METHOD_PATCH, WEBHOOK_HTTP_METHOD_POST, WEBHOOK_HTTP_METHOD_PUT, WEBHOOK_TRIGGER_DELETED_POST, WEBHOOK_TRIGGER_NEW_COMMENT, WEBHOOK_TRIGGER_NEW_POST, WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL, WEBHOOK_TRIGGER_NEW_USER, WEBHOOK_TRIGGER_NEW_VOTE, WEBHOOK_TRIGGER_POST_STATUS_CHANGE, WebhookHttpMethod, WebhookTrigger } from '../../../interfaces/IWebhook';
import { WebhookPages } from './WebhooksSiteSettingsP';
import ActionLink from '../../common/ActionLink';
import { AddIcon, BackIcon, DeleteIcon, LiquidIcon, PreviewIcon } from '../../common/Icons';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import { DangerText } from '../../common/CustomTexts';
import Button from '../../common/Button';
import { URL_REGEX_WHITESPACE_ALLOWED } from '../../../constants/regex';
import Spinner from '../../common/Spinner';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
import HttpStatus from '../../../constants/http_status';
import { useRef, useState } from 'react';
import TemplateVariablesSelector from './TemplateVariablesSelector';
interface Props {
isSubmitting: boolean;
submitError: string;
selectedWebhook: IWebhook;
page: WebhookPages;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
handleSubmitWebhook(webhook: IWebhook): void;
handleUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate): void;
authenticityToken: string;
}
interface ISiteSettingsWebhookFormBase {
name: string;
description: string;
trigger: string;
url: string;
httpBody: string;
httpMethod: string;
}
interface ISiteSettingsWebhookForm extends ISiteSettingsWebhookFormBase {
httpHeaders: Array<{ key: string, value: string }>;
}
export interface ISiteSettingsWebhookFormUpdate extends ISiteSettingsWebhookFormBase {
httpHeaders: string;
}
// This method tries to parse httpHeaders JSON, otherwise returns [{ key: '', value: '' }]
const parseHttpHeaders = (httpHeaders: string) => {
try {
return JSON.parse(httpHeaders);
} catch (e) {
return [{ key: '', value: '' }];
}
}
const WebhookFormPage = ({
isSubmitting,
submitError,
selectedWebhook,
page,
setPage,
handleSubmitWebhook,
handleUpdateWebhook,
authenticityToken,
}: Props) => {
const {
register,
handleSubmit,
control,
formState: { errors, isDirty },
watch,
getValues,
setValue,
} = useForm<ISiteSettingsWebhookForm>({
defaultValues: page === 'new' ? {
name: '',
description: '',
trigger: WEBHOOK_TRIGGER_NEW_POST,
url: '',
httpBody: '',
httpMethod: WEBHOOK_HTTP_METHOD_POST,
httpHeaders: [{ key: '', value: '' }],
} : {
name: selectedWebhook.name,
description: selectedWebhook.description,
trigger: selectedWebhook.trigger,
url: selectedWebhook.url,
httpBody: selectedWebhook.httpBody,
httpMethod: selectedWebhook.httpMethod,
httpHeaders: parseHttpHeaders(selectedWebhook.httpHeaders),
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'httpHeaders', // The name of the httpHeaders field
});
const onSubmit: SubmitHandler<ISiteSettingsWebhookForm> = data => {
// Remove empty headers
let httpHeaders = data.httpHeaders.filter(header => header.key !== '' && header.value !== '');
const webhook = {
isEnabled: false,
name: data.name,
description: data.description,
trigger: data.trigger as WebhookTrigger,
url: data.url.replace(/\s/g, ''),
httpBody: data.httpBody,
httpMethod: data.httpMethod as WebhookHttpMethod,
httpHeaders: JSON.stringify(httpHeaders),
};
if (page === 'new') {
handleSubmitWebhook(webhook);
} else if (page === 'edit') {
handleUpdateWebhook(selectedWebhook.id, webhook);
}
};
const trigger = watch('trigger');
const url = watch('url');
const httpBody = watch('httpBody');
const httpBodyTextAreaRef = useRef(null);
const [cursorPosition, setCursorPosition] = React.useState(0);
const handleCursorPosition = e => {
setCursorPosition(e.target.selectionStart);
};
// Insert custom string at the last cursor position
const insertString = (stringToInsert: string) => {
const currentValue = getValues('httpBody'); // Get the current textarea value
const start = currentValue.slice(0, cursorPosition);
const end = currentValue.slice(cursorPosition);
const newValue = start + stringToInsert + end;
// Update textarea value with react-hook-form
setValue('httpBody', newValue, { shouldDirty: true });
setIsPreviewOutdated(true);
// Update cursor position after the custom string
const newCursorPosition = cursorPosition + stringToInsert.length;
setCursorPosition(newCursorPosition);
// Update the DOM to reflect the cursor position
if (httpBodyTextAreaRef.current) {
setTimeout(() => {
httpBodyTextAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
httpBodyTextAreaRef.current.focus();
}, 0);
}
};
// State for URL and body preview
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
const [previewContent, setPreviewContent] = useState('');
const [isPreviewOutdated, setIsPreviewOutdated] = useState(true);
return (
<>
<ActionLink
onClick={() => {
let confirmation = true;
if (isDirty)
confirmation = confirm(I18n.t('common.unsaved_changes') + ' ' + I18n.t('common.confirmation'));
if (confirmation) setPage('index');
}}
icon={<BackIcon />}
customClass="backButton"
>
{I18n.t('common.buttons.back')}
</ActionLink>
<h2>{ I18n.t(`site_settings.webhooks.form.title_${page}`) }</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="formRow">
<div className="formGroup col-6">
<label htmlFor="name">{ getLabel('webhook', 'name') }</label>
<input
{...register('name', { required: true, maxLength: 255 })}
id="name"
className="formControl"
/>
<DangerText>{errors.name?.type === 'required' && getValidationMessage(errors.name.type, 'webhook', 'name')}</DangerText>
<DangerText>{errors.name?.type === 'maxLength' && (getLabel('webhook', 'name') + ' ' + I18n.t('activerecord.errors.messages.too_long', { count: 255 }))}</DangerText>
</div>
<div className="formGroup col-6">
<label htmlFor="description">{ getLabel('webhook', 'description') }</label>
<input
{...register('description', { maxLength: 255 })}
id="description"
className="formControl"
/>
<DangerText>{errors.description?.type === 'maxLength' && (getLabel('webhook', 'description') + ' ' + I18n.t('activerecord.errors.messages.too_long', { count: 255 }))}</DangerText>
</div>
</div>
<div className="formGroup">
<label htmlFor="trigger">{ getLabel('webhook', 'trigger') }</label>
<select
{...register('trigger')}
id="trigger"
className="selectPicker"
>
<option value={WEBHOOK_TRIGGER_NEW_POST}>
{I18n.t('site_settings.webhooks.triggers.new_post')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL}>
{I18n.t('site_settings.webhooks.triggers.new_post_pending_approval')}
</option>
<option value={WEBHOOK_TRIGGER_DELETED_POST}>
{I18n.t('site_settings.webhooks.triggers.delete_post')}
</option>
<option value={WEBHOOK_TRIGGER_POST_STATUS_CHANGE}>
{I18n.t('site_settings.webhooks.triggers.post_status_change')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_COMMENT}>
{I18n.t('site_settings.webhooks.triggers.new_comment')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_VOTE}>
{I18n.t('site_settings.webhooks.triggers.new_vote')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_USER}>
{I18n.t('site_settings.webhooks.triggers.new_user')}
</option>
</select>
<DangerText>{errors.trigger && getValidationMessage(errors.trigger.type, 'webhook', 'trigger')}</DangerText>
</div>
<div className="formRow">
<div className="formGroup col-3">
<label htmlFor="httpMethod">{ getLabel('webhook', 'http_method') }</label>
<select
{...register('httpMethod')}
id="httpMethod"
className="selectPicker"
>
<option value={WEBHOOK_HTTP_METHOD_POST}>
{I18n.t('site_settings.webhooks.http_methods.post')}
</option>
<option value={WEBHOOK_HTTP_METHOD_PUT}>
{I18n.t('site_settings.webhooks.http_methods.put')}
</option>
<option value={WEBHOOK_HTTP_METHOD_PATCH}>
{I18n.t('site_settings.webhooks.http_methods.patch')}
</option>
<option value={WEBHOOK_HTTP_METHOD_DELETE}>
{I18n.t('site_settings.webhooks.http_methods.delete')}
</option>
</select>
</div>
<div className="formGroup col-9">
<label htmlFor="url">
{ getLabel('webhook', 'url') }
&nbsp;
{ <LiquidIcon /> }
</label>
<input
{...register('url', {
required: true,
pattern: URL_REGEX_WHITESPACE_ALLOWED,
onChange: () => setIsPreviewOutdated(true),
})}
autoComplete="off"
id="url"
className="formControl"
/>
<DangerText>{errors.url?.type === 'required' && getValidationMessage(errors.url.type, 'webhook', 'url')}</DangerText>
<DangerText>{errors.url?.type === 'pattern' && I18n.t('common.validations.url')}</DangerText>
</div>
</div>
<div className="formGroup">
<label htmlFor="httpBody">
{ getLabel('webhook', 'http_body') }
&nbsp;
{ <LiquidIcon /> }
</label>
<textarea
{...register('httpBody', {
onChange: () => setIsPreviewOutdated(true)
})}
ref={(e) => {
register('httpBody').ref(e); // Combine react-hook-form's ref with custom ref
httpBodyTextAreaRef.current = e; // Store a local reference
}}
onClick={handleCursorPosition}
onKeyUp={handleCursorPosition}
id="httpBody"
className="formControl"
/>
<div className="httpBodyActions">
<TemplateVariablesSelector webhookTrigger={trigger} onChange={insertString} />
<ActionLink
icon={<PreviewIcon />}
onClick={async () => {
if ((url === '' && httpBody === '') || !isPreviewOutdated) return;
const res = await fetch(`/webhooks_preview`, {
method: 'PUT',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
webhook: {
trigger: trigger,
url: url,
http_body: httpBody,
}
}),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
setPreviewContent(
getLabel('webhook', 'url') + ":\n" +
json.url_preview + "\n\n" +
getLabel('webhook', 'http_body') + ":\n" +
json.http_body_preview
);
} else {
setPreviewContent(
I18n.t('site_settings.webhooks.form.preview_error') + "\n" +
json.error
)
}
setIsPreviewOutdated(false);
setIsPreviewVisible(true);
}}
disabled={(url === '' && httpBody === '') || !isPreviewOutdated}
customClass="previewHttpBody"
>
{I18n.t('common.buttons.preview')}
</ActionLink>
</div>
{
isPreviewVisible &&
<div className="urlAndHttpBodyPreview">
<label>{ I18n.t('common.buttons.preview') }</label>
<pre id="preview">{previewContent}</pre>
</div>
}
</div>
<div className="formGroup formGroupHttpHeaders">
{
fields.map((field, index) => (
<div className="formRow" key={field.id}>
<div className="formGroup col-5">
<label htmlFor={`httpHeaders${index+1}Key`}>{ I18n.t('site_settings.webhooks.form.header_n_key', { n: index+1 }) }</label>
<input
{...register(`httpHeaders.${index}.key`, { required: (index!==0) })}
id={`httpHeaders${index+1}Key`}
className="formControl"
/>
<DangerText>
{errors.httpHeaders && errors.httpHeaders[index]?.key?.type === 'required' && getValidationMessage(errors.httpHeaders[index]?.key?.type, 'webhook', 'http_headers')}
</DangerText>
</div>
<div className="formGroup col-5">
<label htmlFor={`httpHeaders${index+1}Value`}>{ I18n.t('site_settings.webhooks.form.header_n_value', { n: index+1 }) }</label>
<input
{...register(`httpHeaders.${index}.value`, { required: (index!==0) })}
autoComplete="off"
id={`httpHeaders${index+1}Value`}
className="formControl"
/>
<DangerText>
{errors.httpHeaders && errors.httpHeaders[index]?.value?.type === 'required' && getValidationMessage(errors.httpHeaders[index]?.value?.type, 'webhook', 'http_headers')}
</DangerText>
</div>
<div className="formGroup col-2 deleteHeaderActionLinkContainer">
<ActionLink icon={<DeleteIcon />} onClick={() => remove(index)}>
{I18n.t('common.buttons.delete')}
</ActionLink>
</div>
</div>
))
}
</div>
<ActionLink icon={<AddIcon />} onClick={() => append({ key: "", value: "" })}>
{I18n.t('site_settings.webhooks.form.add_header')}
</ActionLink>
<Button onClick={() => null} type="submit" className="submitWebhookFormButton">
{
isSubmitting ?
<Spinner color="light" />
:
page === 'new' ?
I18n.t('common.buttons.create')
:
I18n.t('common.buttons.update')
}
</Button>
</form>
{ submitError && <p style={{marginTop: '1rem', marginBottom: '0'}}><DangerText>{submitError}</DangerText></p> }
</>
);
}
export default WebhookFormPage;

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { WebhookPages } from './WebhooksSiteSettingsP';
import Box from '../../common/Box';
import { IWebhook } from '../../../interfaces/IWebhook';
import WebhookForm, { ISiteSettingsWebhookFormUpdate } from './WebhookForm';
interface Props {
isSubmitting: boolean;
submitError: string;
handleSubmitWebhook(webhook: IWebhook): void;
handleUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate): void;
selectedWebhook: IWebhook;
page: WebhookPages;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
authenticityToken: string;
}
const WebhookFormPage = ({
isSubmitting,
submitError,
handleSubmitWebhook,
handleUpdateWebhook,
selectedWebhook,
page,
setPage,
authenticityToken,
}: Props) => (
<>
<Box customClass="webhookFormPage">
<WebhookForm
isSubmitting={isSubmitting}
submitError={submitError}
handleSubmitWebhook={handleSubmitWebhook}
handleUpdateWebhook={handleUpdateWebhook}
selectedWebhook={selectedWebhook}
page={page}
setPage={setPage}
authenticityToken={authenticityToken}
/>
</Box>
</>
);
export default WebhookFormPage;

View File

@@ -0,0 +1,88 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { IWebhook } from '../../../interfaces/IWebhook';
import { WebhookPages } from './WebhooksSiteSettingsP';
import Switch from '../../common/Switch';
import ActionLink from '../../common/ActionLink';
import { DeleteIcon, EditIcon, TestIcon } from '../../common/Icons';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
const WEBHOOK_DESCRIPTION_MAX_LENGTH = 100;
interface Props {
webhook: IWebhook;
handleToggleEnabledWebhook: (id: number, enabled: boolean) => void;
handleDeleteWebhook: (id: number) => void;
handleTestWebhook: (id: number) => void;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
}
const WebhookListItem = ({
webhook,
handleToggleEnabledWebhook,
handleDeleteWebhook,
handleTestWebhook,
setSelectedWebhook,
setPage,
}: Props) => (
<li className="webhookListItem">
<div className="webhookInfo">
<div className="webhookNameAndEnabled">
<div className="webhookName">{webhook.name}</div>
{ webhook.description &&
<p className="webhookDescription">
{
webhook.description.length > WEBHOOK_DESCRIPTION_MAX_LENGTH ?
`${webhook.description.slice(0, WEBHOOK_DESCRIPTION_MAX_LENGTH)}...`
:
webhook.description
}
</p>
}
<Switch
label={I18n.t(`common.${webhook.isEnabled ? 'enabled' : 'disabled'}`)}
onClick={() => handleToggleEnabledWebhook(webhook.id, !webhook.isEnabled)}
checked={webhook.isEnabled}
htmlId={`webhook${webhook.name}EnabledSwitch`}
/>
</div>
</div>
<div className="webhookActions">
<ActionLink
onClick={() => handleTestWebhook(webhook.id)}
icon={<TestIcon />}
customClass='testAction'
>
{I18n.t('common.buttons.test')}
</ActionLink>
<ActionLink
onClick={() => {
setSelectedWebhook(webhook.id);
setPage('edit');
}}
icon={<EditIcon />}
customClass="editAction"
>
{I18n.t('common.buttons.edit')}
</ActionLink>
<ActionLink
onClick={() => confirm(I18n.t('common.confirmation')) && handleDeleteWebhook(webhook.id)}
icon={<DeleteIcon />}
customClass="deleteAction"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
</div>
</li>
);
export default WebhookListItem;

View File

@@ -0,0 +1,84 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import { IWebhook } from '../../../interfaces/IWebhook';
import { WebhookPages } from './WebhooksSiteSettingsP';
import ActionLink from '../../common/ActionLink';
import { BackIcon, EditIcon, TestIcon } from '../../common/Icons';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_SUCCESS } from '../../common/Badge';
interface Props {
selectedWebhook: IWebhook;
testHttpCode: number;
testHttpResponse: string;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
handleTestWebhook: (id: number) => void;
}
const WebhookTestPage = ({
selectedWebhook,
testHttpCode,
testHttpResponse,
setSelectedWebhook,
setPage,
handleTestWebhook,
}: Props) => (
<Box customClass="webhookTestPage">
<ActionLink
onClick={() => setPage('index') }
icon={<BackIcon />}
customClass="backButton"
>
{I18n.t('common.buttons.back')}
</ActionLink>
<div className="webhookTestTitle">
<h2>{I18n.t('site_settings.webhooks.test_page.title')}</h2>
</div>
<div className="webhookTestContent">
<div className="webhookTestInfo">
<p>
<b>{I18n.t('activerecord.models.webhook', { count: 1 })}</b>:&nbsp;
<span>{selectedWebhook.name}</span>
</p>
<div className="webhookActions">
<ActionLink
onClick={() => handleTestWebhook(selectedWebhook.id)}
icon={<TestIcon />}
customClass='testAction'
>
{I18n.t('common.buttons.test')}
</ActionLink>
<ActionLink
onClick={() => {
setSelectedWebhook(selectedWebhook.id);
setPage('edit');
}}
icon={<EditIcon />}
customClass="editAction"
>
{I18n.t('common.buttons.edit') + ' ' + I18n.t('activerecord.models.webhook', { count: 1 })}
</ActionLink>
</div>
</div>
<div className="webhookTestResponse">
<Badge type={Array.from({length: 100}, (_, i) => i + 200).includes(testHttpCode) ? BADGE_TYPE_SUCCESS : BADGE_TYPE_DANGER}>
{testHttpCode.toString()}
</Badge>
<pre id="testHttpResponse">{testHttpResponse}</pre>
</div>
</div>
</Box>
);
export default WebhookTestPage;

View File

@@ -0,0 +1,77 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import { WebhooksState } from '../../../reducers/webhooksReducer';
import { WebhookPages } from './WebhooksSiteSettingsP';
import Button from '../../common/Button';
import WebhooksList from './WebhooksList';
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import ActionLink from '../../common/ActionLink';
import { LearnMoreIcon } from '../../common/Icons';
interface Props {
webhooks: WebhooksState;
isSubmitting: boolean;
isTesting: boolean;
submitError: string;
handleToggleEnabledWebhook: (id: number, enabled: boolean) => void;
handleDeleteWebhook: (id: number) => void;
handleTestWebhook: (id: number) => void;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
}
const WebhooksIndexPage = ({
webhooks,
isSubmitting,
isTesting,
submitError,
handleToggleEnabledWebhook,
handleDeleteWebhook,
handleTestWebhook,
setSelectedWebhook,
setPage,
}: Props) => {
return (
<>
<Box customClass="webhooksIndexPage">
<div className="webhooksTitle">
<h2>{I18n.t('site_settings.webhooks.title')}</h2>
<Button onClick={() => setPage('new')}>
{ I18n.t('common.buttons.new') }
</Button>
</div>
<p style={{textAlign: 'left'}}>
<ActionLink
onClick={() => window.open('https://docs.astuto.io/webhooks/webhooks-introduction/', '_blank')}
icon={<LearnMoreIcon />}
>
{I18n.t('site_settings.webhooks.learn_more')}
</ActionLink>
</p>
<WebhooksList
webhooks={webhooks.items}
webhooksAreLoading={webhooks.areLoading}
handleToggleEnabledWebhook={handleToggleEnabledWebhook}
handleDeleteWebhook={handleDeleteWebhook}
handleTestWebhook={handleTestWebhook}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
/>
</Box>
<SiteSettingsInfoBox
areUpdating={webhooks.areLoading || isSubmitting || isTesting}
error={webhooks.error || submitError}
/>
</>
);
};
export default WebhooksIndexPage;

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { WebhookPages } from './WebhooksSiteSettingsP';
import { IWebhook } from '../../../interfaces/IWebhook';
import WebhookListItem from './WebhookListItem';
import { CenteredMutedText } from '../../common/CustomTexts';
import Spinner from '../../common/Spinner';
interface Props {
webhooks: Array<IWebhook>;
webhooksAreLoading: boolean;
handleToggleEnabledWebhook: (id: number, enabled: boolean) => void;
handleDeleteWebhook: (id: number) => void;
handleTestWebhook: (id: number) => void;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
}
const WebhooksList = ({
webhooks,
webhooksAreLoading,
handleToggleEnabledWebhook,
handleDeleteWebhook,
handleTestWebhook,
setSelectedWebhook,
setPage,
}: Props) => {
// from webhooks, get a unique list of triggers
const triggers = Array.from(new Set(webhooks.map(webhook => webhook.trigger)));
if (webhooksAreLoading) return <Spinner />;
return (
<div className="webhooksList">
{
(webhooks && webhooks.length > 0) ?
triggers.map((trigger, i) => (
<div key={i}>
<h4>{I18n.t(`site_settings.webhooks.triggers.${trigger}`)}</h4>
<ul>
{
webhooks.filter(webhook => webhook.trigger === trigger).map((webhook, j) => (
<WebhookListItem
webhook={webhook}
handleToggleEnabledWebhook={handleToggleEnabledWebhook}
handleDeleteWebhook={handleDeleteWebhook}
handleTestWebhook={handleTestWebhook}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
key={j}
/>
))
}
</ul>
</div>
))
:
<CenteredMutedText>{I18n.t('site_settings.webhooks.empty')}</CenteredMutedText>
}
</div>
);
};
export default WebhooksList;

View File

@@ -0,0 +1,124 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { WebhooksState } from '../../../reducers/webhooksReducer';
import WebhooksIndexPage from './WebhooksIndexPage';
import WebhookFormPage from './WebhookFormPage';
import { IWebhook } from '../../../interfaces/IWebhook';
import HttpStatus from '../../../constants/http_status';
import { ISiteSettingsWebhookFormUpdate } from './WebhookForm';
import WebhookTestPage from './WebhookTestPage';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
interface Props {
webhooks: WebhooksState;
isSubmitting: boolean;
submitError: string;
requestWebhooks(): void;
onSubmitWebhook(webhook: IWebhook, authenticityToken: string): Promise<any>;
onUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate, authenticityToken: string): Promise<any>;
onToggleEnabledWebhook(id: number, isEnabled: boolean, authenticityToken: string): Promise<any>;
onDeleteWebhook(id: number, authenticityToken: string): void;
authenticityToken: string;
}
export type WebhookPages = 'index' | 'new' | 'edit' | 'test';
const WebhooksSiteSettingsP = ({
webhooks,
isSubmitting,
submitError,
requestWebhooks,
onSubmitWebhook,
onUpdateWebhook,
onToggleEnabledWebhook,
onDeleteWebhook,
authenticityToken,
}: Props) => {
const [page, setPage] = useState<WebhookPages>('index');
const [selectedWebhook, setSelectedWebhook] = useState<number>(null);
const [isTesting, setIsTesting] = useState<boolean>(false);
const [testHttpCode, setTestHttpCode] = useState<number>(null);
const [testHttpResponse, setTestHttpResponse] = useState<string>(null);
useEffect(requestWebhooks, []);
const handleSubmitWebhook = (webhook: IWebhook) => {
onSubmitWebhook(webhook, authenticityToken).then(res => {
if (res?.status === HttpStatus.Created) window.location.reload();
});
};
const handleUpdateWebhook = (id: number, form: ISiteSettingsWebhookFormUpdate) => {
onUpdateWebhook(id, form, authenticityToken).then(res => {
if (res?.status === HttpStatus.OK) window.location.reload();
});
};
const handleToggleEnabledWebhook = (id: number, enabled: boolean) => {
onToggleEnabledWebhook(id, enabled, authenticityToken).then(res => {
if (res?.status === HttpStatus.OK) window.location.reload();
});
};
const handleDeleteWebhook = (id: number) => {
onDeleteWebhook(id, authenticityToken);
};
const handleTestWebhook = async (id: number) => {
setIsTesting(true);
const res = await fetch(`/webhooks/${id}/test`, {
method: 'PUT',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
setTestHttpCode(res.status);
setTestHttpResponse(JSON.stringify(json, null, 2));
setSelectedWebhook(id);
setPage('test');
setIsTesting(false);
};
return (
page === 'index' ?
<WebhooksIndexPage
webhooks={webhooks}
isSubmitting={isSubmitting}
isTesting={isTesting}
submitError={submitError}
handleToggleEnabledWebhook={handleToggleEnabledWebhook}
handleDeleteWebhook={handleDeleteWebhook}
handleTestWebhook={handleTestWebhook}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
/>
:
(page === 'new' || page === 'edit') ?
<WebhookFormPage
isSubmitting={isSubmitting}
submitError={submitError}
handleSubmitWebhook={handleSubmitWebhook}
handleUpdateWebhook={handleUpdateWebhook}
selectedWebhook={webhooks.items.find(webhook => webhook.id === selectedWebhook)}
page={page}
setPage={setPage}
authenticityToken={authenticityToken}
/>
:
<WebhookTestPage
selectedWebhook={webhooks.items.find(webhook => webhook.id === selectedWebhook)}
testHttpCode={testHttpCode}
testHttpResponse={testHttpResponse}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
handleTestWebhook={handleTestWebhook}
/>
);
};
export default WebhooksSiteSettingsP;

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import createStoreHelper from '../../../helpers/createStore';
import { State } from '../../../reducers/rootReducer';
import WebhooksSiteSettings from '../../../containers/WebhooksSiteSettings';
interface Props {
authenticityToken: string;
}
class WebhooksSiteSettingsRoot extends React.Component<Props> {
store: Store<State, any>;
constructor(props: Props) {
super(props);
this.store = createStoreHelper();
}
render() {
return (
<Provider store={this.store}>
<WebhooksSiteSettings
authenticityToken={this.props.authenticityToken}
/>
</Provider>
);
}
}
export default WebhooksSiteSettingsRoot;

View File

@@ -3,11 +3,13 @@ import * as React from 'react';
export const BADGE_TYPE_LIGHT = 'badgeLight';
export const BADGE_TYPE_WARNING = 'badgeWarning';
export const BADGE_TYPE_DANGER = 'badgeDanger';
export const BADGE_TYPE_SUCCESS = 'badgeSuccess';
export type BadgeTypes =
typeof BADGE_TYPE_LIGHT |
typeof BADGE_TYPE_WARNING |
typeof BADGE_TYPE_DANGER;
typeof BADGE_TYPE_DANGER |
typeof BADGE_TYPE_SUCCESS;
interface Props {
type: BadgeTypes;

View File

@@ -1,11 +1,12 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { Tooltip } from 'react-tooltip'
import { BsReply } from 'react-icons/bs';
import { FiEdit, FiDelete, FiSettings } from 'react-icons/fi';
import { ImCancelCircle } from 'react-icons/im';
import { TbLock, TbLockOpen } from 'react-icons/tb';
import { GrTest, GrClearOption } from 'react-icons/gr';
import { GrTest, GrClearOption, GrOverview } from 'react-icons/gr';
import { BiLike, BiSolidLike } from "react-icons/bi";
import {
MdContentCopy,
@@ -15,8 +16,10 @@ import {
MdVerified,
MdCheck,
MdClear,
MdAdd,
} from 'react-icons/md';
import { FaUserNinja } from "react-icons/fa";
import { FaUserNinja, FaMarkdown } from "react-icons/fa";
import { FaDroplet } from "react-icons/fa6";
export const EditIcon = () => <FiEdit />;
@@ -41,9 +44,12 @@ export const ReplyIcon = () => <BsReply />;
export const LearnMoreIcon = () => <MdOutlineLibraryBooks />;
export const StaffIcon = () => (
<span title={I18n.t('common.user_staff')} className="staffIcon">
<>
<span data-tooltip-id="staff-tooltip" data-tooltip-content={I18n.t('common.user_staff')} className="staffIcon">
<MdVerified />
</span>
<Tooltip id="staff-tooltip" />
</>
);
export const ClearIcon = () => <GrClearOption />;
@@ -54,8 +60,50 @@ export const SolidLikeIcon = ({size = 32}) => <BiSolidLike size={size} />;
export const SettingsIcon = () => <FiSettings />;
export const AnonymousIcon = ({size = 32, title=I18n.t('defaults.user_full_name')}) => <FaUserNinja size={size} title={title} />;
export const AnonymousIcon = ({size = 32}) => (
<>
<span data-tooltip-id="anonymous-tooltip" data-tooltip-content={I18n.t('defaults.user_full_name')} className="anonymousIcon">
<FaUserNinja size={size} />
</span>
<Tooltip id="anonymous-tooltip" />
</>
);
export const ApproveIcon = () => <MdCheck />;
export const RejectIcon = () => <MdClear />;
export const RejectIcon = () => <MdClear />;
export const AddIcon = () => <MdAdd />;
export const PreviewIcon = ({size = 24}) => <GrOverview size={size} />;
export const LiquidIcon = ({size = 18}) => (
<>
<a href="https://shopify.github.io/liquid/" target="_blank" rel="noreferrer" className="link">
<span
data-tooltip-id="liquid-tooltip"
data-tooltip-content={I18n.t('common.language_supported', { language: 'Liquid' })}
className="liquidIcon"
>
<FaDroplet size={size} />
</span>
</a>
<Tooltip id="liquid-tooltip" />
</>
);
export const MarkdownIcon = ({size = 24, style = {}}) => (
<>
<a href="https://www.markdownguide.org/basic-syntax/" target="_blank" rel="noreferrer" className="link">
<span
data-tooltip-id="markdown-tooltip"
data-tooltip-content={I18n.t('common.language_supported', { language: 'Markdown' })}
style={{...style, ...{opacity: 0.75}}}
className="markdownIcon"
>
<FaMarkdown size={size} />
</span>
</a>
<Tooltip id="markdown-tooltip" />
</>
);

View File

@@ -1,2 +1,3 @@
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/;
export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/;
export const URL_REGEX_WHITESPACE_ALLOWED = /^(ftp|http|https):\/\/[^"]+$/;

View File

@@ -0,0 +1,44 @@
import { connect } from "react-redux";
import WebhooksSiteSettingsP from "../components/SiteSettings/Webhooks/WebhooksSiteSettingsP";
import { State } from "../reducers/rootReducer";
import { requestWebhooks } from "../actions/Webhook/requestWebhooks";
import { IWebhook } from "../interfaces/IWebhook";
import { submitWebhook } from "../actions/Webhook/submitWebhook";
import { deleteWebhook } from "../actions/Webhook/deleteWebhook";
import { ISiteSettingsWebhookFormUpdate } from "../components/SiteSettings/Webhooks/WebhookForm";
import { updateWebhook } from "../actions/Webhook/updateWebhook";
const mapStateToProps = (state: State) => ({
webhooks: state.webhooks,
isSubmitting: state.siteSettings.webhooks.isSubmitting,
submitError: state.siteSettings.webhooks.error,
});
const mapDispatchToProps = (dispatch: any) => ({
requestWebhooks() {
dispatch(requestWebhooks());
},
onSubmitWebhook(webhook: IWebhook, authenticityToken: string): Promise<any> {
return dispatch(submitWebhook(webhook, authenticityToken));
},
onUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate, authenticityToken: string): Promise<any> {
return dispatch(updateWebhook({id, form, authenticityToken}));
},
onToggleEnabledWebhook(id: number, isEnabled: boolean, authenticityToken: string): Promise<any> {
return dispatch(updateWebhook({id, isEnabled, authenticityToken}));
},
onDeleteWebhook(id: number, authenticityToken: string) {
dispatch(deleteWebhook(id, authenticityToken));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps,
)(WebhooksSiteSettingsP);

View File

@@ -0,0 +1,77 @@
// Trigger
export const WEBHOOK_TRIGGER_NEW_POST = 'new_post';
export const WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL = 'new_post_pending_approval';
export const WEBHOOK_TRIGGER_DELETED_POST = 'delete_post';
export const WEBHOOK_TRIGGER_POST_STATUS_CHANGE = 'post_status_change';
export const WEBHOOK_TRIGGER_NEW_COMMENT = 'new_comment';
export const WEBHOOK_TRIGGER_NEW_VOTE = 'new_vote';
export const WEBHOOK_TRIGGER_NEW_USER = 'new_user';
export type WebhookTrigger =
typeof WEBHOOK_TRIGGER_NEW_POST |
typeof WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL |
typeof WEBHOOK_TRIGGER_DELETED_POST |
typeof WEBHOOK_TRIGGER_POST_STATUS_CHANGE |
typeof WEBHOOK_TRIGGER_NEW_COMMENT |
typeof WEBHOOK_TRIGGER_NEW_VOTE |
typeof WEBHOOK_TRIGGER_NEW_USER;
// HTTP method
export const WEBHOOK_HTTP_METHOD_POST = 'http_post';
export const WEBHOOK_HTTP_METHOD_PUT = 'http_put';
export const WEBHOOK_HTTP_METHOD_PATCH = 'http_patch';
export const WEBHOOK_HTTP_METHOD_DELETE = 'http_delete';
export type WebhookHttpMethod =
typeof WEBHOOK_HTTP_METHOD_POST |
typeof WEBHOOK_HTTP_METHOD_PUT |
typeof WEBHOOK_HTTP_METHOD_PATCH |
typeof WEBHOOK_HTTP_METHOD_DELETE;
export interface IWebhook {
id?: number;
name: string;
description?: string;
isEnabled: boolean;
trigger: WebhookTrigger;
url: string;
httpBody: string;
httpMethod: WebhookHttpMethod;
httpHeaders: string;
}
export interface IWebhookJSON {
id: string;
name: string;
description?: string;
is_enabled: boolean;
trigger: WebhookTrigger;
url: string;
http_body: string;
http_method: WebhookHttpMethod;
http_headers: string;
}
export const webhookJSON2JS = (webhookJSON: IWebhookJSON): IWebhook => ({
id: parseInt(webhookJSON.id),
name: webhookJSON.name,
description: webhookJSON.description,
isEnabled: webhookJSON.is_enabled,
trigger: webhookJSON.trigger,
url: webhookJSON.url,
httpBody: webhookJSON.http_body,
httpMethod: webhookJSON.http_method,
httpHeaders: webhookJSON.http_headers,
});
export const webhookJS2JSON = (webhook: IWebhook) => ({
id: webhook.id?.toString(),
name: webhook.name,
description: webhook.description,
is_enabled: webhook.isEnabled,
trigger: webhook.trigger,
url: webhook.url,
http_body: webhook.httpBody,
http_method: webhook.httpMethod,
http_headers: webhook.httpHeaders,
});

View File

@@ -0,0 +1,71 @@
import {
WebhookSubmitActionTypes,
WEBHOOK_SUBMIT_START,
WEBHOOK_SUBMIT_SUCCESS,
WEBHOOK_SUBMIT_FAILURE,
} from '../../actions/Webhook/submitWebhook';
import {
WebhookUpdateActionTypes,
WEBHOOK_UPDATE_START,
WEBHOOK_UPDATE_SUCCESS,
WEBHOOK_UPDATE_FAILURE,
} from '../../actions/Webhook/updateWebhook';
import {
WebhookDeleteActionTypes,
WEBHOOK_DELETE_FAILURE,
WEBHOOK_DELETE_START,
WEBHOOK_DELETE_SUCCESS,
} from '../../actions/Webhook/deleteWebhook';
export interface SiteSettingsWebhooksState {
isSubmitting: boolean;
error: string;
}
const initialState: SiteSettingsWebhooksState = {
isSubmitting: false,
error: '',
};
const siteSettingsWebhooksReducer = (
state = initialState,
action:
WebhookSubmitActionTypes |
WebhookUpdateActionTypes |
WebhookDeleteActionTypes
) => {
switch (action.type) {
case WEBHOOK_SUBMIT_START:
case WEBHOOK_UPDATE_START:
case WEBHOOK_DELETE_START:
return {
...state,
isSubmitting: true,
};
case WEBHOOK_SUBMIT_SUCCESS:
case WEBHOOK_UPDATE_SUCCESS:
case WEBHOOK_DELETE_SUCCESS:
return {
...state,
isSubmitting: false,
error: '',
};
case WEBHOOK_SUBMIT_FAILURE:
case WEBHOOK_UPDATE_FAILURE:
case WEBHOOK_DELETE_FAILURE:
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
};
export default siteSettingsWebhooksReducer;

View File

@@ -8,6 +8,7 @@ import postStatusesReducer from './postStatusesReducer';
import usersReducer from './usersReducer';
import currentPostReducer from './currentPostReducer';
import oAuthsReducer from './oAuthsReducer';
import webhooksReducer from './webhooksReducer';
import siteSettingsReducer from './siteSettingsReducer';
import moderationReducer from './moderationReducer';
@@ -20,6 +21,7 @@ const rootReducer = combineReducers({
users: usersReducer,
currentPost: currentPostReducer,
oAuths: oAuthsReducer,
webhooks: webhooksReducer,
siteSettings: siteSettingsReducer,
moderation: moderationReducer,

View File

@@ -82,12 +82,34 @@ import {
OAUTH_DELETE_FAILURE,
} from '../actions/OAuth/deleteOAuth';
import {
WebhookSubmitActionTypes,
WEBHOOK_SUBMIT_FAILURE,
WEBHOOK_SUBMIT_START,
WEBHOOK_SUBMIT_SUCCESS,
} from '../actions/Webhook/submitWebhook';
import {
WebhookUpdateActionTypes,
WEBHOOK_UPDATE_START,
WEBHOOK_UPDATE_SUCCESS,
WEBHOOK_UPDATE_FAILURE,
} from '../actions/Webhook/updateWebhook';
import {
WebhookDeleteActionTypes,
WEBHOOK_DELETE_FAILURE,
WEBHOOK_DELETE_START,
WEBHOOK_DELETE_SUCCESS,
} from '../actions/Webhook/deleteWebhook';
import siteSettingsGeneralReducer, { SiteSettingsGeneralState } from './SiteSettings/generalReducer';
import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer';
import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer';
import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer';
import siteSettingsAuthenticationReducer, { SiteSettingsAuthenticationState } from './SiteSettings/authenticationReducer';
import siteSettingsAppearanceReducer, { SiteSettingsAppearanceState } from './SiteSettings/appearanceReducer';
import siteSettingsWebhooksReducer, { SiteSettingsWebhooksState } from './SiteSettings/webhooksReducer';
interface SiteSettingsState {
general: SiteSettingsGeneralState;
@@ -95,6 +117,7 @@ interface SiteSettingsState {
boards: SiteSettingsBoardsState;
postStatuses: SiteSettingsPostStatusesState;
roadmap: SiteSettingsRoadmapState;
webhooks: SiteSettingsWebhooksState;
appearance: SiteSettingsAppearanceState;
}
@@ -104,6 +127,7 @@ const initialState: SiteSettingsState = {
boards: siteSettingsBoardsReducer(undefined, {} as any),
postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any),
roadmap: siteSettingsRoadmapReducer(undefined, {} as any),
webhooks: siteSettingsWebhooksReducer(undefined, {} as any),
appearance: siteSettingsAppearanceReducer(undefined, {} as any),
};
@@ -121,7 +145,10 @@ const siteSettingsReducer = (
PostStatusOrderUpdateActionTypes |
PostStatusDeleteActionTypes |
PostStatusSubmitActionTypes |
PostStatusUpdateActionTypes
PostStatusUpdateActionTypes |
WebhookSubmitActionTypes |
WebhookUpdateActionTypes |
WebhookDeleteActionTypes
): SiteSettingsState => {
switch (action.type) {
case TENANT_UPDATE_START:
@@ -187,6 +214,20 @@ const siteSettingsReducer = (
roadmap: siteSettingsRoadmapReducer(state.roadmap, action),
};
case WEBHOOK_SUBMIT_START:
case WEBHOOK_SUBMIT_SUCCESS:
case WEBHOOK_SUBMIT_FAILURE:
case WEBHOOK_UPDATE_START:
case WEBHOOK_UPDATE_SUCCESS:
case WEBHOOK_UPDATE_FAILURE:
case WEBHOOK_DELETE_START:
case WEBHOOK_DELETE_SUCCESS:
case WEBHOOK_DELETE_FAILURE:
return {
...state,
webhooks: siteSettingsWebhooksReducer(state.webhooks, action),
};
default:
return state;
}

View File

@@ -0,0 +1,93 @@
import {
WebhooksRequestActionTypes,
WEBHOOKS_REQUEST_START,
WEBHOOKS_REQUEST_SUCCESS,
WEBHOOKS_REQUEST_FAILURE,
} from '../actions/Webhook/requestWebhooks';
import {
WebhookSubmitActionTypes,
WEBHOOK_SUBMIT_SUCCESS,
} from '../actions/Webhook/submitWebhook';
import {
WebhookUpdateActionTypes,
WEBHOOK_UPDATE_SUCCESS,
} from '../actions/Webhook/updateWebhook';
import {
WebhookDeleteActionTypes,
WEBHOOK_DELETE_SUCCESS,
} from '../actions/Webhook/deleteWebhook';
import { IWebhook, webhookJSON2JS } from '../interfaces/IWebhook';
export interface WebhooksState {
items: Array<IWebhook>;
areLoading: boolean;
error: string;
}
const initialState: WebhooksState = {
items: [],
areLoading: true,
error: '',
};
const webhooksReducer = (
state = initialState,
action:
WebhooksRequestActionTypes |
WebhookSubmitActionTypes |
WebhookUpdateActionTypes |
WebhookDeleteActionTypes
) => {
switch (action.type) {
case WEBHOOKS_REQUEST_START:
return {
...state,
areLoading: true,
};
case WEBHOOKS_REQUEST_SUCCESS:
return {
...state,
areLoading: false,
error: '',
items: action.webhooks.map<IWebhook>(webhookJson => webhookJSON2JS(webhookJson)),
};
case WEBHOOKS_REQUEST_FAILURE:
return {
...state,
areLoading: false,
error: action.error,
};
case WEBHOOK_SUBMIT_SUCCESS:
return {
...state,
items: [...state.items, webhookJSON2JS(action.webhook)],
};
case WEBHOOK_UPDATE_SUCCESS:
return {
...state,
items: state.items.map(webhook => {
if (webhook.id !== parseInt(action.webhook.id)) return webhook;
return webhookJSON2JS(action.webhook);
}),
}
case WEBHOOK_DELETE_SUCCESS:
return {
...state,
items: state.items.filter(webhook => webhook.id !== action.id),
};
default:
return state;
}
};
export default webhooksReducer;

105
app/jobs/run_webhook.rb Normal file
View File

@@ -0,0 +1,105 @@
class RunWebhook < ActiveJob::Base
queue_as :webhooks
# entities is a hash with entity_name as key and entity_id as value (entity_name will be mapped to an ActiveRecord class)
def perform(webhook_id:, current_tenant_id:, is_test: false, entities: {})
Current.tenant = Tenant.find(current_tenant_id)
logger.info { "[#{Current.tenant.subdomain}] Performing RunWebhook ActiveJob for webhook ID #{webhook_id}" }
# Find webhook from DB
webhook = Webhook.find(webhook_id)
# Skip if webhook is disabled and is not a test
return if !is_test && !webhook.is_enabled
# Load entities from DB
loaded_entities = {}
entities.each do |entity_name, entity_id|
entity_class = map_entity_name_to_class(entity_name)
# If there is an ActiveRecord class for that entity_name, load it from DB
# Otherwise, just pass the ID (this is the special case of trigger 'delete_post')
if entity_class
loaded_entities[entity_name] = entity_class.find(entity_id)
else
loaded_entities[entity_name] = entity_id
end
end
# Build context based on webhook's trigger
context = CreateLiquidTemplateContextWorkflow.new(
webhook_trigger: webhook.trigger,
is_test: is_test,
entities: loaded_entities,
).run
# Parse and render template for webhook's URL
url_template = Liquid::Template.parse(webhook.url)
url = url_template.render(context)
# Parse and render template for webhook's HTTP body
http_body_template = Liquid::Template.parse(webhook.http_body)
http_body = http_body_template.render(context)
# Prepare HTTP body
if webhook.http_body.present?
http_body = JSON.parse(http_body).to_json
else
http_body = nil
end
# Prepare HTTP headers
if webhook.http_headers.present?
http_headers = JSON.parse(webhook.http_headers).each_with_object({}) do |header, memo|
memo[header['key']] = header['value']
end
else
http_headers = {}
end
# Make HTTP request
HTTParty.send(
map_webhook_http_method(webhook.http_method).downcase,
url,
{
body: http_body,
headers: http_headers,
}
)
end
private
def map_webhook_http_method(http_method)
case http_method
when :http_post
'POST'
when :http_put
'PUT'
when :http_patch
'PATCH'
when :http_delete
'DELETE'
else
'POST'
end
end
def map_entity_name_to_class(entity_name)
case entity_name
when :post
Post
when :user, :post_author, :comment_author, :vote_author
User
when :board
Board
when :post_status
PostStatus
when :comment
Comment
else
nil
end
end
end

View File

@@ -0,0 +1,7 @@
module CustomLiquidFilters
require 'json'
def escape_json(input)
input.to_json[1...-1] # Converts to JSON string and removes surrounding quotes
end
end

View File

@@ -6,5 +6,27 @@ class Comment < ApplicationRecord
belongs_to :parent, class_name: 'Comment', optional: true
has_many :children, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy
after_create :run_webhooks
validates :body, presence: true
private
def run_webhooks
entities = {
comment: self.id,
comment_author: self.user.id,
post: self.post.id,
board: self.post.board.id
}
entities[:post_author] = self.post.user.id if self.post.user_id
Webhook.where(trigger: :new_comment, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

View File

@@ -4,5 +4,26 @@ class Like < ApplicationRecord
belongs_to :user
belongs_to :post
after_create :run_webhooks
validates :user_id, uniqueness: { scope: :post_id }
private
def run_webhooks
entities = {
vote_author: self.user.id,
post: self.post.id,
board: self.post.board.id
}
entities[:post_author] = self.post.user.id if self.post.user_id
Webhook.where(trigger: :new_vote, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

View File

@@ -1,5 +1,7 @@
class Post < ApplicationRecord
include TenantOwnable
include ApplicationHelper
include Rails.application.routes.url_helpers
extend FriendlyId
belongs_to :board
@@ -12,6 +14,9 @@ class Post < ApplicationRecord
has_many :comments, dependent: :destroy
has_many :post_status_changes, dependent: :destroy
after_create :run_new_post_webhooks
after_destroy :run_delete_post_webhooks
enum approval_status: [
:approved,
:pending,
@@ -24,6 +29,10 @@ class Post < ApplicationRecord
friendly_id :title, use: :scoped, scope: :tenant_id
def url
get_url_for(method(:post_url), resource: self)
end
class << self
def find_with_post_status_in(post_statuses)
where(post_status_id: post_statuses.pluck(:id))
@@ -54,4 +63,50 @@ class Post < ApplicationRecord
where(approval_status: "pending")
end
end
private
def run_new_post_webhooks
entities = {
post: self.id,
board: self.board.id
}
entities[:post_author] = self.user.id if self.user_id
# New post (approved)
if self.approval_status == 'approved'
Webhook.where(trigger: :new_post, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
# New post (pending approval)
if self.approval_status == 'pending'
Webhook.where(trigger: :new_post_pending_approval, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end
def run_delete_post_webhooks
# Since the post has already been deleted from DB
# we only provide its ID
entities = { post_id: self.id }
Webhook.where(trigger: :delete_post, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

View File

@@ -4,4 +4,25 @@ class PostStatusChange < ApplicationRecord
belongs_to :user
belongs_to :post
belongs_to :post_status, optional: true
after_create :run_webhooks
private
def run_webhooks
entities = {
post: self.post.id,
board: self.post.board.id
}
entities[:post_author] = self.post.user.id if self.post.user_id
entities[:post_status] = self.post_status.id if self.post_status_id
Webhook.where(trigger: :post_status_change, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

View File

@@ -19,6 +19,7 @@ class User < ApplicationRecord
after_initialize :set_default_role, if: :new_record?
after_initialize :set_default_status, if: :new_record?
after_create :run_webhooks
validates :full_name, presence: true, length: { in: 2..64 }
validates :email,
@@ -107,4 +108,20 @@ class User < ApplicationRecord
self.oauth_token = nil
self.save!
end
private
def run_webhooks
entities = {
user: self.id
}
Webhook.where(trigger: :new_user, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

76
app/models/webhook.rb Normal file
View File

@@ -0,0 +1,76 @@
class Webhook < ApplicationRecord
include TenantOwnable
before_save :encrypt_url
after_find :decrypt_url
before_save :encrypt_http_headers
after_find :decrypt_http_headers
validates :name, presence: true, uniqueness: { scope: :tenant_id }, length: { maximum: 255 }
validates :url, presence: true, format: { with: URI::regexp(%w(http https)), message: I18n.t('common.validations.url') }
validates :trigger, presence: true
validates :http_method, presence: true
enum trigger: [
:new_post,
:new_post_pending_approval,
:delete_post,
:post_status_change,
:new_comment,
:new_vote,
:new_user
]
enum http_method: [
:http_post,
:http_put,
:http_patch,
:http_delete
]
private
def encrypt_url
return if url.nil?
# Derive a 32-byte key from the secret_key_base
key = Digest::SHA256.digest(Rails.application.secrets.secret_key_base)
encryptor = ActiveSupport::MessageEncryptor.new(key)
self.url = encryptor.encrypt_and_sign(url)
end
def decrypt_url
return if url.nil?
# Derive a 32-byte key from the secret_key_base
key = Digest::SHA256.digest(Rails.application.secrets.secret_key_base)
encryptor = ActiveSupport::MessageEncryptor.new(key)
self.url = encryptor.decrypt_and_verify(url)
rescue ActiveSupport::MessageVerifier::InvalidSignature
errors.add(:url, 'could not be decrypted')
end
def encrypt_http_headers
return if http_headers.nil?
# Derive a 32-byte key from the secret_key_base
key = Digest::SHA256.digest(Rails.application.secrets.secret_key_base)
encryptor = ActiveSupport::MessageEncryptor.new(key)
self.http_headers = encryptor.encrypt_and_sign(http_headers.to_json)
end
def decrypt_http_headers
return if http_headers.nil?
# Derive a 32-byte key from the secret_key_base
key = Digest::SHA256.digest(Rails.application.secrets.secret_key_base)
encryptor = ActiveSupport::MessageEncryptor.new(key)
self.http_headers = JSON.parse(encryptor.decrypt_and_verify(http_headers)) if http_headers.present?
rescue ActiveSupport::MessageVerifier::InvalidSignature
errors.add(:http_headers, 'could not be decrypted')
end
end

View File

@@ -0,0 +1,34 @@
class WebhookPolicy < ApplicationPolicy
def permitted_attributes
if user.admin?
[
:name,
:description,
:is_enabled,
:trigger,
:url,
:http_body,
:http_method,
:http_headers
]
else
[]
end
end
def index?
user.admin?
end
def create?
user.admin?
end
def update?
user.admin?
end
def destroy?
user.admin?
end
end

View File

@@ -8,6 +8,7 @@
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.boards'), path: site_settings_boards_path %>
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.post_statuses'), path: site_settings_post_statuses_path %>
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.roadmap'), path: site_settings_roadmap_path %>
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.webhooks'), path: site_settings_webhooks_path %>
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.invitations'), path: site_settings_invitations_path %>
<%= render 'shared/sidebar_menu_link', label: t('site_settings.menu.appearance'), path: site_settings_appearance_path %>
</div>

View File

@@ -0,0 +1,13 @@
<div class="twoColumnsContainer">
<%= render 'menu' %>
<div>
<%=
react_component(
'SiteSettings/Webhooks',
{
authenticityToken: form_authenticity_token
}
)
%>
</div>
</div>

View File

@@ -0,0 +1,159 @@
class CreateLiquidTemplateContextWorkflow
TENANT_ATTRIBUTES = [:site_name, :subdomain, :custom_domain].freeze
BOARD_ATTRIBUTES = [:id, :name, :description, :slug, :created_at, :updated_at].freeze
POST_STATUS_ATTRIBUTES = [:id, :name, :color, :show_in_roadmap, :created_at, :updated_at].freeze
USER_ATTRIBUTES = [:id, :email, :full_name, :role, :status, :created_at, :updated_at].freeze
POST_ATTRIBUTES = [:id, :title, :description, :slug, :board_id, :user_id, :created_at, :updated_at].freeze
POST_VIRTUAL_ATTRIBUTES = [:url].freeze
COMMENT_ATTRIBUTES = [:id, :body, :user_id, :post_id, :created_at, :updated_at].freeze
attr_accessor :webhook_trigger, :is_test, :entities
def initialize(webhook_trigger: "new_post", is_test: false, entities: {})
@webhook_trigger = webhook_trigger
@entities = entities
if is_test
board_test_entity = Board.new(
id: 0,
name: 'Example board for webhook testing',
description: 'This is just an example board for testing webhooks out! Do not worry: this board is not saved anywhere in your feedback space ;)',
slug: 'example-board',
created_at: Time.now,
updated_at: Time.now,
)
post_status_test_entity = PostStatus.new(
id: 0,
name: 'Example post status',
color: '#000000',
show_in_roadmap: true,
created_at: Time.now,
updated_at: Time.now,
)
user_test_entity = User.new(
id: 0,
email: 'user@example.com',
full_name: 'Test User',
role: 'user',
status: 'active',
created_at: Time.now,
updated_at: Time.now,
)
post_author_test_entity = User.new(
id: 0,
email: 'user1@example.com',
full_name: 'Test User Post Author',
role: 'user',
status: 'active',
created_at: Time.now,
updated_at: Time.now,
)
comment_author_test_entity = User.new(
id: 0,
email: 'user2@example.com',
full_name: 'Test User Comment Author',
role: 'user',
status: 'active',
created_at: Time.now,
updated_at: Time.now,
)
vote_author_test_entity = User.new(
id: 0,
email: 'user3@example.com',
full_name: 'Test User Vote Author',
role: 'user',
status: 'active',
created_at: Time.now,
updated_at: Time.now,
)
post_test_entity = Post.new(
id: 0,
title: 'Example post for webhook testing',
description: 'This is just an example post for testing webhooks out! Do not worry: this post is not saved anywhere in your feedback space ;)',
slug: 'example-post',
board_id: 0,
user_id: 0,
created_at: Time.now,
updated_at: Time.now,
)
comment_test_entity = Comment.new(
id: 0,
body: 'This is just an example comment for testing webhooks out!',
user_id: 0,
post_id: 0,
created_at: Time.now,
updated_at: Time.now,
)
@entities = {
board: board_test_entity,
post_status: post_status_test_entity,
user: user_test_entity,
post_author: post_author_test_entity,
comment_author: comment_author_test_entity,
vote_author: vote_author_test_entity,
post: post_test_entity,
comment: comment_test_entity,
# for delete_post trigger
post_id: 0,
}
end
end
def run
tenant = Current.tenant_or_raise!
context = {}
# Add general context variables
context['tenant'] = tenant.as_json(only: TENANT_ATTRIBUTES)
# Add context variables specific to the webhook trigger
# To keep in sync with app/javascript/components/SiteSettings/Webhooks/TemplateVariablesSelector.tsx
case webhook_trigger
when 'new_post'
context['post'] = @entities[:post].as_json(only: POST_ATTRIBUTES, methods: POST_VIRTUAL_ATTRIBUTES)
context['post_author'] = @entities.key?(:post_author) ? @entities[:post_author].as_json(only: USER_ATTRIBUTES) : nil
context['board'] = @entities[:board].as_json(only: BOARD_ATTRIBUTES)
when 'new_post_pending_approval'
context['post'] = @entities[:post].as_json(only: POST_ATTRIBUTES, methods: POST_VIRTUAL_ATTRIBUTES)
context['post_author'] = @entities.key?(:post_author) ? @entities[:post_author].as_json(only: USER_ATTRIBUTES) : nil
context['board'] = @entities[:board].as_json(only: BOARD_ATTRIBUTES)
when 'delete_post'
context['post'] = { id: @entities[:post_id] }.as_json
when 'post_status_change'
context['post'] = @entities[:post].as_json(only: POST_ATTRIBUTES, methods: POST_VIRTUAL_ATTRIBUTES)
context['post_author'] = @entities.key?(:post_author) ? @entities[:post_author].as_json(only: USER_ATTRIBUTES) : nil
context['board'] = @entities[:board].as_json(only: BOARD_ATTRIBUTES)
context['post_status'] = @entities.key?(:post_status) ? @entities[:post_status].as_json(only: POST_STATUS_ATTRIBUTES) : { name: I18n.t('post.post_status_select.no_post_status'), color: '#000000' }.as_json
when 'new_comment'
context['comment'] = @entities[:comment].as_json(only: COMMENT_ATTRIBUTES)
context['comment_author'] = @entities[:comment_author].as_json(only: USER_ATTRIBUTES)
context['post'] = @entities[:post].as_json(only: POST_ATTRIBUTES, methods: POST_VIRTUAL_ATTRIBUTES)
context['post_author'] = @entities.key?(:post_author) ? @entities[:post_author].as_json(only: USER_ATTRIBUTES) : nil
context['board'] = @entities[:board].as_json(only: BOARD_ATTRIBUTES)
when 'new_vote'
context['vote_author'] = @entities[:vote_author].as_json(only: USER_ATTRIBUTES)
context['post'] = @entities[:post].as_json(only: POST_ATTRIBUTES, methods: POST_VIRTUAL_ATTRIBUTES)
context['post_author'] = @entities.key?(:post_author) ? @entities[:post_author].as_json(only: USER_ATTRIBUTES) : nil
context['board'] = @entities[:board].as_json(only: BOARD_ATTRIBUTES)
when 'new_user'
context['user'] = @entities[:user].as_json(only: USER_ATTRIBUTES)
end
context
end
end

View File

@@ -0,0 +1,4 @@
require Rails.root.join('app/lib/custom_liquid_filters') # Load the custom filters module
# Register the custom filter
Liquid::Template.register_filter(CustomLiquidFilters)

View File

@@ -78,6 +78,9 @@ en:
user:
one: 'User'
other: 'Users'
webhook:
one: 'Webhook'
other: 'Webhooks'
attributes:
board:
name: 'Name'
@@ -154,6 +157,15 @@ en:
role: 'Role'
notifications_enabled: 'Notifications enabled'
recap_notification_frequency: 'Recap notification frequency'
webhook:
name: 'Name'
description: 'Description'
is_enabled: 'Enabled'
trigger: 'Trigger'
url: 'URL'
http_body: 'HTTP request body'
http_method: 'HTTP method'
http_headers: 'HTTP request headers'
errors:
messages:
invalid: 'is invalid'

View File

@@ -71,6 +71,7 @@ en:
disabled: 'Disabled'
copied: 'Copied!'
user_staff: 'Staff'
language_supported: '%{language} supported'
powered_by: 'Powered by'
buttons:
new: 'New'
@@ -86,6 +87,7 @@ en:
approve: 'Approve'
reject: 'Reject'
copy_to_clipboard: 'Copy to clipboard'
preview: 'Preview'
datetime:
now: 'just now'
minutes:
@@ -188,6 +190,7 @@ en:
boards: 'Boards'
post_statuses: 'Statuses'
roadmap: 'Roadmap'
webhooks: 'Webhooks'
invitations: 'Invitations'
appearance: 'Appearance'
info_box:
@@ -306,6 +309,33 @@ en:
subtitle_oauth_config: 'OAuth configuration'
subtitle_user_profile_config: 'User profile configuration'
client_secret_help: 'hidden for security purposes'
webhooks:
title: 'Webhooks'
learn_more: 'Learn how to configure and use webhooks'
empty: 'There are no webhooks.'
triggers:
new_post: 'New post'
new_post_pending_approval: 'New post (pending approval)'
delete_post: 'Delete post'
post_status_change: 'Post status change'
new_comment: 'New comment'
new_vote: 'New vote'
new_user: 'New user'
http_methods:
post: 'POST'
put: 'PUT'
patch: 'PATCH'
delete: 'DELETE'
form:
title_new: 'New webhook'
title_edit: 'Edit webhook'
add_header: 'Add header'
header_n_key: 'Header %{n} key'
header_n_value: 'Header %{n} value'
template_variables_selector_placeholder: 'Select a template variable...'
preview_error: 'There is an error in your template'
test_page:
title: 'Webhook test results'
moderation:
menu:
feedback: 'Feedback'

View File

@@ -50,6 +50,9 @@ Rails.application.routes.draw do
resources :tenants, only: [:show, :update]
resources :users, only: [:index, :update]
resources :webhooks, only: [:index, :create, :update, :destroy]
put '/webhooks_preview', to: 'webhooks#preview'
put '/webhooks/:id/test', to: 'webhooks#test'
resources :o_auths, only: [:index, :create, :update, :destroy] do
resource :tenant_default_o_auths, only: [:create, :destroy]
end
@@ -87,6 +90,7 @@ Rails.application.routes.draw do
get 'boards'
get 'post_statuses'
get 'roadmap'
get 'webhooks'
get 'invitations'
get 'appearance'
end

View File

@@ -0,0 +1,19 @@
class CreateWebhooks < ActiveRecord::Migration[6.1]
def change
create_table :webhooks do |t|
t.string :name, null: false
t.string :description
t.boolean :is_enabled, null: false, default: false
t.integer :trigger, null: false, default: 0
t.string :url, null: false
t.text :http_body
t.integer :http_method, null: false, default: 0
t.json :http_headers
t.references :tenant, null: false, foreign_key: true
t.timestamps
end
add_index :webhooks, [:name, :tenant_id], unique: true
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2024_11_18_082824) do
ActiveRecord::Schema.define(version: 2024_11_30_112415) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -246,6 +246,22 @@ ActiveRecord::Schema.define(version: 2024_11_18_082824) do
t.index ["tenant_id"], name: "index_users_on_tenant_id"
end
create_table "webhooks", force: :cascade do |t|
t.string "name", null: false
t.string "description"
t.boolean "is_enabled", default: false, null: false
t.integer "trigger", default: 0, null: false
t.string "url", null: false
t.text "http_body"
t.integer "http_method", default: 0, null: false
t.json "http_headers"
t.bigint "tenant_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["name", "tenant_id"], name: "index_webhooks_on_name_and_tenant_id", unique: true
t.index ["tenant_id"], name: "index_webhooks_on_tenant_id"
end
add_foreign_key "api_keys", "tenants"
add_foreign_key "api_keys", "users"
add_foreign_key "boards", "tenants"
@@ -275,4 +291,5 @@ ActiveRecord::Schema.define(version: 2024_11_18_082824) do
add_foreign_key "tenant_default_o_auths", "tenants"
add_foreign_key "tenant_settings", "tenants"
add_foreign_key "users", "tenants"
add_foreign_key "webhooks", "tenants"
end

View File

@@ -20,17 +20,17 @@
"@babel/preset-typescript": "7.21.5",
"@stripe/react-stripe-js": "2.7.0",
"@stripe/stripe-js": "3.3.0",
"@types/react": "16.9.2",
"@types/react-dom": "16.9.0",
"@types/react": "16.14.35",
"@types/react-dom": "16.9.25",
"babel-loader": "9.1.2",
"bootstrap": "4.6.2",
"i18n-js": "3.9.2",
"jquery": "3.5.1",
"popper.js": "1.16.1",
"rails-erb-loader": "5.5.2",
"react": "16.9.0",
"react": "16.14.0",
"react-beautiful-dnd": "13.1.0",
"react-dom": "16.9.0",
"react-dom": "16.14.0",
"react-gravatar": "2.6.3",
"react-hook-form": "7.33.1",
"react-icons": "5.0.1",
@@ -40,6 +40,7 @@
"react-redux": "7.1.1",
"react-select": "5.8.0",
"react-sticky-box": "1.0.2",
"react-tooltip": "5.28.0",
"react_ujs": "2.6.0",
"redux": "4.0.4",
"redux-thunk": "2.3.0",

View File

@@ -0,0 +1,11 @@
FactoryBot.define do
factory :webhook do
sequence(:name) { |n| "Webhook#{n}" }
description { "Webhook description" }
url { "http://example.com" }
trigger { "new_post" }
http_method { "http_post" }
http_body { "requestbody" }
http_headers { "" }
end
end

View File

@@ -0,0 +1,70 @@
require 'rails_helper'
RSpec.describe Webhook, type: :model do
let(:webhook) { FactoryBot.build(:webhook) }
it 'has a valid factory' do
expect(webhook).to be_valid
end
it 'must have a name' do
webhook.name = nil
expect(webhook).to be_invalid
end
it 'must have a url' do
webhook.url = nil
expect(webhook).to be_invalid
end
it 'must have a trigger' do
webhook.trigger = nil
expect(webhook).to be_invalid
end
it 'must have a http_method' do
webhook.http_method = nil
expect(webhook).to be_invalid
end
it 'is disabled by default' do
expect(webhook.is_enabled).to eq(false)
end
it 'can have the following triggers: new_post, new_post_pending_approval, delete_post, post_status_change, new_comment, new_vote, new_user' do
webhook.trigger = 'new_post'
expect(webhook).to be_valid
webhook.trigger = 'new_post_pending_approval'
expect(webhook).to be_valid
webhook.trigger = 'delete_post'
expect(webhook).to be_valid
webhook.trigger = 'post_status_change'
expect(webhook).to be_valid
webhook.trigger = 'new_comment'
expect(webhook).to be_valid
webhook.trigger = 'new_vote'
expect(webhook).to be_valid
webhook.trigger = 'new_user'
expect(webhook).to be_valid
end
it 'can have the following http_methods: http_post, http_put, http_patch, http_delete' do
webhook.http_method = 'http_post'
expect(webhook).to be_valid
webhook.http_method = 'http_put'
expect(webhook).to be_valid
webhook.http_method = 'http_patch'
expect(webhook).to be_valid
webhook.http_method = 'http_delete'
expect(webhook).to be_valid
end
end

View File

@@ -1297,11 +1297,24 @@
"@floating-ui/core" "^1.6.0"
"@floating-ui/utils" "^0.2.7"
"@floating-ui/dom@^1.6.1":
version "1.6.12"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.12.tgz#6333dcb5a8ead3b2bf82f33d6bc410e95f54e556"
integrity sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==
dependencies:
"@floating-ui/core" "^1.6.0"
"@floating-ui/utils" "^0.2.8"
"@floating-ui/utils@^0.2.7":
version "0.2.7"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e"
integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==
"@floating-ui/utils@^0.2.8":
version "0.2.8"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==
"@gilbarbara/deep-equal@^0.1.1":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz#1a106721368dba5e7e9fb7e9a3a6f9efbd8df36d"
@@ -1441,12 +1454,10 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
"@types/react-dom@16.9.0":
version "16.9.0"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.0.tgz#ba6ddb00bf5de700b0eb91daa452081ffccbfdea"
integrity sha512-OL2lk7LYGjxn4b0efW3Pvf2KBVP0y1v3wip1Bp7nA79NkOpElH98q3WdCEdDj93b2b0zaeBG9DvriuKjIK5xDA==
dependencies:
"@types/react" "*"
"@types/react-dom@16.9.25":
version "16.9.25"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.25.tgz#fc6440aaae3d2d3aa10f6afeb7f1b0c4a55d5e31"
integrity sha512-ZK//eAPhwft9Ul2/Zj+6O11YR6L4JX0J2sVeBC9Ft7x7HFN7xk7yUV/zDxqV6rjvqgl6r8Dq7oQImxtyf/Mzcw==
"@types/react-redux@7.1.3":
version "7.1.3"
@@ -1483,13 +1494,19 @@
"@types/prop-types" "*"
csstype "^2.2.0"
"@types/react@16.9.2":
version "16.9.2"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.2.tgz#6d1765431a1ad1877979013906731aae373de268"
integrity sha512-jYP2LWwlh+FTqGd9v7ynUKZzjj98T8x7Yclz479QdRhHfuW9yQ+0jjnD31eXSXutmBpppj5PYNLYLRfnZJvcfg==
"@types/react@16.14.35":
version "16.14.35"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.35.tgz#9d3cf047d85aca8006c4776693124a5be90ee429"
integrity sha512-NUEiwmSS1XXtmBcsm1NyRRPYjoZF2YTE89/5QiLt5mlGffYK9FQqOKuOLuXNrjPQV04oQgaZG+Yq02ZfHoFyyg==
dependencies:
"@types/prop-types" "*"
csstype "^2.2.0"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.23.0"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.23.0.tgz#0a6655b3e2708eaabca00b7372fafd7a792a7b09"
integrity sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==
"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3":
version "2.0.6"
@@ -1850,6 +1867,11 @@ chrome-trace-event@^1.0.2:
dependencies:
tslib "^1.9.0"
classnames@^2.3.0:
version "2.5.1"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
clone-deep@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
@@ -2784,15 +2806,15 @@ react-beautiful-dnd@13.1.0:
redux "^4.0.4"
use-memo-one "^1.1.1"
react-dom@16.9.0:
version "16.9.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.9.0.tgz#5e65527a5e26f22ae3701131bcccaee9fb0d3962"
integrity sha512-YFT2rxO9hM70ewk9jq0y6sQk8cL02xm4+IzYBz75CQGlClQQ1Bxq0nhHF6OtSbit+AIahujJgb/CPRibFkMNJQ==
react-dom@16.14.0:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89"
integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.15.0"
scheduler "^0.19.1"
react-floater@^0.7.9:
version "0.7.9"
@@ -2930,6 +2952,14 @@ react-sticky-box@1.0.2:
dependencies:
resize-observer-polyfill "^1.5.1"
react-tooltip@5.28.0:
version "5.28.0"
resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-5.28.0.tgz#c7b5343ab2d740a428494a3d8315515af1f26f46"
integrity sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==
dependencies:
"@floating-ui/dom" "^1.6.1"
classnames "^2.3.0"
react-transition-group@^4.3.0:
version "4.4.5"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"
@@ -2940,10 +2970,10 @@ react-transition-group@^4.3.0:
loose-envify "^1.4.0"
prop-types "^15.6.2"
react@16.9.0:
version "16.9.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.9.0.tgz#40ba2f9af13bc1a38d75dbf2f4359a5185c4f7aa"
integrity sha512-+7LQnFBwkiw+BobzOF6N//BdoNw0ouwmSJTEm9cglOOmsg/TMiFHZLe2sEoN5M7LgJTj9oHH0gxklfnQe66S1w==
react@16.14.0:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@@ -3107,10 +3137,10 @@ sass@1.62.1:
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
scheduler@^0.15.0:
version "0.15.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.15.0.tgz#6bfcf80ff850b280fed4aeecc6513bc0b4f17f8e"
integrity sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg==
scheduler@^0.19.1:
version "0.19.1"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196"
integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"