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:
Shivam Mishra
2025-10-06 21:35:54 +05:30
committed by GitHub
parent 8bbb8ba5a4
commit 9fb0dfa4a7
29 changed files with 1474 additions and 24 deletions

View 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();

View File

@@ -10,6 +10,10 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
translationKey: {
type: String,
required: true,
},
entity: { entity: {
type: Object, type: Object,
required: true, required: true,
@@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const deleteDialogRef = ref(null); const deleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase()); const i18nKey = computed(() => {
return props.translationKey || props.type.toUpperCase();
});
const deleteEntity = async payload => { const deleteEntity = async payload => {
if (!payload) return; if (!payload) return;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -232,6 +232,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN_RESPONSES'), label: t('SIDEBAR.CAPTAIN_RESPONSES'),
to: accountScopedRoute('captain_responses_index'), to: accountScopedRoute('captain_responses_index'),
}, },
{
name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'),
to: accountScopedRoute('captain_tools_index'),
},
], ],
}, },
{ {

View File

@@ -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": { "RESPONSES": {
"HEADER": "FAQs", "HEADER": "FAQs",
"ADD_NEW": "Create new FAQ", "ADD_NEW": "Create new FAQ",

View File

@@ -304,6 +304,7 @@
"CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents", "CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs", "CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "Home", "HOME": "Home",
"AGENTS": "Agents", "AGENTS": "Agents",
"AGENT_BOTS": "Bots", "AGENT_BOTS": "Bots",

View File

@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue'; import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
import DocumentsIndex from './documents/Index.vue'; import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue'; import ResponsesIndex from './responses/Index.vue';
import CustomToolsIndex from './tools/Index.vue';
export const routes = [ 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,
],
},
},
]; ];

View File

@@ -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>

View 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);
}
},
}),
});

View File

@@ -3,7 +3,7 @@ import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api'; import { throwErrorMessage } from 'dashboard/store/utils/api';
const toolsStore = createStore({ const toolsStore = createStore({
name: 'captainTool', name: 'Tools',
API: CaptainToolsAPI, API: CaptainToolsAPI,
actions: mutations => ({ actions: mutations => ({
getTools: async ({ commit }) => { getTools: async ({ commit }) => {

View File

@@ -57,6 +57,7 @@ import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages'; import copilotMessages from './captain/copilotMessages';
import captainScenarios from './captain/scenarios'; import captainScenarios from './captain/scenarios';
import captainTools from './captain/tools'; import captainTools from './captain/tools';
import captainCustomTools from './captain/customTools';
const plugins = []; const plugins = [];
@@ -119,6 +120,7 @@ export default createStore({
copilotMessages, copilotMessages,
captainScenarios, captainScenarios,
captainTools, captainTools,
captainCustomTools,
}, },
plugins, plugins,
}); });

View File

@@ -336,6 +336,8 @@ en:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})' processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}' chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}' page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
custom_tool:
slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal: public_portal:
search: search:
search_placeholder: Search for article by title or body... search_placeholder: Search for article by title or body...

View File

@@ -67,6 +67,7 @@ Rails.application.routes.draw do
resources :copilot_threads, only: [:index, :create] do resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create] resources :copilot_messages, only: [:index, :create]
end end
resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy] resources :documents, only: [:index, :show, :create, :destroy]
end end
resource :saml_settings, only: [:show, :create, :update, :destroy] resource :saml_settings, only: [:show, :create, :update, :destroy]

View File

@@ -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

View File

@@ -29,6 +29,8 @@ class Captain::CustomTool < ApplicationRecord
self.table_name = 'captain_custom_tools' self.table_name = 'captain_custom_tools'
NAME_PREFIX = 'custom'.freeze
NAME_SEPARATOR = '_'.freeze
PARAM_SCHEMA_VALIDATION = { PARAM_SCHEMA_VALIDATION = {
'type': 'array', 'type': 'array',
'items': { 'items': {
@@ -73,16 +75,23 @@ class Captain::CustomTool < ApplicationRecord
def generate_slug def generate_slug
return if slug.present? 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) self.slug = find_unique_slug(base_slug)
end end
def find_unique_slug(base_slug, counter = 0) def find_unique_slug(base_slug)
slug_candidate = counter.zero? ? base_slug : "#{base_slug}-#{counter}" return base_slug unless slug_exists?(base_slug)
return find_unique_slug(base_slug, counter + 1) if slug_exists?(slug_candidate)
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 end
def slug_exists?(candidate) def slug_exists?(candidate)

View File

@@ -3,7 +3,10 @@ module Concerns::Toolable
def tool(assistant) def tool(assistant)
custom_tool_record = self 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 tool_class = Class.new(Captain::Tools::HttpTool) do
description custom_tool_record.description description custom_tool_record.description
@@ -15,6 +18,16 @@ module Concerns::Toolable
end end
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) tool_class.new(assistant, self)
end end

View 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

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool

View File

@@ -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

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool

View File

@@ -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

View File

@@ -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

View File

@@ -19,8 +19,8 @@ RSpec.describe Captain::CustomTool, type: :model do
let(:account) { create(:account) } let(:account) { create(:account) }
it 'validates uniqueness of slug scoped to account' do it 'validates uniqueness of slug scoped to account' do
create(: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') duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test_tool')
expect(duplicate).not_to be_valid expect(duplicate).not_to be_valid
expect(duplicate.errors[:slug]).to include('has already been taken') 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 it 'allows same slug across different accounts' do
account2 = create(:account) account2 = create(:account)
create(:captain_custom_tool, account: account, 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') different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool')
expect(different_account_tool).to be_valid expect(different_account_tool).to be_valid
end end
@@ -114,7 +114,7 @@ RSpec.describe Captain::CustomTool, type: :model do
it 'generates slug from title on creation' do it 'generates slug from title on creation' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status') 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 end
it 'adds custom_ prefix to generated slug' do it 'adds custom_ prefix to generated slug' do
@@ -124,37 +124,39 @@ RSpec.describe Captain::CustomTool, type: :model do
end end
it 'does not override manually set slug' do 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 end
it 'handles slug collisions by appending counter' do it 'handles slug collisions by appending random suffix' 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')
tool2 = create(:captain_custom_tool, account: account, title: '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 end
it 'handles multiple slug collisions' do 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')
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_abc123')
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool') 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 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 = 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 end
it 'parameterizes title correctly' do it 'parameterizes title correctly' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!') 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
end end