mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: Add UI for custom tools (#12585)
### Tools list <img width="2316" height="666" alt="CleanShot 2025-10-03 at 20 42 41@2x" src="https://github.com/user-attachments/assets/ccbffd16-804d-4eb8-9c64-2d1cfd407e4e" /> ### Tools form <img width="2294" height="2202" alt="CleanShot 2025-10-03 at 20 43 05@2x" src="https://github.com/user-attachments/assets/9f49aa09-75a1-4585-a09d-837ca64139b8" /> ## Response <img width="800" height="2144" alt="CleanShot 2025-10-03 at 20 45 56@2x" src="https://github.com/user-attachments/assets/b0c3c899-6050-4c51-baed-c8fbec5aae61" /> --------- Co-authored-by: Pranav <pranavrajs@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
		
							
								
								
									
										36
									
								
								app/javascript/dashboard/api/captain/customTools.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								app/javascript/dashboard/api/captain/customTools.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| /* global axios */ | ||||
| import ApiClient from '../ApiClient'; | ||||
|  | ||||
| class CaptainCustomTools extends ApiClient { | ||||
|   constructor() { | ||||
|     super('captain/custom_tools', { accountScoped: true }); | ||||
|   } | ||||
|  | ||||
|   get({ page = 1, searchKey } = {}) { | ||||
|     return axios.get(this.url, { | ||||
|       params: { page, searchKey }, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   show(id) { | ||||
|     return axios.get(`${this.url}/${id}`); | ||||
|   } | ||||
|  | ||||
|   create(data = {}) { | ||||
|     return axios.post(this.url, { | ||||
|       custom_tool: data, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   update(id, data = {}) { | ||||
|     return axios.put(`${this.url}/${id}`, { | ||||
|       custom_tool: data, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   delete(id) { | ||||
|     return axios.delete(`${this.url}/${id}`); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default new CaptainCustomTools(); | ||||
| @@ -10,6 +10,10 @@ const props = defineProps({ | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   translationKey: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   entity: { | ||||
|     type: Object, | ||||
|     required: true, | ||||
| @@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']); | ||||
| const { t } = useI18n(); | ||||
| const store = useStore(); | ||||
| const deleteDialogRef = ref(null); | ||||
| const i18nKey = computed(() => props.type.toUpperCase()); | ||||
| const i18nKey = computed(() => { | ||||
|   return props.translationKey || props.type.toUpperCase(); | ||||
| }); | ||||
|  | ||||
| const deleteEntity = async payload => { | ||||
|   if (!payload) return; | ||||
|   | ||||
| @@ -0,0 +1,73 @@ | ||||
| <script setup> | ||||
| import { defineModel, watch } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import Input from 'dashboard/components-next/input/Input.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   authType: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|     validator: value => ['none', 'bearer', 'basic', 'api_key'].includes(value), | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const authConfig = defineModel('authConfig', { | ||||
|   type: Object, | ||||
|   default: () => ({}), | ||||
| }); | ||||
|  | ||||
| watch( | ||||
|   () => props.authType, | ||||
|   () => { | ||||
|     authConfig.value = {}; | ||||
|   } | ||||
| ); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div class="flex flex-col gap-2"> | ||||
|     <Input | ||||
|       v-if="authType === 'bearer'" | ||||
|       v-model="authConfig.token" | ||||
|       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN')" | ||||
|       :placeholder=" | ||||
|         t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN_PLACEHOLDER') | ||||
|       " | ||||
|     /> | ||||
|     <template v-else-if="authType === 'basic'"> | ||||
|       <Input | ||||
|         v-model="authConfig.username" | ||||
|         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME')" | ||||
|         :placeholder=" | ||||
|           t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME_PLACEHOLDER') | ||||
|         " | ||||
|       /> | ||||
|       <Input | ||||
|         v-model="authConfig.password" | ||||
|         type="password" | ||||
|         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD')" | ||||
|         :placeholder=" | ||||
|           t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD_PLACEHOLDER') | ||||
|         " | ||||
|       /> | ||||
|     </template> | ||||
|     <template v-else-if="authType === 'api_key'"> | ||||
|       <Input | ||||
|         v-model="authConfig.name" | ||||
|         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY')" | ||||
|         :placeholder=" | ||||
|           t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY_PLACEHOLDER') | ||||
|         " | ||||
|       /> | ||||
|       <Input | ||||
|         v-model="authConfig.key" | ||||
|         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE')" | ||||
|         :placeholder=" | ||||
|           t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE_PLACEHOLDER') | ||||
|         " | ||||
|       /> | ||||
|     </template> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -0,0 +1,87 @@ | ||||
| <script setup> | ||||
| import { ref, computed } from 'vue'; | ||||
| import { useStore } from 'dashboard/composables/store'; | ||||
| import { useAlert } from 'dashboard/composables'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; | ||||
|  | ||||
| import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||
| import CustomToolForm from './CustomToolForm.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   selectedTool: { | ||||
|     type: Object, | ||||
|     default: () => ({}), | ||||
|   }, | ||||
|   type: { | ||||
|     type: String, | ||||
|     default: 'create', | ||||
|     validator: value => ['create', 'edit'].includes(value), | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['close']); | ||||
| const { t } = useI18n(); | ||||
| const store = useStore(); | ||||
|  | ||||
| const dialogRef = ref(null); | ||||
|  | ||||
| const updateTool = toolDetails => | ||||
|   store.dispatch('captainCustomTools/update', { | ||||
|     id: props.selectedTool.id, | ||||
|     ...toolDetails, | ||||
|   }); | ||||
|  | ||||
| const i18nKey = computed( | ||||
|   () => `CAPTAIN.CUSTOM_TOOLS.${props.type.toUpperCase()}` | ||||
| ); | ||||
|  | ||||
| const createTool = toolDetails => | ||||
|   store.dispatch('captainCustomTools/create', toolDetails); | ||||
|  | ||||
| const handleSubmit = async updatedTool => { | ||||
|   try { | ||||
|     if (props.type === 'edit') { | ||||
|       await updateTool(updatedTool); | ||||
|     } else { | ||||
|       await createTool(updatedTool); | ||||
|     } | ||||
|     useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`)); | ||||
|     dialogRef.value.close(); | ||||
|   } catch (error) { | ||||
|     const errorMessage = | ||||
|       parseAPIErrorResponse(error) || t(`${i18nKey.value}.ERROR_MESSAGE`); | ||||
|     useAlert(errorMessage); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const handleClose = () => { | ||||
|   emit('close'); | ||||
| }; | ||||
|  | ||||
| const handleCancel = () => { | ||||
|   dialogRef.value.close(); | ||||
| }; | ||||
|  | ||||
| defineExpose({ dialogRef }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <Dialog | ||||
|     ref="dialogRef" | ||||
|     width="2xl" | ||||
|     :title="$t(`${i18nKey}.TITLE`)" | ||||
|     :description="$t('CAPTAIN.CUSTOM_TOOLS.FORM_DESCRIPTION')" | ||||
|     :show-cancel-button="false" | ||||
|     :show-confirm-button="false" | ||||
|     @close="handleClose" | ||||
|   > | ||||
|     <CustomToolForm | ||||
|       :mode="type" | ||||
|       :tool="selectedTool" | ||||
|       @submit="handleSubmit" | ||||
|       @cancel="handleCancel" | ||||
|     /> | ||||
|     <template #footer /> | ||||
|   </Dialog> | ||||
| </template> | ||||
| @@ -0,0 +1,125 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
| import { useToggle } from '@vueuse/core'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { dynamicTime } from 'shared/helpers/timeHelper'; | ||||
|  | ||||
| import CardLayout from 'dashboard/components-next/CardLayout.vue'; | ||||
| import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import Policy from 'dashboard/components/policy.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   id: { | ||||
|     type: Number, | ||||
|     required: true, | ||||
|   }, | ||||
|   title: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   description: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   authType: { | ||||
|     type: String, | ||||
|     default: 'none', | ||||
|   }, | ||||
|   updatedAt: { | ||||
|     type: Number, | ||||
|     required: true, | ||||
|   }, | ||||
|   createdAt: { | ||||
|     type: Number, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['action']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const [showActionsDropdown, toggleDropdown] = useToggle(); | ||||
|  | ||||
| const menuItems = computed(() => [ | ||||
|   { | ||||
|     label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'), | ||||
|     value: 'edit', | ||||
|     action: 'edit', | ||||
|     icon: 'i-lucide-pencil-line', | ||||
|   }, | ||||
|   { | ||||
|     label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.DELETE_TOOL'), | ||||
|     value: 'delete', | ||||
|     action: 'delete', | ||||
|     icon: 'i-lucide-trash', | ||||
|   }, | ||||
| ]); | ||||
|  | ||||
| const timestamp = computed(() => | ||||
|   dynamicTime(props.updatedAt || props.createdAt) | ||||
| ); | ||||
|  | ||||
| const handleAction = ({ action, value }) => { | ||||
|   toggleDropdown(false); | ||||
|   emit('action', { action, value, id: props.id }); | ||||
| }; | ||||
|  | ||||
| const authTypeLabel = computed(() => { | ||||
|   return t( | ||||
|     `CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.${props.authType.toUpperCase()}` | ||||
|   ); | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <CardLayout class="relative"> | ||||
|     <div class="flex relative justify-between w-full gap-1"> | ||||
|       <span class="text-base text-n-slate-12 line-clamp-1 font-medium"> | ||||
|         {{ title }} | ||||
|       </span> | ||||
|       <div class="flex items-center gap-2"> | ||||
|         <Policy | ||||
|           v-on-clickaway="() => toggleDropdown(false)" | ||||
|           :permissions="['administrator']" | ||||
|           class="relative flex items-center group" | ||||
|         > | ||||
|           <Button | ||||
|             icon="i-lucide-ellipsis-vertical" | ||||
|             color="slate" | ||||
|             size="xs" | ||||
|             class="rounded-md group-hover:bg-n-alpha-2" | ||||
|             @click="toggleDropdown()" | ||||
|           /> | ||||
|           <DropdownMenu | ||||
|             v-if="showActionsDropdown" | ||||
|             :menu-items="menuItems" | ||||
|             class="mt-1 ltr:right-0 rtl:right-0 top-full" | ||||
|             @action="handleAction($event)" | ||||
|           /> | ||||
|         </Policy> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="flex items-center justify-between w-full gap-4"> | ||||
|       <div class="flex items-center gap-3 flex-1"> | ||||
|         <span | ||||
|           v-if="description" | ||||
|           class="text-sm truncate text-n-slate-11 flex-1" | ||||
|         > | ||||
|           {{ description }} | ||||
|         </span> | ||||
|         <span | ||||
|           v-if="authType !== 'none'" | ||||
|           class="text-sm shrink-0 text-n-slate-11 inline-flex items-center gap-1" | ||||
|         > | ||||
|           <i class="i-lucide-lock text-base" /> | ||||
|           {{ authTypeLabel }} | ||||
|         </span> | ||||
|       </div> | ||||
|       <span class="text-sm text-n-slate-11 line-clamp-1 shrink-0"> | ||||
|         {{ timestamp }} | ||||
|       </span> | ||||
|     </div> | ||||
|   </CardLayout> | ||||
| </template> | ||||
| @@ -0,0 +1,271 @@ | ||||
| <script setup> | ||||
| import { reactive, computed, useTemplateRef, watch } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useVuelidate } from '@vuelidate/core'; | ||||
| import { required } from '@vuelidate/validators'; | ||||
| import { useMapGetter } from 'dashboard/composables/store'; | ||||
|  | ||||
| import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; | ||||
| import ParamRow from './ParamRow.vue'; | ||||
| import AuthConfig from './AuthConfig.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   mode: { | ||||
|     type: String, | ||||
|     default: 'create', | ||||
|     validator: value => ['create', 'edit'].includes(value), | ||||
|   }, | ||||
|   tool: { | ||||
|     type: Object, | ||||
|     default: () => ({}), | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['submit', 'cancel']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const formState = { | ||||
|   uiFlags: useMapGetter('captainCustomTools/getUIFlags'), | ||||
| }; | ||||
|  | ||||
| const initialState = { | ||||
|   title: '', | ||||
|   description: '', | ||||
|   endpoint_url: '', | ||||
|   http_method: 'GET', | ||||
|   request_template: '', | ||||
|   response_template: '', | ||||
|   auth_type: 'none', | ||||
|   auth_config: {}, | ||||
|   param_schema: [], | ||||
| }; | ||||
|  | ||||
| const state = reactive({ ...initialState }); | ||||
|  | ||||
| // Populate form when in edit mode | ||||
| watch( | ||||
|   () => props.tool, | ||||
|   newTool => { | ||||
|     if (props.mode === 'edit' && newTool && newTool.id) { | ||||
|       state.title = newTool.title || ''; | ||||
|       state.description = newTool.description || ''; | ||||
|       state.endpoint_url = newTool.endpoint_url || ''; | ||||
|       state.http_method = newTool.http_method || 'GET'; | ||||
|       state.request_template = newTool.request_template || ''; | ||||
|       state.response_template = newTool.response_template || ''; | ||||
|       state.auth_type = newTool.auth_type || 'none'; | ||||
|       state.auth_config = newTool.auth_config || {}; | ||||
|       state.param_schema = newTool.param_schema || []; | ||||
|     } | ||||
|   }, | ||||
|   { immediate: true } | ||||
| ); | ||||
|  | ||||
| const DEFAULT_PARAM = { | ||||
|   name: '', | ||||
|   type: 'string', | ||||
|   description: '', | ||||
|   required: false, | ||||
| }; | ||||
|  | ||||
| const validationRules = { | ||||
|   title: { required }, | ||||
|   endpoint_url: { required }, | ||||
|   http_method: { required }, | ||||
|   auth_type: { required }, | ||||
| }; | ||||
|  | ||||
| const httpMethodOptions = computed(() => [ | ||||
|   { value: 'GET', label: 'GET' }, | ||||
|   { value: 'POST', label: 'POST' }, | ||||
| ]); | ||||
|  | ||||
| const authTypeOptions = computed(() => [ | ||||
|   { value: 'none', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.NONE') }, | ||||
|   { value: 'bearer', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BEARER') }, | ||||
|   { value: 'basic', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BASIC') }, | ||||
|   { | ||||
|     value: 'api_key', | ||||
|     label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.API_KEY'), | ||||
|   }, | ||||
| ]); | ||||
|  | ||||
| const v$ = useVuelidate(validationRules, state); | ||||
|  | ||||
| const isLoading = computed(() => | ||||
|   props.mode === 'edit' | ||||
|     ? formState.uiFlags.value.updatingItem | ||||
|     : formState.uiFlags.value.creatingItem | ||||
| ); | ||||
|  | ||||
| const getErrorMessage = (field, errorKey) => { | ||||
|   return v$.value[field].$error | ||||
|     ? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`) | ||||
|     : ''; | ||||
| }; | ||||
|  | ||||
| const formErrors = computed(() => ({ | ||||
|   title: getErrorMessage('title', 'TITLE'), | ||||
|   endpoint_url: getErrorMessage('endpoint_url', 'ENDPOINT_URL'), | ||||
| })); | ||||
|  | ||||
| const paramsRef = useTemplateRef('paramsRef'); | ||||
|  | ||||
| const isParamsValid = () => { | ||||
|   if (!paramsRef.value || paramsRef.value.length === 0) { | ||||
|     return true; | ||||
|   } | ||||
|   return paramsRef.value.every(param => param.validate()); | ||||
| }; | ||||
|  | ||||
| const removeParam = index => { | ||||
|   state.param_schema.splice(index, 1); | ||||
| }; | ||||
|  | ||||
| const addParam = () => { | ||||
|   state.param_schema.push({ ...DEFAULT_PARAM }); | ||||
| }; | ||||
|  | ||||
| const handleCancel = () => emit('cancel'); | ||||
|  | ||||
| const handleSubmit = async () => { | ||||
|   const isFormValid = await v$.value.$validate(); | ||||
|   if (!isFormValid || !isParamsValid()) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   emit('submit', state); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <form | ||||
|     class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll" | ||||
|     @submit.prevent="handleSubmit" | ||||
|   > | ||||
|     <Input | ||||
|       v-model="state.title" | ||||
|       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.LABEL')" | ||||
|       :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.PLACEHOLDER')" | ||||
|       :message="formErrors.title" | ||||
|       :message-type="formErrors.title ? 'error' : 'info'" | ||||
|     /> | ||||
|  | ||||
|     <TextArea | ||||
|       v-model="state.description" | ||||
|       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.LABEL')" | ||||
|       :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.PLACEHOLDER')" | ||||
|       :rows="2" | ||||
|     /> | ||||
|  | ||||
|     <div class="flex gap-2"> | ||||
|       <div class="flex flex-col gap-1 w-28"> | ||||
|         <label class="mb-0.5 text-sm font-medium text-n-slate-12"> | ||||
|           {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }} | ||||
|         </label> | ||||
|         <ComboBox | ||||
|           v-model="state.http_method" | ||||
|           :options="httpMethodOptions" | ||||
|           class="[&>div>button]:bg-n-alpha-black2 [&_li]:font-mono [&_button]:font-mono [&>div>button]:outline-offset-[-1px]" | ||||
|         /> | ||||
|       </div> | ||||
|       <Input | ||||
|         v-model="state.endpoint_url" | ||||
|         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.LABEL')" | ||||
|         :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.PLACEHOLDER')" | ||||
|         :message="formErrors.endpoint_url" | ||||
|         :message-type="formErrors.endpoint_url ? 'error' : 'info'" | ||||
|         class="flex-1" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <div class="flex flex-col gap-1"> | ||||
|       <label class="mb-0.5 text-sm font-medium text-n-slate-12"> | ||||
|         {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }} | ||||
|       </label> | ||||
|       <ComboBox | ||||
|         v-model="state.auth_type" | ||||
|         :options="authTypeOptions" | ||||
|         class="[&>div>button]:bg-n-alpha-black2" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <AuthConfig | ||||
|       v-model:auth-config="state.auth_config" | ||||
|       :auth-type="state.auth_type" | ||||
|     /> | ||||
|  | ||||
|     <div class="flex flex-col gap-2"> | ||||
|       <label class="text-sm font-medium text-n-slate-12"> | ||||
|         {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }} | ||||
|       </label> | ||||
|       <p class="text-xs text-n-slate-11 -mt-1"> | ||||
|         {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }} | ||||
|       </p> | ||||
|       <ul v-if="state.param_schema.length > 0" class="grid gap-2 list-none"> | ||||
|         <ParamRow | ||||
|           v-for="(param, index) in state.param_schema" | ||||
|           :key="index" | ||||
|           ref="paramsRef" | ||||
|           v-model:name="param.name" | ||||
|           v-model:type="param.type" | ||||
|           v-model:description="param.description" | ||||
|           v-model:required="param.required" | ||||
|           @remove="removeParam(index)" | ||||
|         /> | ||||
|       </ul> | ||||
|       <Button | ||||
|         type="button" | ||||
|         sm | ||||
|         ghost | ||||
|         blue | ||||
|         icon="i-lucide-plus" | ||||
|         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_PARAMETER')" | ||||
|         @click="addParam" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <TextArea | ||||
|       v-if="state.http_method === 'POST'" | ||||
|       v-model="state.request_template" | ||||
|       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.LABEL')" | ||||
|       :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.PLACEHOLDER')" | ||||
|       :rows="4" | ||||
|       class="[&_textarea]:font-mono" | ||||
|     /> | ||||
|  | ||||
|     <TextArea | ||||
|       v-model="state.response_template" | ||||
|       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.LABEL')" | ||||
|       :placeholder=" | ||||
|         t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.PLACEHOLDER') | ||||
|       " | ||||
|       :rows="4" | ||||
|       class="[&_textarea]:font-mono" | ||||
|     /> | ||||
|  | ||||
|     <div class="flex gap-3 justify-between items-center w-full"> | ||||
|       <Button | ||||
|         type="button" | ||||
|         variant="faded" | ||||
|         color="slate" | ||||
|         :label="t('CAPTAIN.FORM.CANCEL')" | ||||
|         class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3" | ||||
|         @click="handleCancel" | ||||
|       /> | ||||
|       <Button | ||||
|         type="submit" | ||||
|         :label=" | ||||
|           t(mode === 'edit' ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE') | ||||
|         " | ||||
|         class="w-full" | ||||
|         :is-loading="isLoading" | ||||
|         :disabled="isLoading" | ||||
|       /> | ||||
|     </div> | ||||
|   </form> | ||||
| </template> | ||||
| @@ -0,0 +1,113 @@ | ||||
| <script setup> | ||||
| import { computed, defineModel, ref, watch } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; | ||||
| import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; | ||||
|  | ||||
| const emit = defineEmits(['remove']); | ||||
| const { t } = useI18n(); | ||||
| const showErrors = ref(false); | ||||
|  | ||||
| const name = defineModel('name', { | ||||
|   type: String, | ||||
|   required: true, | ||||
| }); | ||||
|  | ||||
| const type = defineModel('type', { | ||||
|   type: String, | ||||
|   required: true, | ||||
| }); | ||||
|  | ||||
| const description = defineModel('description', { | ||||
|   type: String, | ||||
|   default: '', | ||||
| }); | ||||
|  | ||||
| const required = defineModel('required', { | ||||
|   type: Boolean, | ||||
|   default: false, | ||||
| }); | ||||
|  | ||||
| const paramTypeOptions = computed(() => [ | ||||
|   { value: 'string', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.STRING') }, | ||||
|   { value: 'number', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.NUMBER') }, | ||||
|   { | ||||
|     value: 'boolean', | ||||
|     label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.BOOLEAN'), | ||||
|   }, | ||||
|   { value: 'array', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.ARRAY') }, | ||||
|   { value: 'object', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.OBJECT') }, | ||||
| ]); | ||||
|  | ||||
| const validationError = computed(() => { | ||||
|   if (!name.value || name.value.trim() === '') { | ||||
|     return 'PARAM_NAME_REQUIRED'; | ||||
|   } | ||||
|   return null; | ||||
| }); | ||||
|  | ||||
| watch([name, type, description, required], () => { | ||||
|   showErrors.value = false; | ||||
| }); | ||||
|  | ||||
| const validate = () => { | ||||
|   showErrors.value = true; | ||||
|   return !validationError.value; | ||||
| }; | ||||
|  | ||||
| defineExpose({ validate }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <li class="list-none"> | ||||
|     <div | ||||
|       class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2" | ||||
|       :class="{ | ||||
|         'animate-wiggle border-n-ruby-9': showErrors && validationError, | ||||
|       }" | ||||
|     > | ||||
|       <div class="flex flex-col flex-1 gap-3"> | ||||
|         <div class="grid grid-cols-3 gap-2"> | ||||
|           <Input | ||||
|             v-model="name" | ||||
|             :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_NAME.PLACEHOLDER')" | ||||
|             class="col-span-2" | ||||
|           /> | ||||
|           <ComboBox | ||||
|             v-model="type" | ||||
|             :options="paramTypeOptions" | ||||
|             :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPE.PLACEHOLDER')" | ||||
|             class="[&>div>button]:bg-n-alpha-black2" | ||||
|           /> | ||||
|         </div> | ||||
|         <Input | ||||
|           v-model="description" | ||||
|           :placeholder=" | ||||
|             t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_DESCRIPTION.PLACEHOLDER') | ||||
|           " | ||||
|         /> | ||||
|         <label class="flex items-center gap-2 cursor-pointer"> | ||||
|           <Checkbox v-model="required" /> | ||||
|           <span class="text-sm text-n-slate-11"> | ||||
|             {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }} | ||||
|           </span> | ||||
|         </label> | ||||
|       </div> | ||||
|       <Button | ||||
|         solid | ||||
|         slate | ||||
|         icon="i-lucide-trash" | ||||
|         class="flex-shrink-0" | ||||
|         @click.stop="emit('remove')" | ||||
|       /> | ||||
|     </div> | ||||
|     <span | ||||
|       v-if="showErrors && validationError" | ||||
|       class="block mt-1 text-sm text-n-ruby-11" | ||||
|     > | ||||
|       {{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }} | ||||
|     </span> | ||||
|   </li> | ||||
| </template> | ||||
| @@ -0,0 +1,29 @@ | ||||
| <script setup> | ||||
| import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
|  | ||||
| const emit = defineEmits(['click']); | ||||
|  | ||||
| const onClick = () => { | ||||
|   emit('click'); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <EmptyStateLayout | ||||
|     :title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')" | ||||
|     :subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')" | ||||
|     :action-perms="['administrator']" | ||||
|   > | ||||
|     <template #empty-state-item> | ||||
|       <div class="min-h-[600px]" /> | ||||
|     </template> | ||||
|     <template #actions> | ||||
|       <Button | ||||
|         :label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')" | ||||
|         icon="i-lucide-plus" | ||||
|         @click="onClick" | ||||
|       /> | ||||
|     </template> | ||||
|   </EmptyStateLayout> | ||||
| </template> | ||||
| @@ -232,6 +232,11 @@ const menuItems = computed(() => { | ||||
|           label: t('SIDEBAR.CAPTAIN_RESPONSES'), | ||||
|           to: accountScopedRoute('captain_responses_index'), | ||||
|         }, | ||||
|         { | ||||
|           name: 'Tools', | ||||
|           label: t('SIDEBAR.CAPTAIN_TOOLS'), | ||||
|           to: accountScopedRoute('captain_tools_index'), | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|     { | ||||
|   | ||||
| @@ -750,6 +750,115 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "CUSTOM_TOOLS": { | ||||
|       "HEADER": "Tools", | ||||
|       "ADD_NEW": "Create a new tool", | ||||
|       "EMPTY_STATE": { | ||||
|         "TITLE": "No custom tools available", | ||||
|         "SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.", | ||||
|         "FEATURE_SPOTLIGHT": { | ||||
|           "TITLE": "Custom Tools", | ||||
|           "NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities." | ||||
|         } | ||||
|       }, | ||||
|       "FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs", | ||||
|       "OPTIONS": { | ||||
|         "EDIT_TOOL": "Edit tool", | ||||
|         "DELETE_TOOL": "Delete tool" | ||||
|       }, | ||||
|       "CREATE": { | ||||
|         "TITLE": "Create Custom Tool", | ||||
|         "SUCCESS_MESSAGE": "Custom tool created successfully", | ||||
|         "ERROR_MESSAGE": "Failed to create custom tool" | ||||
|       }, | ||||
|       "EDIT": { | ||||
|         "TITLE": "Edit Custom Tool", | ||||
|         "SUCCESS_MESSAGE": "Custom tool updated successfully", | ||||
|         "ERROR_MESSAGE": "Failed to update custom tool" | ||||
|       }, | ||||
|       "DELETE": { | ||||
|         "TITLE": "Delete Custom Tool", | ||||
|         "DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.", | ||||
|         "CONFIRM": "Yes, delete", | ||||
|         "SUCCESS_MESSAGE": "Custom tool deleted successfully", | ||||
|         "ERROR_MESSAGE": "Failed to delete custom tool" | ||||
|       }, | ||||
|       "FORM": { | ||||
|         "TITLE": { | ||||
|           "LABEL": "Tool Name", | ||||
|           "PLACEHOLDER": "Order Lookup", | ||||
|           "ERROR": "Tool name is required" | ||||
|         }, | ||||
|         "DESCRIPTION": { | ||||
|           "LABEL": "Description", | ||||
|           "PLACEHOLDER": "Looks up order details by order ID" | ||||
|         }, | ||||
|         "HTTP_METHOD": { | ||||
|           "LABEL": "Method" | ||||
|         }, | ||||
|         "ENDPOINT_URL": { | ||||
|           "LABEL": "Endpoint URL", | ||||
|           "PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}", | ||||
|           "ERROR": "Valid URL is required" | ||||
|         }, | ||||
|         "AUTH_TYPE": { | ||||
|           "LABEL": "Authentication Type" | ||||
|         }, | ||||
|         "AUTH_TYPES": { | ||||
|           "NONE": "None", | ||||
|           "BEARER": "Bearer Token", | ||||
|           "BASIC": "Basic Auth", | ||||
|           "API_KEY": "API Key" | ||||
|         }, | ||||
|         "AUTH_CONFIG": { | ||||
|           "BEARER_TOKEN": "Bearer Token", | ||||
|           "BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token", | ||||
|           "USERNAME": "Username", | ||||
|           "USERNAME_PLACEHOLDER": "Enter username", | ||||
|           "PASSWORD": "Password", | ||||
|           "PASSWORD_PLACEHOLDER": "Enter password", | ||||
|           "API_KEY": "Header Name", | ||||
|           "API_KEY_PLACEHOLDER": "X-API-Key", | ||||
|           "API_VALUE": "Header Value", | ||||
|           "API_VALUE_PLACEHOLDER": "Enter API key value" | ||||
|         }, | ||||
|         "PARAMETERS": { | ||||
|           "LABEL": "Parameters", | ||||
|           "HELP_TEXT": "Define the parameters that will be extracted from user queries" | ||||
|         }, | ||||
|         "ADD_PARAMETER": "Add Parameter", | ||||
|         "PARAM_NAME": { | ||||
|           "PLACEHOLDER": "Parameter name (e.g., order_id)" | ||||
|         }, | ||||
|         "PARAM_TYPE": { | ||||
|           "PLACEHOLDER": "Type" | ||||
|         }, | ||||
|         "PARAM_TYPES": { | ||||
|           "STRING": "String", | ||||
|           "NUMBER": "Number", | ||||
|           "BOOLEAN": "Boolean", | ||||
|           "ARRAY": "Array", | ||||
|           "OBJECT": "Object" | ||||
|         }, | ||||
|         "PARAM_DESCRIPTION": { | ||||
|           "PLACEHOLDER": "Description of the parameter" | ||||
|         }, | ||||
|         "PARAM_REQUIRED": { | ||||
|           "LABEL": "Required" | ||||
|         }, | ||||
|         "REQUEST_TEMPLATE": { | ||||
|           "LABEL": "Request Body Template (Optional)", | ||||
|           "PLACEHOLDER": "{'{'}\n  \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}" | ||||
|         }, | ||||
|         "RESPONSE_TEMPLATE": { | ||||
|           "LABEL": "Response Template (Optional)", | ||||
|           "PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}" | ||||
|         }, | ||||
|         "ERRORS": { | ||||
|           "PARAM_NAME_REQUIRED": "Parameter name is required" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "RESPONSES": { | ||||
|       "HEADER": "FAQs", | ||||
|       "ADD_NEW": "Create new FAQ", | ||||
|   | ||||
| @@ -304,6 +304,7 @@ | ||||
|     "CAPTAIN_ASSISTANTS": "Assistants", | ||||
|     "CAPTAIN_DOCUMENTS": "Documents", | ||||
|     "CAPTAIN_RESPONSES": "FAQs", | ||||
|     "CAPTAIN_TOOLS": "Tools", | ||||
|     "HOME": "Home", | ||||
|     "AGENTS": "Agents", | ||||
|     "AGENT_BOTS": "Bots", | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue'; | ||||
| import AssistantScenariosIndex from './assistants/scenarios/Index.vue'; | ||||
| import DocumentsIndex from './documents/Index.vue'; | ||||
| import ResponsesIndex from './responses/Index.vue'; | ||||
| import CustomToolsIndex from './tools/Index.vue'; | ||||
|  | ||||
| export const routes = [ | ||||
|   { | ||||
| @@ -124,4 +125,17 @@ export const routes = [ | ||||
|       ], | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     path: frontendURL('accounts/:accountId/captain/tools'), | ||||
|     component: CustomToolsIndex, | ||||
|     name: 'captain_tools_index', | ||||
|     meta: { | ||||
|       permissions: ['administrator', 'agent'], | ||||
|       featureFlag: FEATURE_FLAGS.CAPTAIN_V2, | ||||
|       installationTypes: [ | ||||
|         INSTALLATION_TYPES.CLOUD, | ||||
|         INSTALLATION_TYPES.ENTERPRISE, | ||||
|       ], | ||||
|     }, | ||||
|   }, | ||||
| ]; | ||||
|   | ||||
| @@ -0,0 +1,138 @@ | ||||
| <script setup> | ||||
| import { computed, onMounted, ref, nextTick } from 'vue'; | ||||
| import { useMapGetter, useStore } from 'dashboard/composables/store'; | ||||
| import { FEATURE_FLAGS } from 'dashboard/featureFlags'; | ||||
|  | ||||
| import PageLayout from 'dashboard/components-next/captain/PageLayout.vue'; | ||||
| import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue'; | ||||
| import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue'; | ||||
| import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue'; | ||||
| import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue'; | ||||
| import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue'; | ||||
|  | ||||
| const store = useStore(); | ||||
|  | ||||
| const uiFlags = useMapGetter('captainCustomTools/getUIFlags'); | ||||
| const customTools = useMapGetter('captainCustomTools/getRecords'); | ||||
| const isFetching = computed(() => uiFlags.value.fetchingList); | ||||
| const customToolsMeta = useMapGetter('captainCustomTools/getMeta'); | ||||
|  | ||||
| const createDialogRef = ref(null); | ||||
| const deleteDialogRef = ref(null); | ||||
| const selectedTool = ref(null); | ||||
| const dialogType = ref(''); | ||||
|  | ||||
| const fetchCustomTools = (page = 1) => { | ||||
|   store.dispatch('captainCustomTools/get', { page }); | ||||
| }; | ||||
|  | ||||
| const onPageChange = page => fetchCustomTools(page); | ||||
|  | ||||
| const openCreateDialog = () => { | ||||
|   dialogType.value = 'create'; | ||||
|   selectedTool.value = null; | ||||
|   nextTick(() => createDialogRef.value.dialogRef.open()); | ||||
| }; | ||||
|  | ||||
| const handleEdit = tool => { | ||||
|   dialogType.value = 'edit'; | ||||
|   selectedTool.value = tool; | ||||
|   nextTick(() => createDialogRef.value.dialogRef.open()); | ||||
| }; | ||||
|  | ||||
| const handleDelete = tool => { | ||||
|   selectedTool.value = tool; | ||||
|   nextTick(() => deleteDialogRef.value.dialogRef.open()); | ||||
| }; | ||||
|  | ||||
| const handleAction = ({ action, id }) => { | ||||
|   const tool = customTools.value.find(t => t.id === id); | ||||
|   if (action === 'edit') { | ||||
|     handleEdit(tool); | ||||
|   } else if (action === 'delete') { | ||||
|     handleDelete(tool); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const handleDialogClose = () => { | ||||
|   dialogType.value = ''; | ||||
|   selectedTool.value = null; | ||||
| }; | ||||
|  | ||||
| const onDeleteSuccess = () => { | ||||
|   selectedTool.value = null; | ||||
|   // Check if page will be empty after deletion | ||||
|   if (customTools.value.length === 1 && customToolsMeta.value.page > 1) { | ||||
|     // Go to previous page if current page will be empty | ||||
|     onPageChange(customToolsMeta.value.page - 1); | ||||
|   } else { | ||||
|     // Refresh current page | ||||
|     fetchCustomTools(customToolsMeta.value.page); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| onMounted(() => { | ||||
|   fetchCustomTools(); | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <PageLayout | ||||
|     :header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')" | ||||
|     :button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')" | ||||
|     :button-policy="['administrator']" | ||||
|     :total-count="customToolsMeta.totalCount" | ||||
|     :current-page="customToolsMeta.page" | ||||
|     :show-pagination-footer="!isFetching && !!customTools.length" | ||||
|     :is-fetching="isFetching" | ||||
|     :is-empty="!customTools.length" | ||||
|     :feature-flag="FEATURE_FLAGS.CAPTAIN_V2" | ||||
|     @update:current-page="onPageChange" | ||||
|     @click="openCreateDialog" | ||||
|   > | ||||
|     <template #paywall> | ||||
|       <CaptainPaywall /> | ||||
|     </template> | ||||
|  | ||||
|     <template #emptyState> | ||||
|       <CustomToolsPageEmptyState @click="openCreateDialog" /> | ||||
|     </template> | ||||
|  | ||||
|     <template #body> | ||||
|       <div class="flex flex-col gap-4"> | ||||
|         <CustomToolCard | ||||
|           v-for="tool in customTools" | ||||
|           :id="tool.id" | ||||
|           :key="tool.id" | ||||
|           :title="tool.title" | ||||
|           :description="tool.description" | ||||
|           :endpoint-url="tool.endpoint_url" | ||||
|           :http-method="tool.http_method" | ||||
|           :auth-type="tool.auth_type" | ||||
|           :param-schema="tool.param_schema" | ||||
|           :enabled="tool.enabled" | ||||
|           :created-at="tool.created_at" | ||||
|           :updated-at="tool.updated_at" | ||||
|           @action="handleAction" | ||||
|         /> | ||||
|       </div> | ||||
|     </template> | ||||
|   </PageLayout> | ||||
|  | ||||
|   <CreateCustomToolDialog | ||||
|     v-if="dialogType" | ||||
|     ref="createDialogRef" | ||||
|     :type="dialogType" | ||||
|     :selected-tool="selectedTool" | ||||
|     @close="handleDialogClose" | ||||
|   /> | ||||
|  | ||||
|   <DeleteDialog | ||||
|     v-if="selectedTool" | ||||
|     ref="deleteDialogRef" | ||||
|     :entity="selectedTool" | ||||
|     type="CustomTools" | ||||
|     translation-key="CUSTOM_TOOLS" | ||||
|     @delete-success="onDeleteSuccess" | ||||
|   /> | ||||
| </template> | ||||
							
								
								
									
										35
									
								
								app/javascript/dashboard/store/captain/customTools.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/javascript/dashboard/store/captain/customTools.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import CaptainCustomTools from 'dashboard/api/captain/customTools'; | ||||
| import { createStore } from './storeFactory'; | ||||
| import { throwErrorMessage } from 'dashboard/store/utils/api'; | ||||
|  | ||||
| export default createStore({ | ||||
|   name: 'CaptainCustomTool', | ||||
|   API: CaptainCustomTools, | ||||
|   actions: mutations => ({ | ||||
|     update: async ({ commit }, { id, ...updateObj }) => { | ||||
|       commit(mutations.SET_UI_FLAG, { updatingItem: true }); | ||||
|       try { | ||||
|         const response = await CaptainCustomTools.update(id, updateObj); | ||||
|         commit(mutations.EDIT, response.data); | ||||
|         commit(mutations.SET_UI_FLAG, { updatingItem: false }); | ||||
|         return response.data; | ||||
|       } catch (error) { | ||||
|         commit(mutations.SET_UI_FLAG, { updatingItem: false }); | ||||
|         return throwErrorMessage(error); | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     delete: async ({ commit }, id) => { | ||||
|       commit(mutations.SET_UI_FLAG, { deletingItem: true }); | ||||
|       try { | ||||
|         await CaptainCustomTools.delete(id); | ||||
|         commit(mutations.DELETE, id); | ||||
|         commit(mutations.SET_UI_FLAG, { deletingItem: false }); | ||||
|         return id; | ||||
|       } catch (error) { | ||||
|         commit(mutations.SET_UI_FLAG, { deletingItem: false }); | ||||
|         return throwErrorMessage(error); | ||||
|       } | ||||
|     }, | ||||
|   }), | ||||
| }); | ||||
| @@ -3,7 +3,7 @@ import CaptainToolsAPI from '../../api/captain/tools'; | ||||
| import { throwErrorMessage } from 'dashboard/store/utils/api'; | ||||
|  | ||||
| const toolsStore = createStore({ | ||||
|   name: 'captainTool', | ||||
|   name: 'Tools', | ||||
|   API: CaptainToolsAPI, | ||||
|   actions: mutations => ({ | ||||
|     getTools: async ({ commit }) => { | ||||
|   | ||||
| @@ -57,6 +57,7 @@ import copilotThreads from './captain/copilotThreads'; | ||||
| import copilotMessages from './captain/copilotMessages'; | ||||
| import captainScenarios from './captain/scenarios'; | ||||
| import captainTools from './captain/tools'; | ||||
| import captainCustomTools from './captain/customTools'; | ||||
|  | ||||
| const plugins = []; | ||||
|  | ||||
| @@ -119,6 +120,7 @@ export default createStore({ | ||||
|     copilotMessages, | ||||
|     captainScenarios, | ||||
|     captainTools, | ||||
|     captainCustomTools, | ||||
|   }, | ||||
|   plugins, | ||||
| }); | ||||
|   | ||||
| @@ -336,6 +336,8 @@ en: | ||||
|       processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})' | ||||
|       chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}' | ||||
|       page_processing_error: 'Error processing pages %{start}-%{end}: %{error}' | ||||
|     custom_tool: | ||||
|       slug_generation_failed: 'Unable to generate unique slug after 5 attempts' | ||||
|   public_portal: | ||||
|     search: | ||||
|       search_placeholder: Search for article by title or body... | ||||
|   | ||||
| @@ -67,6 +67,7 @@ Rails.application.routes.draw do | ||||
|             resources :copilot_threads, only: [:index, :create] do | ||||
|               resources :copilot_messages, only: [:index, :create] | ||||
|             end | ||||
|             resources :custom_tools | ||||
|             resources :documents, only: [:index, :show, :create, :destroy] | ||||
|           end | ||||
|           resource :saml_settings, only: [:show, :create, :update, :destroy] | ||||
|   | ||||
| @@ -0,0 +1,49 @@ | ||||
| class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController | ||||
|   before_action :current_account | ||||
|   before_action -> { check_authorization(Captain::CustomTool) } | ||||
|   before_action :set_custom_tool, only: [:show, :update, :destroy] | ||||
|  | ||||
|   def index | ||||
|     @custom_tools = account_custom_tools.enabled | ||||
|   end | ||||
|  | ||||
|   def show; end | ||||
|  | ||||
|   def create | ||||
|     @custom_tool = account_custom_tools.create!(custom_tool_params) | ||||
|   end | ||||
|  | ||||
|   def update | ||||
|     @custom_tool.update!(custom_tool_params) | ||||
|   end | ||||
|  | ||||
|   def destroy | ||||
|     @custom_tool.destroy | ||||
|     head :no_content | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_custom_tool | ||||
|     @custom_tool = account_custom_tools.find(params[:id]) | ||||
|   end | ||||
|  | ||||
|   def account_custom_tools | ||||
|     @account_custom_tools ||= Current.account.captain_custom_tools | ||||
|   end | ||||
|  | ||||
|   def custom_tool_params | ||||
|     params.require(:custom_tool).permit( | ||||
|       :title, | ||||
|       :description, | ||||
|       :endpoint_url, | ||||
|       :http_method, | ||||
|       :request_template, | ||||
|       :response_template, | ||||
|       :auth_type, | ||||
|       :enabled, | ||||
|       auth_config: {}, | ||||
|       param_schema: [:name, :type, :description, :required] | ||||
|     ) | ||||
|   end | ||||
| end | ||||
| @@ -29,6 +29,8 @@ class Captain::CustomTool < ApplicationRecord | ||||
|  | ||||
|   self.table_name = 'captain_custom_tools' | ||||
|  | ||||
|   NAME_PREFIX = 'custom'.freeze | ||||
|   NAME_SEPARATOR = '_'.freeze | ||||
|   PARAM_SCHEMA_VALIDATION = { | ||||
|     'type': 'array', | ||||
|     'items': { | ||||
| @@ -73,16 +75,23 @@ class Captain::CustomTool < ApplicationRecord | ||||
|  | ||||
|   def generate_slug | ||||
|     return if slug.present? | ||||
|     return if title.blank? | ||||
|  | ||||
|     base_slug = title.present? ? "custom_#{title.parameterize}" : "custom_#{SecureRandom.uuid}" | ||||
|     paramterized_title = title.parameterize(separator: NAME_SEPARATOR) | ||||
|  | ||||
|     base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}" | ||||
|     self.slug = find_unique_slug(base_slug) | ||||
|   end | ||||
|  | ||||
|   def find_unique_slug(base_slug, counter = 0) | ||||
|     slug_candidate = counter.zero? ? base_slug : "#{base_slug}-#{counter}" | ||||
|     return find_unique_slug(base_slug, counter + 1) if slug_exists?(slug_candidate) | ||||
|   def find_unique_slug(base_slug) | ||||
|     return base_slug unless slug_exists?(base_slug) | ||||
|  | ||||
|     slug_candidate | ||||
|     5.times do | ||||
|       slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}" | ||||
|       return slug_candidate unless slug_exists?(slug_candidate) | ||||
|     end | ||||
|  | ||||
|     raise ActiveRecord::RecordNotUnique, I18n.t('captain.custom_tool.slug_generation_failed') | ||||
|   end | ||||
|  | ||||
|   def slug_exists?(candidate) | ||||
|   | ||||
| @@ -3,7 +3,10 @@ module Concerns::Toolable | ||||
|  | ||||
|   def tool(assistant) | ||||
|     custom_tool_record = self | ||||
|     # Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize) | ||||
|     class_name = custom_tool_record.slug.underscore.camelize | ||||
|  | ||||
|     # Always create a fresh class to reflect current metadata | ||||
|     tool_class = Class.new(Captain::Tools::HttpTool) do | ||||
|       description custom_tool_record.description | ||||
|  | ||||
| @@ -15,6 +18,16 @@ module Concerns::Toolable | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     # Register the dynamically created class as a constant in the Captain::Tools namespace. | ||||
|     # This is required because RubyLLM's Tool base class derives the tool name from the class name | ||||
|     # (via Class#name). Anonymous classes created with Class.new have no name and return empty strings, | ||||
|     # which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API. | ||||
|     # By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup") | ||||
|     # which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API. | ||||
|     # We refresh the constant on each call to ensure tool metadata changes are reflected. | ||||
|     Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false) | ||||
|     Captain::Tools.const_set(class_name, tool_class) | ||||
|  | ||||
|     tool_class.new(assistant, self) | ||||
|   end | ||||
|  | ||||
|   | ||||
							
								
								
									
										21
									
								
								enterprise/app/policies/captain/custom_tool_policy.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								enterprise/app/policies/captain/custom_tool_policy.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| class Captain::CustomToolPolicy < ApplicationPolicy | ||||
|   def index? | ||||
|     true | ||||
|   end | ||||
|  | ||||
|   def show? | ||||
|     true | ||||
|   end | ||||
|  | ||||
|   def create? | ||||
|     @account_user.administrator? | ||||
|   end | ||||
|  | ||||
|   def update? | ||||
|     @account_user.administrator? | ||||
|   end | ||||
|  | ||||
|   def destroy? | ||||
|     @account_user.administrator? | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1 @@ | ||||
| json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool | ||||
| @@ -0,0 +1,10 @@ | ||||
| json.payload do | ||||
|   json.array! @custom_tools do |custom_tool| | ||||
|     json.partial! 'api/v1/models/captain/custom_tool', custom_tool: custom_tool | ||||
|   end | ||||
| end | ||||
|  | ||||
| json.meta do | ||||
|   json.total_count @custom_tools.count | ||||
|   json.page 1 | ||||
| end | ||||
| @@ -0,0 +1 @@ | ||||
| json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool | ||||
| @@ -0,0 +1 @@ | ||||
| json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool | ||||
| @@ -0,0 +1,15 @@ | ||||
| json.id custom_tool.id | ||||
| json.slug custom_tool.slug | ||||
| json.title custom_tool.title | ||||
| json.description custom_tool.description | ||||
| json.endpoint_url custom_tool.endpoint_url | ||||
| json.http_method custom_tool.http_method | ||||
| json.request_template custom_tool.request_template | ||||
| json.response_template custom_tool.response_template | ||||
| json.auth_type custom_tool.auth_type | ||||
| json.auth_config custom_tool.auth_config | ||||
| json.param_schema custom_tool.param_schema | ||||
| json.enabled custom_tool.enabled | ||||
| json.account_id custom_tool.account_id | ||||
| json.created_at custom_tool.created_at.to_i | ||||
| json.updated_at custom_tool.updated_at.to_i | ||||
| @@ -0,0 +1,281 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do | ||||
|   let(:account) { create(:account) } | ||||
|   let(:admin) { create(:user, account: account, role: :administrator) } | ||||
|   let(:agent) { create(:user, account: account, role: :agent) } | ||||
|  | ||||
|   def json_response | ||||
|     JSON.parse(response.body, symbolize_names: true) | ||||
|   end | ||||
|  | ||||
|   describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools' do | ||||
|     context 'when it is an un-authenticated user' do | ||||
|       it 'returns unauthorized status' do | ||||
|         get "/api/v1/accounts/#{account.id}/captain/custom_tools" | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when it is an agent' do | ||||
|       it 'returns success status' do | ||||
|         create_list(:captain_custom_tool, 3, account: account) | ||||
|         get "/api/v1/accounts/#{account.id}/captain/custom_tools", | ||||
|             headers: agent.create_new_auth_token, | ||||
|             as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(json_response[:payload].length).to eq(3) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when it is an admin' do | ||||
|       it 'returns success status and custom tools' do | ||||
|         create_list(:captain_custom_tool, 5, account: account) | ||||
|         get "/api/v1/accounts/#{account.id}/captain/custom_tools", | ||||
|             headers: admin.create_new_auth_token, | ||||
|             as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(json_response[:payload].length).to eq(5) | ||||
|       end | ||||
|  | ||||
|       it 'returns only enabled custom tools' do | ||||
|         create(:captain_custom_tool, account: account, enabled: true) | ||||
|         create(:captain_custom_tool, account: account, enabled: false) | ||||
|         get "/api/v1/accounts/#{account.id}/captain/custom_tools", | ||||
|             headers: admin.create_new_auth_token, | ||||
|             as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(json_response[:payload].length).to eq(1) | ||||
|         expect(json_response[:payload].first[:enabled]).to be(true) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do | ||||
|     let(:custom_tool) { create(:captain_custom_tool, account: account) } | ||||
|  | ||||
|     context 'when it is an un-authenticated user' do | ||||
|       it 'returns unauthorized status' do | ||||
|         get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}" | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when it is an agent' do | ||||
|       it 'returns success status and custom tool' do | ||||
|         get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", | ||||
|             headers: agent.create_new_auth_token, | ||||
|             as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(json_response[:id]).to eq(custom_tool.id) | ||||
|         expect(json_response[:title]).to eq(custom_tool.title) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when custom tool does not exist' do | ||||
|       it 'returns not found status' do | ||||
|         get "/api/v1/accounts/#{account.id}/captain/custom_tools/999999", | ||||
|             headers: agent.create_new_auth_token | ||||
|  | ||||
|         expect(response).to have_http_status(:not_found) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'POST /api/v1/accounts/{account.id}/captain/custom_tools' do | ||||
|     let(:valid_attributes) do | ||||
|       { | ||||
|         custom_tool: { | ||||
|           title: 'Fetch Order Status', | ||||
|           description: 'Fetches order status from external API', | ||||
|           endpoint_url: 'https://api.example.com/orders/{{ order_id }}', | ||||
|           http_method: 'GET', | ||||
|           enabled: true, | ||||
|           param_schema: [ | ||||
|             { name: 'order_id', type: 'string', description: 'The order ID', required: true } | ||||
|           ] | ||||
|         } | ||||
|       } | ||||
|     end | ||||
|  | ||||
|     context 'when it is an un-authenticated user' do | ||||
|       it 'returns unauthorized status' do | ||||
|         post "/api/v1/accounts/#{account.id}/captain/custom_tools", | ||||
|              params: valid_attributes | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when it is an agent' do | ||||
|       it 'returns unauthorized status' do | ||||
|         post "/api/v1/accounts/#{account.id}/captain/custom_tools", | ||||
|              params: valid_attributes, | ||||
|              headers: agent.create_new_auth_token | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when it is an admin' do | ||||
|       it 'creates a new custom tool and returns success status' do | ||||
|         post "/api/v1/accounts/#{account.id}/captain/custom_tools", | ||||
|              params: valid_attributes, | ||||
|              headers: admin.create_new_auth_token, | ||||
|              as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(json_response[:title]).to eq('Fetch Order Status') | ||||
|         expect(json_response[:description]).to eq('Fetches order status from external API') | ||||
|         expect(json_response[:enabled]).to be(true) | ||||
|         expect(json_response[:slug]).to eq('custom_fetch_order_status') | ||||
|         expect(json_response[:param_schema]).to eq([ | ||||
|                                                      { name: 'order_id', type: 'string', description: 'The order ID', required: true } | ||||
|                                                    ]) | ||||
|       end | ||||
|  | ||||
|       context 'with invalid parameters' do | ||||
|         let(:invalid_attributes) do | ||||
|           { | ||||
|             custom_tool: { | ||||
|               title: '', | ||||
|               endpoint_url: '' | ||||
|             } | ||||
|           } | ||||
|         end | ||||
|  | ||||
|         it 'returns unprocessable entity status' do | ||||
|           post "/api/v1/accounts/#{account.id}/captain/custom_tools", | ||||
|                params: invalid_attributes, | ||||
|                headers: admin.create_new_auth_token, | ||||
|                as: :json | ||||
|  | ||||
|           expect(response).to have_http_status(:unprocessable_entity) | ||||
|         end | ||||
|       end | ||||
|  | ||||
|       context 'with invalid endpoint URL' do | ||||
|         let(:invalid_url_attributes) do | ||||
|           { | ||||
|             custom_tool: { | ||||
|               title: 'Test Tool', | ||||
|               endpoint_url: 'http://localhost/api', | ||||
|               http_method: 'GET' | ||||
|             } | ||||
|           } | ||||
|         end | ||||
|  | ||||
|         it 'returns unprocessable entity status' do | ||||
|           post "/api/v1/accounts/#{account.id}/captain/custom_tools", | ||||
|                params: invalid_url_attributes, | ||||
|                headers: admin.create_new_auth_token, | ||||
|                as: :json | ||||
|  | ||||
|           expect(response).to have_http_status(:unprocessable_entity) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'PATCH /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do | ||||
|     let(:custom_tool) { create(:captain_custom_tool, account: account) } | ||||
|     let(:update_attributes) do | ||||
|       { | ||||
|         custom_tool: { | ||||
|           title: 'Updated Tool Title', | ||||
|           enabled: false | ||||
|         } | ||||
|       } | ||||
|     end | ||||
|  | ||||
|     context 'when it is an un-authenticated user' do | ||||
|       it 'returns unauthorized status' do | ||||
|         patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", | ||||
|               params: update_attributes | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when it is an agent' do | ||||
|       it 'returns unauthorized status' do | ||||
|         patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", | ||||
|               params: update_attributes, | ||||
|               headers: agent.create_new_auth_token | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when it is an admin' do | ||||
|       it 'updates the custom tool and returns success status' do | ||||
|         patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", | ||||
|               params: update_attributes, | ||||
|               headers: admin.create_new_auth_token, | ||||
|               as: :json | ||||
|  | ||||
|         expect(response).to have_http_status(:success) | ||||
|         expect(json_response[:title]).to eq('Updated Tool Title') | ||||
|         expect(json_response[:enabled]).to be(false) | ||||
|       end | ||||
|  | ||||
|       context 'with invalid parameters' do | ||||
|         let(:invalid_attributes) do | ||||
|           { | ||||
|             custom_tool: { | ||||
|               title: '' | ||||
|             } | ||||
|           } | ||||
|         end | ||||
|  | ||||
|         it 'returns unprocessable entity status' do | ||||
|           patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", | ||||
|                 params: invalid_attributes, | ||||
|                 headers: admin.create_new_auth_token, | ||||
|                 as: :json | ||||
|  | ||||
|           expect(response).to have_http_status(:unprocessable_entity) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'DELETE /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do | ||||
|     let!(:custom_tool) { create(:captain_custom_tool, account: account) } | ||||
|  | ||||
|     context 'when it is an un-authenticated user' do | ||||
|       it 'returns unauthorized status' do | ||||
|         delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}" | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when it is an agent' do | ||||
|       it 'returns unauthorized status' do | ||||
|         delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", | ||||
|                headers: agent.create_new_auth_token | ||||
|         expect(response).to have_http_status(:unauthorized) | ||||
|       end | ||||
|     end | ||||
|  | ||||
|     context 'when it is an admin' do | ||||
|       it 'deletes the custom tool and returns no content status' do | ||||
|         expect do | ||||
|           delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", | ||||
|                  headers: admin.create_new_auth_token | ||||
|         end.to change(Captain::CustomTool, :count).by(-1) | ||||
|  | ||||
|         expect(response).to have_http_status(:no_content) | ||||
|       end | ||||
|  | ||||
|       context 'when custom tool does not exist' do | ||||
|         it 'returns not found status' do | ||||
|           delete "/api/v1/accounts/#{account.id}/captain/custom_tools/999999", | ||||
|                  headers: admin.create_new_auth_token | ||||
|  | ||||
|           expect(response).to have_http_status(:not_found) | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -19,8 +19,8 @@ RSpec.describe Captain::CustomTool, type: :model do | ||||
|       let(:account) { create(:account) } | ||||
|  | ||||
|       it 'validates uniqueness of slug scoped to account' do | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_test-tool') | ||||
|         duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test-tool') | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_test_tool') | ||||
|         duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test_tool') | ||||
|  | ||||
|         expect(duplicate).not_to be_valid | ||||
|         expect(duplicate.errors[:slug]).to include('has already been taken') | ||||
| @@ -28,8 +28,8 @@ RSpec.describe Captain::CustomTool, type: :model do | ||||
|  | ||||
|       it 'allows same slug across different accounts' do | ||||
|         account2 = create(:account) | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_test-tool') | ||||
|         different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test-tool') | ||||
|         create(:captain_custom_tool, account: account, slug: 'custom_test_tool') | ||||
|         different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool') | ||||
|  | ||||
|         expect(different_account_tool).to be_valid | ||||
|       end | ||||
| @@ -114,7 +114,7 @@ RSpec.describe Captain::CustomTool, type: :model do | ||||
|     it 'generates slug from title on creation' do | ||||
|       tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status') | ||||
|  | ||||
|       expect(tool.slug).to eq('custom_fetch-order-status') | ||||
|       expect(tool.slug).to eq('custom_fetch_order_status') | ||||
|     end | ||||
|  | ||||
|     it 'adds custom_ prefix to generated slug' do | ||||
| @@ -124,37 +124,39 @@ RSpec.describe Captain::CustomTool, type: :model do | ||||
|     end | ||||
|  | ||||
|     it 'does not override manually set slug' do | ||||
|       tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual-slug') | ||||
|       tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug') | ||||
|  | ||||
|       expect(tool.slug).to eq('custom_manual-slug') | ||||
|       expect(tool.slug).to eq('custom_manual_slug') | ||||
|     end | ||||
|  | ||||
|     it 'handles slug collisions by appending counter' do | ||||
|       create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool') | ||||
|     it 'handles slug collisions by appending random suffix' do | ||||
|       create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool') | ||||
|       tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool') | ||||
|  | ||||
|       expect(tool2.slug).to eq('custom_test-tool-1') | ||||
|       expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/) | ||||
|     end | ||||
|  | ||||
|     it 'handles multiple slug collisions' do | ||||
|       create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool') | ||||
|       create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool-1') | ||||
|       create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool') | ||||
|       create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool_abc123') | ||||
|       tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool') | ||||
|  | ||||
|       expect(tool3.slug).to eq('custom_test-tool-2') | ||||
|       expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/) | ||||
|       expect(tool3.slug).not_to eq('custom_test_tool') | ||||
|       expect(tool3.slug).not_to eq('custom_test_tool_abc123') | ||||
|     end | ||||
|  | ||||
|     it 'generates slug with UUID when title is blank' do | ||||
|     it 'does not generate slug when title is blank' do | ||||
|       tool = build(:captain_custom_tool, account: account, title: nil) | ||||
|       tool.valid? | ||||
|  | ||||
|       expect(tool.slug).to match(/^custom_[0-9a-f-]+$/) | ||||
|       expect(tool).not_to be_valid | ||||
|       expect(tool.errors[:title]).to include("can't be blank") | ||||
|     end | ||||
|  | ||||
|     it 'parameterizes title correctly' do | ||||
|       tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!') | ||||
|  | ||||
|       expect(tool.slug).to eq('custom_fetch-order-status-details') | ||||
|       expect(tool.slug).to eq('custom_fetch_order_status_details') | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Shivam Mishra
					Shivam Mishra