Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

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