mirror of
https://github.com/astuto/astuto.git
synced 2025-12-14 18:57:51 +01:00
Add webhooks (#447)
This commit is contained in:
committed by
GitHub
parent
2290cff507
commit
a12a95eccc
5
Gemfile
5
Gemfile
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -173,6 +173,7 @@ body {
|
||||
}
|
||||
.badgeWarning { @extend .badge-warning; }
|
||||
.badgeDanger { @extend .badge-danger; }
|
||||
.badgeSuccess { @extend .badge-success; }
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
}
|
||||
|
||||
.editCommentForm {
|
||||
.commentFormContainer { @extend .d-block; }
|
||||
|
||||
textarea {
|
||||
@extend .my-2;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
.pl-0;
|
||||
|
||||
list-style: none;
|
||||
height: 500px;
|
||||
max-height: 500px;
|
||||
overflow-y: scroll;
|
||||
|
||||
li.invitationListItem {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ class SiteSettingsController < ApplicationController
|
||||
def roadmap
|
||||
end
|
||||
|
||||
def webhooks
|
||||
end
|
||||
|
||||
def invitations
|
||||
@invitations = Invitation.all.order(updated_at: :desc)
|
||||
end
|
||||
|
||||
97
app/controllers/webhooks_controller.rb
Normal file
97
app/controllers/webhooks_controller.rb
Normal 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
|
||||
@@ -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";
|
||||
|
||||
69
app/javascript/actions/Webhook/deleteWebhook.ts
Normal file
69
app/javascript/actions/Webhook/deleteWebhook.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
);
|
||||
59
app/javascript/actions/Webhook/requestWebhooks.ts
Normal file
59
app/javascript/actions/Webhook/requestWebhooks.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
)
|
||||
78
app/javascript/actions/Webhook/submitWebhook.ts
Normal file
78
app/javascript/actions/Webhook/submitWebhook.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
96
app/javascript/actions/Webhook/updateWebhook.ts
Normal file
96
app/javascript/actions/Webhook/updateWebhook.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import I18n from 'i18n-js';
|
||||
|
||||
import Box from '../../common/Box';
|
||||
import { AuthenticationPages } from './AuthenticationSiteSettingsP';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' && (
|
||||
<>
|
||||
|
||||
@@ -50,6 +50,7 @@ const RoadmapEmbedding: React.FC<Props> = ({ embeddedRoadmapUrl }) => {
|
||||
onChange={event => setEmbedCode(event.target.value)}
|
||||
rows={5}
|
||||
id="roadmapEmbedCode"
|
||||
className="formControl"
|
||||
>
|
||||
</textarea>
|
||||
|
||||
|
||||
@@ -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;
|
||||
415
app/javascript/components/SiteSettings/Webhooks/WebhookForm.tsx
Normal file
415
app/javascript/components/SiteSettings/Webhooks/WebhookForm.tsx
Normal 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') }
|
||||
|
||||
{ <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') }
|
||||
|
||||
{ <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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>:
|
||||
<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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
33
app/javascript/components/SiteSettings/Webhooks/index.tsx
Normal file
33
app/javascript/components/SiteSettings/Webhooks/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
@@ -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):\/\/[^"]+$/;
|
||||
44
app/javascript/containers/WebhooksSiteSettings.tsx
Normal file
44
app/javascript/containers/WebhooksSiteSettings.tsx
Normal 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);
|
||||
77
app/javascript/interfaces/IWebhook.ts
Normal file
77
app/javascript/interfaces/IWebhook.ts
Normal 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,
|
||||
});
|
||||
71
app/javascript/reducers/SiteSettings/webhooksReducer.ts
Normal file
71
app/javascript/reducers/SiteSettings/webhooksReducer.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
93
app/javascript/reducers/webhooksReducer.ts
Normal file
93
app/javascript/reducers/webhooksReducer.ts
Normal 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
105
app/jobs/run_webhook.rb
Normal 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
|
||||
7
app/lib/custom_liquid_filters.rb
Normal file
7
app/lib/custom_liquid_filters.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
76
app/models/webhook.rb
Normal 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
|
||||
34
app/policies/webhook_policy.rb
Normal file
34
app/policies/webhook_policy.rb
Normal 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
|
||||
@@ -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>
|
||||
|
||||
13
app/views/site_settings/webhooks.html.erb
Normal file
13
app/views/site_settings/webhooks.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<div class="twoColumnsContainer">
|
||||
<%= render 'menu' %>
|
||||
<div>
|
||||
<%=
|
||||
react_component(
|
||||
'SiteSettings/Webhooks',
|
||||
{
|
||||
authenticityToken: form_authenticity_token
|
||||
}
|
||||
)
|
||||
%>
|
||||
</div>
|
||||
</div>
|
||||
159
app/workflows/create_liquid_template_context_workflow.rb
Normal file
159
app/workflows/create_liquid_template_context_workflow.rb
Normal 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
|
||||
4
config/initializers/liquid.rb
Normal file
4
config/initializers/liquid.rb
Normal 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)
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
19
db/migrate/20241130112415_create_webhooks.rb
Normal file
19
db/migrate/20241130112415_create_webhooks.rb
Normal 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
|
||||
19
db/schema.rb
19
db/schema.rb
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
11
spec/factories/webhooks.rb
Normal file
11
spec/factories/webhooks.rb
Normal 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
|
||||
70
spec/models/webhook_spec.rb
Normal file
70
spec/models/webhook_spec.rb
Normal 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
|
||||
78
yarn.lock
78
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user