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,273 @@
<script setup>
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import SearchableDropdown from './SearchableDropdown.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const teams = ref([]);
const assignees = ref([]);
const projects = ref([]);
const labels = ref([]);
const statuses = ref([]);
const priorities = [
{ id: 0, name: 'No priority' },
{ id: 1, name: 'Urgent' },
{ id: 2, name: 'High' },
{ id: 3, name: 'Normal' },
{ id: 4, name: 'Low' },
];
const statusDesiredOrder = [
'Backlog',
'Todo',
'In Progress',
'Done',
'Canceled',
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '0.75rem', fontSize: '0.875rem' };
const formState = reactive({
title: '',
description: '',
teamId: '',
assigneeId: '',
labelId: '',
stateId: '',
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
() => v$.value.title.$invalid || isCreating.value
);
const nameError = computed(() =>
v$.value.title.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
: ''
);
const teamError = computed(() =>
v$.value.teamId.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
: ''
);
const dropdowns = computed(() => {
return [
{
type: 'teamId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL',
items: teams.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.SEARCH',
error: teamError.value,
},
{
type: 'assigneeId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL',
items: assignees.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.SEARCH',
error: '',
},
{
type: 'labelId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL',
items: labels.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.SEARCH',
error: '',
},
{
type: 'priority',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL',
items: priorities,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.SEARCH',
error: '',
},
{
type: 'projectId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL',
items: projects.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.SEARCH',
error: '',
},
{
type: 'stateId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL',
items: statuses.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.SEARCH',
error: '',
},
];
});
const onClose = () => emit('close');
const getTeams = async () => {
try {
const response = await LinearAPI.getTeams();
teams.value = response.data;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
);
useAlert(errorMessage);
}
};
const getTeamEntities = async () => {
try {
const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users;
labels.value = response.data.labels;
projects.value = response.data.projects;
statuses.value = statusDesiredOrder
.map(name => response.data.states.find(status => status.name === name))
.filter(Boolean);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
);
useAlert(errorMessage);
}
};
const onChange = (item, type) => {
formState[type] = item.id;
if (type === 'teamId') {
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
formState.projectId = '';
getTeamEntities();
}
};
const createIssue = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
team_id: formState.teamId,
title: formState.title,
description: formState.description || undefined,
assignee_id: formState.assigneeId || undefined,
project_id: formState.projectId || undefined,
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
conversation_id: props.conversationId,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { identifier: issueIdentifier } = response.data;
await LinearAPI.link_issue(
props.conversationId,
issueIdentifier,
props.title
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
);
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
onMounted(getTeams);
</script>
<template>
<div>
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '0.375rem 0.75rem' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
"
:error="nameError"
@input="v$.title.$touch"
/>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="formState.description"
:style="{ ...inputStyles, padding: '0.5rem 0.75rem' }"
rows="3"
class="text-sm"
:placeholder="
$t(
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
)
"
/>
</label>
<div class="flex flex-col gap-4">
<SearchableDropdown
v-for="dropdown in dropdowns"
:key="dropdown.type"
:type="dropdown.type"
:value="formState[dropdown.type]"
:label="$t(dropdown.label)"
:items="dropdown.items"
:placeholder="$t(dropdown.placeholder)"
:error="dropdown.error"
@change="onChange"
/>
</div>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<Button
faded
slate
type="reset"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL')"
@click.prevent="onClose"
/>
<Button
type="submit"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE')"
:disabled="isSubmitDisabled"
:is-loading="isCreating"
@click.prevent="createIssue"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,95 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import LinkIssue from './LinkIssue.vue';
import CreateIssue from './CreateIssue.vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const selectedTabIndex = ref(0);
const title = computed(() => {
const { meta: { sender: { name = null } = {} } = {} } = props.conversation;
return t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_TITLE', {
conversationId: props.conversation.id,
name,
});
});
const tabs = ref([
{
key: 0,
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
},
{
key: 1,
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
},
]);
const onClose = () => {
emit('close');
};
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
:header-content="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4 mt-1">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0 h-10"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="(tab, index) in tabs"
:key="tab.key"
:index="index"
:name="tab.name"
:show-badge="false"
is-compact
/>
</woot-tabs>
</div>
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
<CreateIssue
:account-id="accountId"
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
<div v-else class="flex flex-col px-8 pb-4">
<LinkIssue
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
identifier: {
type: String,
required: true,
},
issueUrl: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlinkIssue']);
const unlinkIssue = () => {
emit('unlinkIssue');
};
const openIssue = () => {
window.open(props.issueUrl, '_blank');
};
</script>
<template>
<div class="flex items-center justify-between">
<div
class="flex items-center gap-2 px-2 py-1.5 border rounded-lg border-n-strong"
>
<div class="flex items-center gap-1">
<fluent-icon
icon="linear"
size="16"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-n-slate-12">
{{ identifier }}
</span>
</div>
<span class="w-px h-3 text-n-weak bg-n-weak" />
<Button
link
xs
slate
icon="i-lucide-arrow-up-right"
class="!size-4"
@click="openIssue"
/>
</div>
<Button ghost xs slate icon="i-lucide-unlink" @click="unlinkIssue" />
</div>
</template>

View File

@@ -0,0 +1,132 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useAlert, useTrack } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import LinearIssueItem from './LinearIssueItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const getters = useStoreGetters();
const linkedIssues = ref([]);
const isLoading = ref(false);
const shouldShowCreateModal = ref(false);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(
() => getters.getConversationById.value(props.conversationId) || {}
);
const hasIssues = computed(() => linkedIssues.value.length > 0);
const loadLinkedIssues = async () => {
isLoading.value = true;
linkedIssues.value = [];
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
linkedIssues.value = response.data || [];
} catch (error) {
// Silent fail - not critical for UX
} finally {
isLoading.value = false;
}
};
const unlinkIssue = async (linkId, issueIdentifier) => {
try {
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssues.value = linkedIssues.value.filter(
issue => issue.id !== linkId
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
}
};
const openCreateModal = () => {
shouldShowCreateModal.value = true;
};
const closeCreateModal = () => {
shouldShowCreateModal.value = false;
loadLinkedIssues();
};
watch(
() => props.conversationId,
() => {
loadLinkedIssues();
}
);
onMounted(() => {
loadLinkedIssues();
});
</script>
<template>
<div>
<div class="px-4 pt-3 pb-2">
<NextButton
ghost
xs
icon="i-lucide-plus"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
@click="openCreateModal"
/>
</div>
<div v-if="isLoading" class="flex justify-center p-8">
<Spinner />
</div>
<div v-else-if="!hasIssues" class="flex justify-center p-4">
<p class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES') }}
</p>
</div>
<div v-else class="max-h-[300px] overflow-y-auto">
<LinearIssueItem
v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id"
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue"
@unlink-issue="unlinkIssue"
/>
</div>
<woot-modal
v-model:show="shouldShowCreateModal"
:on-close="closeCreateModal"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<CreateOrLinkIssue
:conversation="conversation"
:account-id="currentAccountId"
@close="closeCreateModal"
/>
</woot-modal>
</div>
</template>

View File

@@ -0,0 +1,113 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import IssueHeader from './IssueHeader.vue';
const props = defineProps({
linkedIssue: {
type: Object,
required: true,
},
});
const emit = defineEmits(['unlinkIssue']);
const { linkedIssue } = props;
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const issue = computed(() => linkedIssue.issue);
const assignee = computed(() => {
const assigneeDetails = issue.value.assignee;
if (!assigneeDetails) return null;
return {
name: assigneeDetails.name,
thumbnail: assigneeDetails.avatarUrl,
};
});
const labels = computed(() => issue.value.labels?.nodes || []);
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
const unlinkIssue = () => {
emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier);
};
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col w-full">
<IssueHeader
:identifier="issue.identifier"
:link-id="linkedIssue.id"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<h3 class="mt-2 text-sm font-medium text-n-slate-12">
{{ issue.title }}
</h3>
<p
v-if="issue.description"
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
>
{{ issue.description }}
</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div v-if="assignee" class="flex items-center gap-1.5">
<Avatar :src="assignee.thumbnail" :name="assignee.name" :size="16" />
<span class="text-xs capitalize truncate text-n-slate-12">
{{ assignee.name }}
</span>
</div>
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
<div class="flex items-center gap-1">
<Icon
icon="i-lucide-activity"
class="size-4"
:style="{ color: issue.state?.color }"
/>
<span class="text-xs text-n-slate-12">
{{ issue.state?.name }}
</span>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
<div v-if="priorityLabel" class="flex items-center gap-1.5">
<CardPriorityIcon :priority="priorityLabel.toLowerCase()" />
<span class="text-xs text-n-slate-12">
{{ priorityLabel }}
</span>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
const { isAdmin } = useAdmin();
const getters = useStoreGetters();
const accountId = getters.getCurrentAccountId;
const integrationId = 'linear';
const actionURL = computed(() =>
frontendURL(
`accounts/${accountId.value}/settings/integrations/${integrationId}`
)
);
const openLinearAccount = () => {
window.open(actionURL.value, '_blank');
};
</script>
<template>
<div class="flex flex-col p-3">
<div class="w-12 h-12 mb-3">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:hidden dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
class="hidden object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:block"
/>
</div>
<div class="flex-1 mb-4">
<h3 class="mb-1.5 text-sm font-medium text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.TITLE') }}
</h3>
<p v-if="isAdmin" class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.DESCRIPTION') }}
</p>
<p v-else class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.AGENT_DESCRIPTION') }}
</p>
</div>
<NextButton v-if="isAdmin" faded slate @click="openLinearAccount">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.BUTTON_TEXT') }}
</NextButton>
</div>
</template>

View File

@@ -0,0 +1,150 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const issues = ref([]);
const shouldShowDropdown = ref(false);
const selectedOption = ref({ id: null, name: '' });
const isFetching = ref(false);
const isLinking = ref(false);
const searchQuery = ref('');
const toggleDropdown = () => {
issues.value = [];
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const linkIssueTitle = computed(() => {
return selectedOption.value.id
? selectedOption.value.name
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
});
const isSubmitDisabled = computed(() => {
return !selectedOption.value.id || isLinking.value;
});
const onSelectIssue = item => {
selectedOption.value = item;
toggleDropdown();
};
const onClose = () => {
emit('close');
};
const onSearch = async value => {
issues.value = [];
if (!value) return;
searchQuery.value = value;
try {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.identifier,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')
);
useAlert(errorMessage);
} finally {
isFetching.value = false;
}
};
const linkIssue = async () => {
const { id: issueId } = selectedOption.value;
try {
isLinking.value = true;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
searchQuery.value = '';
issues.value = [];
onClose();
useTrack(LINEAR_EVENTS.LINK_ISSUE);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')
);
useAlert(errorMessage);
} finally {
isLinking.value = false;
}
};
</script>
<template>
<div
class="flex flex-col justify-between"
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
>
<FilterButton
trailing-icon
icon="i-lucide-chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl bg-n-alpha-black2 outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<FilterListDropdown
v-if="issues"
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="issues"
:active-filter-id="selectedOption.id"
:is-loading="isFetching"
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
:loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@on-search="onSearch"
@select="onSelectIssue"
/>
</template>
</FilterButton>
<div class="flex items-center justify-end w-full gap-2 mt-2">
<Button
faded
slate
type="reset"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL')"
@click.prevent="onClose"
/>
<Button
type="submit"
:label="$t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE')"
:disabled="isSubmitDisabled"
:is-loading="isLinking"
@click.prevent="linkIssue"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup>
import { ref, computed, defineOptions } from 'vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
const props = defineProps({
type: { type: String, required: true },
label: { type: String, default: null },
items: { type: Array, required: true },
value: { type: [Number, String], default: null },
placeholder: { type: String, default: null },
error: { type: String, default: null },
});
const emit = defineEmits(['change']);
defineOptions({
name: 'SearchableDropdown',
});
const shouldShowDropdown = ref(false);
const toggleDropdown = () => {
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const onSelect = item => {
emit('change', item, props.type);
toggleDropdown();
};
const hasError = computed(() => !!props.error);
const selectedItem = computed(() => {
if (!props.value) return null;
return props.items.find(i => i.id === props.value);
});
const selectedItemName = computed(
() => selectedItem.value?.name || props.placeholder
);
const selectedItemId = computed(() => selectedItem.value?.id || null);
</script>
<template>
<div
class="flex w-full"
:class="type === 'stateId' && shouldShowDropdown ? 'h-[150px]' : 'gap-2'"
>
<label class="w-full" :class="{ error: hasError }">
{{ label }}
<FilterButton
trailing-icon
icon="i-lucide-chevron-down"
:button-text="selectedItemName"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl bg-n-alpha-black2 outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<FilterListDropdown
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="items"
:active-filter-id="selectedItemId"
:input-placeholder="placeholder"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@select="onSelect"
/>
</template>
</FilterButton>
<span v-if="hasError" class="mt-1 message">{{ error }}</span>
</label>
</div>
</template>

View File

@@ -0,0 +1,10 @@
import { required } from '@vuelidate/validators';
export default {
title: {
required,
},
teamId: {
required,
},
};