feat(design): Update the design for the custom attribute console (#10049)

This PR continues the design update series, updates the design for the custom attributes management page. This PR improves the interaction in the Add Custom Attribute feature. Now, the attribute model in the add attribute form will default to the currently selected tab.


---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Pranav
2024-08-29 19:06:11 +05:30
committed by GitHub
parent 6dda1e8c8f
commit 3a47b7e3d1
7 changed files with 274 additions and 247 deletions

View File

@@ -331,10 +331,12 @@ export default {
::v-deep { ::v-deep {
.selector-wrap { .selector-wrap {
@apply m-0 top-1; @apply m-0 top-1;
.selector-name { .selector-name {
@apply ml-0; @apply ml-0;
} }
} }
.name { .name {
@apply ml-0; @apply ml-0;
} }

View File

@@ -3,7 +3,8 @@
"HEADER": "Custom Attributes", "HEADER": "Custom Attributes",
"HEADER_BTN_TXT": "Add Custom Attribute", "HEADER_BTN_TXT": "Add Custom Attribute",
"LOADING": "Fetching custom attributes", "LOADING": "Fetching custom attributes",
"SIDEBAR_TXT": "<p><b>Custom Attributes</b> <p>A custom attribute tracks facts about your contacts/conversation — like the subscription plan, or when they ordered the first item etc. <br /><br />For creating a Custom Attribute, just click on the <b>Add Custom Attribute.</b> You can also edit or delete an existing Custom Attribute by clicking on the Edit or Delete button.</p>", "DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"ADD": { "ADD": {
"TITLE": "Add Custom Attribute", "TITLE": "Add Custom Attribute",
"SUBMIT": "Create", "SUBMIT": "Create",
@@ -91,7 +92,12 @@
"CONTACT": "Contact" "CONTACT": "Contact"
}, },
"LIST": { "LIST": {
"TABLE_HEADER": ["Name", "Description", "Type", "Key"], "TABLE_HEADER": [
"Name",
"Description",
"Type",
"Key"
],
"BUTTONS": { "BUTTONS": {
"EDIT": "Edit", "EDIT": "Edit",
"DELETE": "Delete" "DELETE": "Delete"

View File

@@ -22,17 +22,21 @@ defineProps({
<template> <template>
<div class="flex flex-col w-full h-full gap-10 font-inter"> <div class="flex flex-col w-full h-full gap-10 font-inter">
<slot name="header" /> <slot name="header" />
<slot v-if="isLoading" name="loading"> <!-- Added to render any templates that should be rendered before body -->
<woot-loading-state :message="loadingMessage" /> <div>
</slot> <slot name="preBody" />
<p <slot v-if="isLoading" name="loading">
v-else-if="noRecordsFound" <woot-loading-state :message="loadingMessage" />
class="flex-1 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base" </slot>
> <p
{{ noRecordsMessage }} v-else-if="noRecordsFound"
</p> class="flex-1 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
<slot v-else name="body" /> >
<!-- Do not delete the slot below. It is required to render anything that is not defined in the above slots. --> {{ noRecordsMessage }}
<slot /> </p>
<slot v-else name="body" />
<!-- Do not delete the slot below. It is required to render anything that is not defined in the above slots. -->
<slot />
</div>
</div> </div>
</template> </template>

View File

@@ -12,6 +12,12 @@ export default {
type: Function, type: Function,
default: () => {}, default: () => {},
}, },
// Passes 0 or 1 based on the selected AttributeModel tab selected in the UI
// Needs a better data type, todo: refactor this component later
selectedAttributeModelTab: {
type: Number,
default: 0,
},
}, },
setup() { setup() {
return { v$: useVuelidate() }; return { v$: useVuelidate() };
@@ -20,7 +26,10 @@ export default {
return { return {
displayName: '', displayName: '',
description: '', description: '',
attributeModel: 0, // Using the prop as default. There is no side effect here as the component
// is destroyed completely when the modal is closed. The prop doesn't change
// dynamically when the modal is active.
attributeModel: this.selectedAttributeModelTab || 0,
attributeType: 0, attributeType: 0,
attributeKey: '', attributeKey: '',
regexPattern: null, regexPattern: null,
@@ -280,13 +289,16 @@ export default {
padding: 0 var(--space-small) var(--space-small) 0; padding: 0 var(--space-small) var(--space-small) 0;
font-family: monospace; font-family: monospace;
} }
.multiselect--wrap { .multiselect--wrap {
margin-bottom: var(--space-normal); margin-bottom: var(--space-normal);
.error-message { .error-message {
color: var(--r-400); color: var(--r-400);
font-size: var(--font-size-small); font-size: var(--font-size-small);
font-weight: var(--font-weight-normal); font-weight: var(--font-weight-normal);
} }
.invalid { .invalid {
::v-deep { ::v-deep {
.multiselect__tags { .multiselect__tags {
@@ -295,13 +307,16 @@ export default {
} }
} }
} }
::v-deep { ::v-deep {
.multiselect { .multiselect {
margin-bottom: 0; margin-bottom: 0;
} }
.multiselect__content-wrapper { .multiselect__content-wrapper {
display: none; display: none;
} }
.multiselect--active .multiselect__tags { .multiselect--active .multiselect__tags {
border-radius: var(--border-radius-normal); border-radius: var(--border-radius-normal);
} }

View File

@@ -1,195 +1,141 @@
<script> <script setup>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import EditAttribute from './EditAttribute.vue'; import EditAttribute from './EditAttribute.vue';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
const props = defineProps({
attributeModel: {
type: String,
default: 'conversation_attribute',
},
});
export default { const { t } = useI18n();
components: {
EditAttribute,
},
data() {
return {
selectedTabIndex: 0,
showEditPopup: false,
showDeletePopup: false,
selectedAttribute: {},
};
},
computed: {
...mapGetters({
uiFlags: 'attributes/getUIFlags',
}),
attributes() {
const attributeModel = this.selectedTabIndex
? 'contact_attribute'
: 'conversation_attribute';
return this.$store.getters['attributes/getAttributesByModel']( const showEditPopup = ref(false);
attributeModel const showDeletePopup = ref(false);
); const selectedAttribute = ref({});
},
tabs() { const getters = useStoreGetters();
return [ const store = useStore();
{
key: 0, const attributes = computed(() =>
name: this.$t('ATTRIBUTES_MGMT.TABS.CONVERSATION'), getters['attributes/getAttributesByModel'].value(props.attributeModel)
}, );
{ const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
key: 1,
name: this.$t('ATTRIBUTES_MGMT.TABS.CONTACT'), const attributeDisplayName = computed(
}, () => selectedAttribute.value.attribute_display_name
]; );
}, const deleteConfirmText = computed(
deleteConfirmText() { () =>
return `${this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${ `${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${attributeDisplayName.value}`
this.selectedAttribute.attribute_display_name );
}`; const deleteRejectText = computed(() => t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO'));
}, const confirmDeleteTitle = computed(() =>
deleteRejectText() { t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
return this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO'); attributeName: attributeDisplayName.value,
}, })
confirmDeleteTitle() { );
return this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', { const confirmPlaceHolderText = computed(
attributeName: this.selectedAttribute.attribute_display_name, () =>
}); `${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
}, attributeName: attributeDisplayName.value,
confirmPlaceHolderText() { })}`
return `${this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', { );
attributeName: this.selectedAttribute.attribute_display_name,
})}`; const deleteAttributes = async ({ id }) => {
}, try {
}, await store.dispatch('attributes/delete', id);
mounted() { useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
this.fetchAttributes(this.selectedTabIndex); } catch (error) {
}, const errorMessage =
methods: { error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
onClickTabChange(index) { useAlert(errorMessage);
this.selectedTabIndex = index; }
this.fetchAttributes(index); };
}, const openEditPopup = response => {
fetchAttributes(index) { showEditPopup.value = true;
this.$store.dispatch('attributes/get', index); selectedAttribute.value = response;
}, };
async deleteAttributes({ id }) { const hideEditPopup = () => {
try { showEditPopup.value = false;
await this.$store.dispatch('attributes/delete', id); };
useAlert(this.$t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) { const closeDelete = () => {
const errorMessage = showDeletePopup.value = false;
error?.response?.message || selectedAttribute.value = {};
this.$t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE'); };
useAlert(errorMessage); const confirmDeletion = () => {
} deleteAttributes(selectedAttribute.value);
}, closeDelete();
openEditPopup(response) { };
this.showEditPopup = true; const openDelete = value => {
this.selectedAttribute = response; showDeletePopup.value = true;
}, selectedAttribute.value = value;
hideEditPopup() {
this.showEditPopup = false;
},
confirmDeletion() {
this.deleteAttributes(this.selectedAttribute);
this.closeDelete();
},
openDelete(value) {
this.showDeletePopup = true;
this.selectedAttribute = value;
},
closeDelete() {
this.showDeletePopup = false;
this.selectedAttribute = {};
},
},
}; };
</script> </script>
<template> <template>
<div class="flex flex-row gap-4 p-8"> <div class="flex flex-col">
<div class="w-full lg:w-3/5"> <table class="min-w-full overflow-x-auto">
<woot-tabs :index="selectedTabIndex" @change="onClickTabChange"> <thead>
<woot-tabs-item <th
v-for="tab in tabs" v-for="tableHeader in $t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER')"
:key="tab.key" :key="tableHeader"
:name="tab.name" class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-slate-700 dark:text-slate-300"
:show-badge="false"
/>
</woot-tabs>
<div class="w-full">
<p
v-if="!uiFlags.isFetching && !attributes.length"
class="flex items-center justify-center mt-12"
> >
{{ $t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404') }} {{ tableHeader }}
</p> </th>
<woot-loading-state </thead>
v-if="uiFlags.isFetching" <tbody
:message="$t('ATTRIBUTES_MGMT.LOADING')" class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
/> >
<table <tr v-for="attribute in attributes" :key="attribute.attribute_key">
v-if="!uiFlags.isFetching && attributes.length" <td
class="w-full mt-2 table-fixed woot-table" class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
> >
<thead> {{ attribute.attribute_display_name }}
<th </td>
v-for="tableHeader in $t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER')" <td class="py-4 ltr:pr-4 rtl:pl-4">
:key="tableHeader" {{ attribute.attribute_description }}
class="pl-0 max-w-[6.25rem] min-w-[5rem]" </td>
> <td
{{ tableHeader }} class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
</th> >
</thead> {{ attribute.attribute_display_type }}
<tbody> </td>
<tr v-for="attribute in attributes" :key="attribute.attribute_key"> <td
<td class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
class="pl-0 max-w-[6.25rem] min-w-[5rem] overflow-hidden whitespace-nowrap text-ellipsis" >
> {{ attribute.attribute_key }}
{{ attribute.attribute_display_name }} </td>
</td> <td class="py-4 min-w-xs">
<td <div class="flex gap-1">
class="pl-0 max-w-[10rem] min-w-[6.25rem] overflow-hidden whitespace-nowrap text-ellipsis" <woot-button
> v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
{{ attribute.attribute_description }} variant="smooth"
</td> size="tiny"
<td color-scheme="secondary"
class="pl-0 max-w-[6.25rem] min-w-[5rem] overflow-hidden whitespace-nowrap text-ellipsis" class-names="grey-btn"
> icon="edit"
{{ attribute.attribute_display_type }} @click="openEditPopup(attribute)"
</td> />
<td <woot-button
class="attribute-key pl-0 max-w-[6.25rem] min-w-[5rem] overflow-hidden whitespace-nowrap text-ellipsis" v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
> variant="smooth"
{{ attribute.attribute_key }} color-scheme="alert"
</td> size="tiny"
<td class="button-wrapper"> icon="dismiss-circle"
<woot-button class-names="grey-btn"
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')" @click="openDelete(attribute)"
variant="smooth" />
size="tiny" </div>
color-scheme="secondary" </td>
class-names="grey-btn" </tr>
icon="edit" </tbody>
@click="openEditPopup(attribute)" </table>
/>
<woot-button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
class-names="grey-btn"
@click="openDelete(attribute)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="hidden w-1/3 lg:block">
<span v-dompurify-html="$t('ATTRIBUTES_MGMT.SIDEBAR_TXT')" />
</div>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup"> <woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<EditAttribute <EditAttribute
:selected-attribute="selectedAttribute" :selected-attribute="selectedAttribute"
@@ -216,17 +162,4 @@ export default {
.attribute-key { .attribute-key {
font-family: monospace; font-family: monospace;
} }
::v-deep {
.tabs--container {
.tabs {
@apply p-0;
}
}
.tabs-title a {
font-weight: var(--font-weight-medium);
padding-top: 0;
}
}
</style> </style>

View File

@@ -1,40 +1,112 @@
<script> <script setup>
import { computed, onMounted, ref } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AddAttribute from './AddAttribute.vue'; import AddAttribute from './AddAttribute.vue';
import CustomAttribute from './CustomAttribute.vue'; import CustomAttribute from './CustomAttribute.vue';
export default { import SettingsLayout from '../SettingsLayout.vue';
components: { import { useI18n } from 'dashboard/composables/useI18n';
AddAttribute, import { useStoreGetters, useStore } from 'dashboard/composables/store';
CustomAttribute,
}, const { t } = useI18n();
data() {
return { const getters = useStoreGetters();
showAddPopup: false, const store = useStore();
};
}, const showAddPopup = ref(false);
methods: { const selectedTabIndex = ref(0);
openAddPopup() { const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
this.showAddPopup = true;
const openAddPopup = () => {
showAddPopup.value = true;
};
const hideAddPopup = () => {
showAddPopup.value = false;
};
const tabs = computed(() => {
return [
{
key: 0,
name: t('ATTRIBUTES_MGMT.TABS.CONVERSATION'),
}, },
hideAddPopup() { {
this.showAddPopup = false; key: 1,
name: t('ATTRIBUTES_MGMT.TABS.CONTACT'),
}, },
}, ];
});
onMounted(() => {
store.dispatch('attributes/get');
});
const attributeModel = computed(() =>
selectedTabIndex.value ? 'contact_attribute' : 'conversation_attribute'
);
const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(attributeModel.value)
);
const onClickTabChange = index => {
selectedTabIndex.value = index;
}; };
</script> </script>
<template> <template>
<div class="flex-1 overflow-auto"> <SettingsLayout
<woot-button :is-loading="uiFlags.isFetching"
color-scheme="success" :loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
class-names="button--fixed-top" :no-records-found="!attributes.length"
icon="add-circle" :no-records-message="$t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404')"
@click="openAddPopup()" >
<template #header>
<BaseSettingsHeader
:title="$t('ATTRIBUTES_MGMT.HEADER')"
:description="$t('ATTRIBUTES_MGMT.DESCRIPTION')"
:link-text="$t('ATTRIBUTES_MGMT.LEARN_MORE')"
feature-name="custom_attributes"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
@click="openAddPopup"
>
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
</template>
<template #preBody>
<woot-tabs
class="font-medium [&_.tabs]:p-0 mb-4"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
</template>
<template #body>
<CustomAttribute
:key="attributeModel"
:attribute-model="attributeModel"
/>
</template>
<woot-modal
v-if="showAddPopup"
:show.sync="showAddPopup"
:on-close="hideAddPopup"
> >
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }} <AddAttribute
</woot-button> :on-close="hideAddPopup"
<CustomAttribute /> :selected-attribute-model-tab="selectedTabIndex"
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup"> />
<AddAttribute :on-close="hideAddPopup" />
</woot-modal> </woot-modal>
</div> </SettingsLayout>
</template> </template>

View File

@@ -1,17 +1,12 @@
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue'); const SettingsWrapper = () => import('../SettingsWrapper.vue');
const AttributesHome = () => import('./Index.vue'); const AttributesHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
path: frontendURL('accounts/:accountId/settings/custom-attributes'), path: frontendURL('accounts/:accountId/settings/custom-attributes'),
component: SettingsContent, component: SettingsWrapper,
props: {
headerTitle: 'ATTRIBUTES_MGMT.HEADER',
icon: 'code',
showNewButton: false,
},
children: [ children: [
{ {
path: '', path: '',