From cd134b726671c94dfba76e05618264a0f9435f07 Mon Sep 17 00:00:00 2001 From: bimal gurung Date: Mon, 22 Jan 2024 10:54:02 +0545 Subject: [PATCH] feat(ui): flag triggers (#631) --- ui/web-v2/apps/admin/src/assets/lang/en.json | 27 + ui/web-v2/apps/admin/src/assets/lang/ja.json | 27 + .../apps/admin/src/assets/svg/webhook.svg | 3 + .../components/FeatureTriggerForm/index.tsx | 694 ++++++++++++++++++ .../admin/src/components/Select/index.tsx | 3 + .../components/TriggerDeleteDialog/index.tsx | 51 ++ .../components/TriggerResetDialog/index.tsx | 51 ++ ui/web-v2/apps/admin/src/constants/routing.ts | 1 + ui/web-v2/apps/admin/src/grpc/flagTriggers.ts | 265 +++++++ ui/web-v2/apps/admin/src/lang/messages.ts | 112 +++ .../apps/admin/src/modules/flagTriggers.ts | 261 +++++++ ui/web-v2/apps/admin/src/modules/index.ts | 2 + .../apps/admin/src/pages/feature/detail.tsx | 94 ++- .../admin/src/pages/feature/formSchema.ts | 6 + .../apps/admin/src/pages/feature/triggers.tsx | 43 ++ ui/web-v2/apps/admin/tailwind.config.js | 2 +- ui/web-v2/package.json | 1 + ui/web-v2/yarn.lock | 5 + 18 files changed, 1609 insertions(+), 39 deletions(-) create mode 100644 ui/web-v2/apps/admin/src/assets/svg/webhook.svg create mode 100644 ui/web-v2/apps/admin/src/components/FeatureTriggerForm/index.tsx create mode 100644 ui/web-v2/apps/admin/src/components/TriggerDeleteDialog/index.tsx create mode 100644 ui/web-v2/apps/admin/src/components/TriggerResetDialog/index.tsx create mode 100644 ui/web-v2/apps/admin/src/grpc/flagTriggers.ts create mode 100644 ui/web-v2/apps/admin/src/modules/flagTriggers.ts create mode 100644 ui/web-v2/apps/admin/src/pages/feature/triggers.tsx diff --git a/ui/web-v2/apps/admin/src/assets/lang/en.json b/ui/web-v2/apps/admin/src/assets/lang/en.json index c7e47f277..797884d87 100644 --- a/ui/web-v2/apps/admin/src/assets/lang/en.json +++ b/ui/web-v2/apps/admin/src/assets/lang/en.json @@ -155,6 +155,7 @@ "button.edit": "Edit", "button.enable": "Enable", "button.result": "Result", + "button.save": "Save", "button.saveWithComment": "Save with comment", "button.schedule": "Schedule", "button.submit": "Submit", @@ -302,6 +303,7 @@ "feature.tab.history": "History", "feature.tab.settings": "Settings", "feature.tab.targeting": "Targeting", + "feature.tab.triggers": "Triggers", "feature.tab.variations": "Variations", "feature.targetingDescription": "Enable targeting settings. You can configure targeting users, complex rules, default strategy, and off variation.", "feature.targetings": "Individual targeting", @@ -317,6 +319,7 @@ "feature.variationType": "Flag type", "filter.add": "Add filter", "filter.filter": "Filter", + "fullStop": ".", "goal.action.archive": "Archive", "goal.add.header.description": "The goal lets you measure user behaviors affected by your feature flags in experiments.", "goal.add.header.title": "Create a goal", @@ -520,6 +523,30 @@ "successMessages.schedule": "Schedule has been configured", "tags": "Tags", "total": "Total", + "trigger.action": "Action", + "trigger.addTrigger": "Add Trigger", + "trigger.deleteTrigger": "Delete Trigger", + "trigger.deleteTriggerDialogBtnTxt": "Delete", + "trigger.deleteTriggerDialogMessage": "The trigger will be deleted permanently.", + "trigger.deleteTriggerDialogTitle": "Delete Trigger", + "trigger.description": "Use triggers to turn a flag on or off remotely. See the {link}", + "trigger.disableTrigger": "Disable Trigger", + "trigger.documentation": "documentation", + "trigger.editDescription": "Edit description", + "trigger.enableTrigger": "Enable Trigger", + "trigger.lastTriggered": "Last Triggered", + "trigger.resetTriggerDialogBtnLabel": "Reset", + "trigger.resetTriggerDialogMessage": "The current URL will become invalid. Ensure that you copy and store the new URL.", + "trigger.resetTriggerDialogTitle": "Reset Trigger URL", + "trigger.resetURL": "Reset URL", + "trigger.triggerType": "Type", + "trigger.triggerURL": "Trigger URL", + "trigger.triggerUrlDescription": "Once you leave this page, the URL will be hidden.", + "trigger.triggerUrlTitle": "Copy and store this URL.", + "trigger.triggeredTimes": "Triggered Times", + "trigger.turnTheFlagOFF": "Turn the flag OFF", + "trigger.turnTheFlagON": "Turn the flag ON", + "trigger.updated": "Updated", "type": "Type", "urlCode": "URL code", "warning": "Warning", diff --git a/ui/web-v2/apps/admin/src/assets/lang/ja.json b/ui/web-v2/apps/admin/src/assets/lang/ja.json index 6c951a6ba..a594beafd 100644 --- a/ui/web-v2/apps/admin/src/assets/lang/ja.json +++ b/ui/web-v2/apps/admin/src/assets/lang/ja.json @@ -155,6 +155,7 @@ "button.edit": "編集", "button.enable": "有効化", "button.result": "結果", + "button.save": "保存", "button.saveWithComment": "コメントを付けて保存", "button.schedule": "スケジュール", "button.submit": "送信", @@ -302,6 +303,7 @@ "feature.tab.history": "履歴", "feature.tab.settings": "設定", "feature.tab.targeting": "ターゲティング", + "feature.tab.triggers": "トリガー", "feature.tab.variations": "バリエーション", "feature.targetingDescription": "ターゲティングを設定します。 ユーザーターゲティング、複雑なルール設定、デフォルトストラテジー、オフバリエーションを設定できます。", "feature.targetings": "ターゲティングユーザー", @@ -317,6 +319,7 @@ "feature.variationType": "フラグの型", "filter.add": "フィルターを追加", "filter.filter": "フィルター", + "fullStop": "。", "goal.action.archive": "アーカイブ", "goal.add.header.description": "ゴールを使用することで、エクスペリメントでフィーチャーフラグの影響受けるユーザーの行動を計測できます。", "goal.add.header.title": "ゴールの作成", @@ -520,6 +523,30 @@ "successMessages.schedule": "スケジュールが設定されました", "tags": "タグ", "total": "合計", + "trigger.action": "アクション", + "trigger.addTrigger": "トリガーの追加", + "trigger.deleteTrigger": "トリガーを削除", + "trigger.deleteTriggerDialogBtnTxt": "削除", + "trigger.deleteTriggerDialogMessage": "このトリガーが永久に削除されます。", + "trigger.deleteTriggerDialogTitle": "トリガーの削除", + "trigger.description": "トリガーを使用してリモートでフラグをオンまたはオフにできます。詳しくは{link}", + "trigger.disableTrigger": "トリガーを無効化する", + "trigger.documentation": "こちら", + "trigger.editDescription": "説明を変更", + "trigger.enableTrigger": "トリガーを有効化する", + "trigger.lastTriggered": "最終実行日", + "trigger.resetTriggerDialogBtnLabel": "リセット", + "trigger.resetTriggerDialogMessage": "現在のURLは無効になります。 新しいURLを必ずコピーして保管してください。", + "trigger.resetTriggerDialogTitle": "トリガーURLのリセット", + "trigger.resetURL": "URLをリセット", + "trigger.triggerType": "トリガータイプ", + "trigger.triggerURL": "トリガーURL", + "trigger.triggerUrlDescription": "このページを離れますとURLが表示されなくなります。", + "trigger.triggerUrlTitle": "URLをコピーし、保管してください。", + "trigger.triggeredTimes": "実行回数", + "trigger.turnTheFlagOFF": "フラグを無効化する", + "trigger.turnTheFlagON": "フラグを有効化する", + "trigger.updated": "更新", "type": "タイプ", "urlCode": "URLコード", "warning": "警告", diff --git a/ui/web-v2/apps/admin/src/assets/svg/webhook.svg b/ui/web-v2/apps/admin/src/assets/svg/webhook.svg new file mode 100644 index 000000000..915112a7b --- /dev/null +++ b/ui/web-v2/apps/admin/src/assets/svg/webhook.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/web-v2/apps/admin/src/components/FeatureTriggerForm/index.tsx b/ui/web-v2/apps/admin/src/components/FeatureTriggerForm/index.tsx new file mode 100644 index 000000000..7f2930c7e --- /dev/null +++ b/ui/web-v2/apps/admin/src/components/FeatureTriggerForm/index.tsx @@ -0,0 +1,694 @@ +import { intl } from '@/lang'; +import { messages } from '@/lang/messages'; +import { AppState } from '@/modules'; +import { + createFlagTrigger, + selectAll, + deleteFlagTrigger, + listFlagTriggers, + updateFlagTrigger, + resetFlagTrigger, + disableFlagTrigger, + enableFlagTrigger, +} from '@/modules/flagTriggers'; +import { FlagTrigger } from '@/proto/feature/flag_trigger_pb'; +import { + CreateFlagTriggerResponse, + ListFlagTriggersResponse, + ResetFlagTriggerResponse, +} from '@/proto/feature/service_pb'; +import { AppDispatch } from '@/store'; +import { classNames } from '@/utils/css'; +import { Popover } from '@headlessui/react'; +import { + DotsHorizontalIcon, + PlusIcon, + BanIcon, + RefreshIcon, + TrashIcon, + PencilAltIcon, + CheckCircleIcon, + XIcon, + ClockIcon, + InformationCircleIcon, +} from '@heroicons/react/outline'; +import { ExclamationCircleIcon } from '@heroicons/react/solid'; +import { FileCopyOutlined } from '@material-ui/icons'; +import React, { FC, memo, useCallback, useEffect, useState } from 'react'; +import { useFormContext, Controller } from 'react-hook-form'; +import { useIntl } from 'react-intl'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; + +import { ReactComponent as OpenInNewSvg } from '../../assets/svg/open-new-tab.svg'; +import { ReactComponent as WebhookSvg } from '../../assets/svg/webhook.svg'; +import { useCurrentEnvironment, useIsEditable } from '../../modules/me'; +import { CopyChip } from '../CopyChip'; +import { DetailSkeleton } from '../DetailSkeleton'; +import { HoverPopover } from '../HoverPopover'; +import { RelativeDateText } from '../RelativeDateText'; +import { Select } from '../Select'; +import { TriggerDeleteDialog } from '../TriggerDeleteDialog'; +import { TriggerResetDialog } from '../TriggerResetDialog'; + +const triggerTypeOptions = [ + { + value: FlagTrigger.Type.TYPE_WEBHOOK.toString(), + label: 'Webhook', + }, +]; + +const actionOptions = [ + { + value: FlagTrigger.Action.ACTION_OFF.toString(), + label: intl.formatMessage(messages.trigger.turnTheFlagOFF), + }, + { + value: FlagTrigger.Action.ACTION_ON.toString(), + label: intl.formatMessage(messages.trigger.turnTheFlagON), + }, +]; + +interface CopyUrl { + id: string; + url: string; +} +interface FeatureTriggerFormProps { + featureId: string; +} + +export const FeatureTriggerForm: FC = memo( + ({ featureId }) => { + const dispatch = useDispatch(); + const { formatMessage: f } = useIntl(); + const methods = useFormContext(); + const { reset } = methods; + const currentEnvironment = useCurrentEnvironment(); + const [isDeleteConfirmDialogOpen, setIsDeleteConfirmDialogOpen] = + useState(false); + const [isResetConfirmDialogOpen, setIsResetConfirmDialogOpen] = + useState(false); + + const isLoading = useSelector( + (state) => state.flagTriggers.loading + ); + const flagTriggers = useSelector< + AppState, + ListFlagTriggersResponse.FlagTriggerWithUrl.AsObject[] + >((state) => selectAll(state.flagTriggers), shallowEqual); + + const [isAddTriggerOpen, setIsAddTriggerOpen] = useState(false); + const [selectedFlagTrigger, setSelectedFlagTrigger] = + useState(null); + const [selectedFlagTriggerForCopyUrl, setSelectedFlagTriggerForCopyUrl] = + useState(null); + + const fetchFlagTriggers = useCallback(() => { + dispatch( + listFlagTriggers({ + environmentNamespace: currentEnvironment.id, + featureId, + }) + ); + }, []); + + useEffect(() => { + fetchFlagTriggers(); + }, []); + + const handleDelete = () => { + setIsDeleteConfirmDialogOpen(false); + setSelectedFlagTrigger(null); + dispatch( + deleteFlagTrigger({ + id: selectedFlagTrigger.flagTrigger.id, + environmentNamespace: currentEnvironment.id, + }) + ).then(() => fetchFlagTriggers()); + }; + + const handleReset = () => { + setIsResetConfirmDialogOpen(false); + setSelectedFlagTrigger(null); + setSelectedFlagTriggerForCopyUrl(null); + dispatch( + resetFlagTrigger({ + id: selectedFlagTrigger.flagTrigger.id, + environmentNamespace: currentEnvironment.id, + }) + ).then((response) => { + const payload = response.payload as ResetFlagTriggerResponse.AsObject; + setSelectedFlagTriggerForCopyUrl({ + id: payload.flagTrigger.id, + url: payload.url, + }); + fetchFlagTriggers(); + }); + }; + + const handleEnable = useCallback((flagTriggerId) => { + dispatch( + enableFlagTrigger({ + id: flagTriggerId, + environmentNamespace: currentEnvironment.id, + }) + ).then(() => fetchFlagTriggers()); + }, []); + + const handleDisable = useCallback((flagTriggerId) => { + dispatch( + disableFlagTrigger({ + id: flagTriggerId, + environmentNamespace: currentEnvironment.id, + }) + ).then(() => fetchFlagTriggers()); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + <> +
+
+

{f(messages.feature.tab.triggers)}

+
+

+ {f(messages.trigger.description, { + link: ( + + {f(messages.trigger.documentation)} + + + ), + })} + {f(messages.fullStop)} +

+
+ {flagTriggers.map((flagTriggerWithUrl) => + flagTriggerWithUrl.flagTrigger.id === + selectedFlagTrigger?.flagTrigger?.id && + !isDeleteConfirmDialogOpen && + !isResetConfirmDialogOpen ? ( + { + reset(); + setSelectedFlagTrigger(null); + }} + featureId={featureId} + fetchFlagTriggers={fetchFlagTriggers} + flagTriggerWithUrl={flagTriggerWithUrl} + setSelectedFlagTriggerForCopyUrl={ + setSelectedFlagTriggerForCopyUrl + } + /> + ) : ( +
+
+ +
+
+
+
+
+

+ { + triggerTypeOptions.find( + (d) => + d.value === + flagTriggerWithUrl.flagTrigger.type.toString() + )?.label + } +

+ {flagTriggerWithUrl.flagTrigger.disabled ? ( +
+ Off +
+ ) : ( +
+ On +
+ )} +
+
+
+ + {flagTriggerWithUrl.flagTrigger.updatedAt && ( +
+

+ {f(messages.trigger.updated)}  +

+ +
+ )} +
+ + +
+ +
+
+ + + {flagTriggerWithUrl.flagTrigger.disabled ? ( + + ) : ( + + )} + + + +
+
+
+

+ {flagTriggerWithUrl.flagTrigger.description} +

+
+
+ {selectedFlagTriggerForCopyUrl?.id === + flagTriggerWithUrl.flagTrigger.id ? ( +
+
+ + {f(messages.trigger.triggerURL)} + +
+
+ {selectedFlagTriggerForCopyUrl.url} +
+ +
+ +
+
+
+
+
+
+
+ ) : ( +
+
+

+ {f(messages.trigger.action)} +

+

+ {FlagTrigger.Action.ACTION_OFF === + flagTriggerWithUrl.flagTrigger.action && + f(messages.trigger.turnTheFlagOFF)} + {FlagTrigger.Action.ACTION_ON === + flagTriggerWithUrl.flagTrigger.action && + f(messages.trigger.turnTheFlagON)} +

+
+
+

+ {f(messages.trigger.triggerURL)} +

+

+ {flagTriggerWithUrl.url} +

+
+
+

+ {f(messages.trigger.triggeredTimes)} +

+

+ {flagTriggerWithUrl.flagTrigger.triggerCount} +

+
+
+

+ {f(messages.trigger.lastTriggered)} +

+

+ {flagTriggerWithUrl.flagTrigger + .lastTriggeredAt ? ( + + ) : ( + '-' + )} +

+
+
+ )} +
+
+
+ ) + )} + {isAddTriggerOpen && ( + { + reset(); + setIsAddTriggerOpen(false); + }} + featureId={featureId} + fetchFlagTriggers={fetchFlagTriggers} + setSelectedFlagTriggerForCopyUrl={ + setSelectedFlagTriggerForCopyUrl + } + /> + )} + {(!isAddTriggerOpen || selectedFlagTrigger) && ( + + )} +
+
+ { + setIsDeleteConfirmDialogOpen(false); + setSelectedFlagTrigger(null); + }} + /> + { + setIsResetConfirmDialogOpen(false); + setSelectedFlagTrigger(null); + }} + /> + + ); + } +); + +interface AddUpdateTriggerProps { + close: () => void; + flagTriggerWithUrl?: CreateFlagTriggerResponse.AsObject; + featureId: string; + fetchFlagTriggers: () => void; + setSelectedFlagTriggerForCopyUrl: React.Dispatch< + React.SetStateAction + >; +} +const AddUpdateTrigger: FC = memo( + ({ + close, + flagTriggerWithUrl, + featureId, + fetchFlagTriggers, + setSelectedFlagTriggerForCopyUrl, + }) => { + const dispatch = useDispatch(); + const { formatMessage: f } = useIntl(); + const methods = useFormContext(); + const { + control, + formState: { errors, isValid }, + watch, + handleSubmit, + register, + reset, + setValue, + } = methods; + const editable = useIsEditable(); + const currentEnvironment = useCurrentEnvironment(); + + useEffect(() => { + if (flagTriggerWithUrl) { + setValue('triggerType', flagTriggerWithUrl.flagTrigger.type.toString()); + setValue('action', flagTriggerWithUrl.flagTrigger.action.toString()); + setValue('description', flagTriggerWithUrl.flagTrigger.description); + } + }, [flagTriggerWithUrl]); + + const handleOnSubmit = useCallback((data) => { + dispatch( + createFlagTrigger({ + environmentNamespace: currentEnvironment.id, + featureId, + triggerType: data.triggerType, + action: data.action, + description: data.description, + }) + ).then((response) => { + const payload = response.payload as CreateFlagTriggerResponse.AsObject; + setSelectedFlagTriggerForCopyUrl({ + id: payload.flagTrigger.id, + url: payload.url, + }); + fetchFlagTriggers(); + reset(); + close(); + }); + }, []); + + const handleOnSaveSubmit = useCallback( + (data) => { + dispatch( + updateFlagTrigger({ + environmentNamespace: currentEnvironment.id, + id: flagTriggerWithUrl.flagTrigger.id, + description: data.description, + }) + ).then(() => { + fetchFlagTriggers(); + reset(); + close(); + setSelectedFlagTriggerForCopyUrl(null); + }); + }, + [flagTriggerWithUrl] + ); + + return ( +
+
+
+ + { + return ( +
+ Tooltip +
+ ); + }} + > + +
+
+ ( + field.onChange(o.value)} + value={actionOptions.find((o) => o.value === field.value)} + options={actionOptions} + isSearchable={false} + disabled={!!flagTriggerWithUrl} + /> + )} + /> +
+
+
+ + +
+