feat: Add conditions row component (#10496)

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Shivam Mishra
2024-11-26 06:03:12 +05:30
committed by GitHub
parent c23cd094f9
commit b0287fe389
6 changed files with 822 additions and 1 deletions

View File

@@ -57,3 +57,5 @@ exclude_patterns:
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
- '**/fixtures/**'
- '**/*/fixtures.js'

View File

@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue';
import ConditionRow from './ConditionRow.vue';
import Button from 'next/button/Button.vue';
import { filterTypes } from './fixtures/filterTypes.js';
const DEFAULT_FILTER = {
attributeKey: 'status',
filterOperator: 'equal_to',
values: [],
queryOperator: 'and',
};
const filters = ref([{ ...DEFAULT_FILTER }]);
const removeFilter = index => {
filters.value.splice(index, 1);
};
const showQueryOperator = true;
const addFilter = () => {
filters.value.push({ ...DEFAULT_FILTER });
};
</script>
<template>
<Story
title="Components/Filters/ConditionRow"
:layout="{ type: 'grid', width: '600px' }"
>
<div class="min-h-[400px] p-2 space-y-2">
<template v-for="(filter, index) in filters" :key="`filter-${index}`">
<ConditionRow
v-if="index === 0"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:show-query-operator="false"
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
v-model:query-operator="filters[index - 1].queryOperator"
:show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
<Button sm label="Add Filter" @click="addFilter" />
</div>
</Story>
</template>

View File

@@ -0,0 +1,197 @@
<script setup>
import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import FilterSelect from './inputs/FilterSelect.vue';
import MultiSelect from './inputs/MultiSelect.vue';
import SingleSelect from './inputs/SingleSelect.vue';
import { validateSingleFilter } from 'dashboard/helper/validations.js';
// filterTypes: import('vue').ComputedRef<FilterType[]>
const { filterTypes } = defineProps({
showQueryOperator: { type: Boolean, default: false },
filterTypes: { type: Array, required: true },
});
const emit = defineEmits(['remove']);
const { t } = useI18n();
const showErrors = ref(false);
const attributeKey = defineModel('attributeKey', {
type: String,
required: true,
});
const values = defineModel('values', {
type: [String, Number, Array, Object],
required: true,
});
const filterOperator = defineModel('filterOperator', {
type: String,
required: true,
});
const queryOperator = defineModel('queryOperator', {
type: String,
required: false,
default: undefined,
validator: value => ['and', 'or'].includes(value),
});
const getFilterFromFilterTypes = key =>
filterTypes.find(filterObj => filterObj.attributeKey === key);
const currentFilter = computed(() =>
getFilterFromFilterTypes(attributeKey.value)
);
const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter.filterOperators.find(
operator => operator.value === selectedOperator
);
if (!operatorFromOptions) {
return filter.filterOperators[0];
}
return operatorFromOptions;
};
const currentOperator = computed(() =>
getOperator(currentFilter.value, filterOperator.value)
);
const getInputType = (operator, filter) =>
operator.inputOverride ?? filter.inputType;
const inputType = computed(() =>
getInputType(currentOperator.value, currentFilter.value)
);
const queryOperatorOptions = computed(() => {
return [
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
value: 'and',
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
},
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
value: 'or',
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
},
];
});
const booleanOptions = computed(() => [
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
]);
const validationError = computed(() => {
return validateSingleFilter({
attributeKey: attributeKey.value,
filter_operator: filterOperator.value,
values: values.value,
});
});
const resetModelOnAttributeKeyChange = newAttributeKey => {
/**
* Resets the filter values and operator when the attribute key changes. This ensures that
* the values and operator remain compatible with the new attribute type. For example,
* switching from a text field to a multi-select should reset the value from '' (empty string)
* to an empty array.
*/
const filter = getFilterFromFilterTypes(newAttributeKey);
const newOperator = getOperator(filter, filterOperator.value);
const newInputType = getInputType(newOperator, filter);
if (newInputType === 'multiSelect') {
values.value = [];
} else if (['searchSelect', 'booleanSelect'].includes(newInputType)) {
values.value = {};
} else {
values.value = '';
}
filterOperator.value = newOperator.value;
};
watch([attributeKey, values, filterOperator], () => {
showErrors.value = false;
});
const validate = () => {
showErrors.value = true;
return !validationError.value;
};
defineExpose({ validate });
</script>
<template>
<li class="list-none">
<div
class="flex items-center gap-2 rounded-md"
:class="{
'animate-wiggle': showErrors && validationError,
}"
>
<FilterSelect
v-if="showQueryOperator"
v-model="queryOperator"
variant="faded"
hide-icon
class="text-sm"
:options="queryOperatorOptions"
/>
<FilterSelect
v-model="attributeKey"
variant="faded"
:options="filterTypes"
@update:model-value="resetModelOnAttributeKeyChange"
/>
<FilterSelect
v-model="filterOperator"
variant="ghost"
:options="currentFilter.filterOperators"
/>
<template v-if="currentOperator.hasInput">
<MultiSelect
v-if="inputType === 'multiSelect'"
v-model="values"
:options="currentFilter.options"
/>
<SingleSelect
v-else-if="inputType === 'searchSelect'"
v-model="values"
:options="currentFilter.options"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
v-model="values"
disable-search
:options="booleanOptions"
/>
<input
v-else
v-model="values"
:type="inputType === 'date' ? 'date' : 'text'"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</template>
<Button
sm
solid
slate
icon="i-lucide-trash"
@click.stop="emit('remove')"
/>
</div>
<span v-if="showErrors && validationError" class="text-sm text-n-ruby-11">
{{ t(`FILTER.ERRORS.${validationError}`) }}
</span>
</li>
</template>

View File

@@ -0,0 +1,558 @@
export const filterTypes = [
{
attributeKey: 'status',
value: 'status',
attributeName: 'Status',
label: 'Status',
inputType: 'multiSelect',
options: [
{ id: 'open', name: 'Open' },
{ id: 'resolved', name: 'Resolved' },
{ id: 'pending', name: 'Pending' },
{ id: 'snoozed', name: 'Snoozed' },
{ id: 'all', name: 'All' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'assignee_id',
value: 'assignee_id',
attributeName: 'Assignee name',
label: 'Assignee name',
inputType: 'searchSelect',
options: [
{ id: 14, name: 'Ben Nugent' },
{ id: 30, name: 'Bruce' },
{ id: 16, name: 'Cathy Simms' },
{ id: 7, name: 'Charles Miner' },
{ id: 10, name: 'Craig D' },
{ id: 9, name: 'Dan Gore' },
{ id: 13, name: 'Danny Cordray' },
{ id: 3, name: 'David Wallace' },
{ id: 4, name: 'Deangelo Vickers' },
{ id: 33, name: 'Devon White' },
{ id: 8, name: 'Ed Truck' },
{ id: 31, name: 'Frank' },
{ id: 29, name: 'Gideon' },
{ id: 24, name: 'Glenn Max' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'team_id',
value: 'team_id',
attributeName: 'Team name',
label: 'Team name',
inputType: 'searchSelect',
options: [
{ id: 223, name: '💰 sales' },
{ id: 224, name: '💼 management' },
{ id: 225, name: '👩‍💼 administration' },
{ id: 226, name: '🚛 warehouse' },
],
dataType: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'display_id',
value: 'display_id',
attributeName: 'Conversation identifier',
label: 'Conversation identifier',
inputType: 'plainText',
datatype: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'contains',
label: 'Contains',
hasInput: true,
inputOverride: null,
icon: 'i-ph-superset-of-bold',
},
{
value: 'does_not_contain',
label: 'Does not contain',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-superset-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'campaign_id',
value: 'campaign_id',
attributeName: 'Campaign name',
label: 'Campaign name',
inputType: 'searchSelect',
options: [],
datatype: 'number',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'labels',
value: 'labels',
attributeName: 'Labels',
label: 'Labels',
inputType: 'multiSelect',
options: [
{ id: 'billing', name: 'billing' },
{ id: 'delivery', name: 'delivery' },
{ id: 'lead', name: 'lead' },
{ id: 'ops-handover', name: 'ops-handover' },
{ id: 'premium-customer', name: 'premium-customer' },
{ id: 'software', name: 'software' },
],
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'referer',
value: 'referer',
attributeName: 'Referer link',
label: 'Referer link',
inputType: 'plainText',
dataType: 'text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'contains',
label: 'Contains',
hasInput: true,
inputOverride: null,
icon: 'i-ph-superset-of-bold',
},
{
value: 'does_not_contain',
label: 'Does not contain',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-superset-of',
},
],
attributeModel: 'additional',
},
{
attributeKey: 'created_at',
value: 'created_at',
attributeName: 'Created at',
label: 'Created at',
inputType: 'date',
dataType: 'text',
filterOperators: [
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
{
value: 'days_before',
label: 'Is x days before',
hasInput: true,
inputOverride: 'plainText',
icon: 'i-ph-calendar-minus-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
value: 'last_activity_at',
attributeName: 'Last activity',
label: 'Last activity',
inputType: 'date',
dataType: 'text',
filterOperators: [
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
{
value: 'days_before',
label: 'Is x days before',
hasInput: true,
inputOverride: 'plainText',
icon: 'i-ph-calendar-minus-bold',
},
],
attributeModel: 'standard',
},
{
attributeKey: 'are_you_a_paid_customer',
value: 'are_you_a_paid_customer',
attributeName: 'Are you a paid customer?',
label: 'Are you a paid customer?',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'date_of_purchase',
value: 'date_of_purchase',
attributeName: 'Date of Purchase',
label: 'Date of Purchase',
inputType: 'date',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
{
value: 'is_present',
label: 'Is present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-member-of-bold',
},
{
value: 'is_not_present',
label: 'Is not present',
hasInput: false,
inputOverride: null,
icon: 'i-ph-not-member-of',
},
{
value: 'is_greater_than',
label: 'Is greater than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-greater-than-bold',
},
{
value: 'is_less_than',
label: 'Is lesser than',
hasInput: true,
inputOverride: null,
icon: 'i-ph-less-than-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'your_website',
value: 'your_website',
attributeName: 'Your website',
label: 'Your website',
inputType: 'plainText',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'are_you_residing_in_india',
value: 'are_you_residing_in_india',
attributeName: 'Are you residing in India?',
label: 'Are you residing in India?',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'cloud',
value: 'cloud',
attributeName: 'Cloud',
label: 'Cloud',
inputType: 'booleanSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [],
attributeModel: 'customAttributes',
},
{
attributeKey: 'license_type',
value: 'license_type',
attributeName: 'License Type',
label: 'License Type',
inputType: 'searchSelect',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-equals-bold',
},
{
value: 'not_equal_to',
label: 'Not equal to',
hasInput: true,
inputOverride: null,
icon: 'i-ph-not-equals-bold',
},
],
options: [
{
id: 'Personal',
name: 'Personal',
},
{
id: 'Enterprise',
name: 'Enterprise',
},
{
id: 'Teams',
name: 'Teams',
},
{
id: 'Professional',
name: 'Professional',
},
],
attributeModel: 'customAttributes',
},
];

View File

@@ -20,7 +20,7 @@ export const ATLEAST_ONE_ACTION_REQUIRED = 'ATLEAST_ONE_ACTION_REQUIRED';
*
* @returns {string|null} An error message if validation fails, or null if validation passes.
*/
const validateSingleFilter = filter => {
export const validateSingleFilter = filter => {
if (!filter.attribute_key) {
return ATTRIBUTE_KEY_REQUIRED;
}

View File

@@ -29,6 +29,7 @@ const tailwindConfig = {
'./app/javascript/shared/**/*.vue',
'./app/javascript/survey/**/*.vue',
'./app/javascript/dashboard/helper/**/*.js',
'./app/javascript/dashboard/components-next/**/*.js',
'./app/views/**/*.html.erb',
],
theme: {
@@ -112,6 +113,11 @@ const tailwindConfig = {
collections: {
woot: {
icons: {
'logic-or': {
body: `<rect x="14" y="5" width="2" height="13" rx="1" fill="currentColor"/><rect x="8" y="5" width="2" height="13" rx="1" fill="currentColor"/>`,
width: 24,
height: 24,
},
alert: {
body: `<path d="M1.81348 0.9375L1.69727 7.95117H0.302734L0.179688 0.9375H1.81348ZM1 11.1025C0.494141 11.1025 0.0908203 10.7061 0.0976562 10.2207C0.0908203 9.72852 0.494141 9.33203 1 9.33203C1.49219 9.33203 1.89551 9.72852 1.90234 10.2207C1.89551 10.7061 1.49219 11.1025 1 11.1025Z" fill="currentColor" />`,
width: 2,