## Description * Added the ability to sort for 4 sub-sections under conversations folders, teams, channels and labels. * the sort options are basically created at, alphabetical and unread counts along with both directions. * for folders we don't have an unread count, so we sort it by only created and alphabetical. * all the sort preferences are stored on the frontend - easiest implementation for now. Fixes # CW-7193 ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Tested this locally by visually verifying the changes. Also ran the newly added tests for component level changes. Here is the screenshot of the changes: Added the sort option to sub sections in the conversation sidebar: <img width="282" height="848" alt="Screenshot 2026-06-02 at 1 58 12 AM" src="https://github.com/user-attachments/assets/4a7c6061-86e3-438a-92ae-ee643a0128b6" /> The sort options looks like this: <img width="783" height="698" alt="Screenshot 2026-06-02 at 1 58 49 AM" src="https://github.com/user-attachments/assets/a15bb0a7-b810-4423-a88c-fbd84d0476c0" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
338 lines
9.7 KiB
Vue
338 lines
9.7 KiB
Vue
<script setup>
|
|
import { computed, onMounted, onUnmounted, watch, nextTick, ref } from 'vue';
|
|
import { useSidebarContext, usePopoverState } from './provider';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import Policy from 'dashboard/components/policy.vue';
|
|
import Icon from 'next/icon/Icon.vue';
|
|
import SidebarGroupHeader from './SidebarGroupHeader.vue';
|
|
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
|
import SidebarSubGroup from './SidebarSubGroup.vue';
|
|
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
|
|
import SidebarCollapsedPopover from './SidebarCollapsedPopover.vue';
|
|
|
|
const props = defineProps({
|
|
name: { type: String, required: true },
|
|
label: { type: String, required: true },
|
|
icon: { type: [String, Object, Function], default: null },
|
|
to: { type: Object, default: null },
|
|
activeOn: { type: Array, default: () => [] },
|
|
children: { type: Array, default: undefined },
|
|
getterKeys: { type: Object, default: () => ({}) },
|
|
});
|
|
|
|
const {
|
|
expandedItem,
|
|
setExpandedItem,
|
|
resolvePath,
|
|
resolvePermissions,
|
|
resolveFeatureFlag,
|
|
isAllowed,
|
|
isCollapsed,
|
|
isResizing,
|
|
} = useSidebarContext();
|
|
|
|
const {
|
|
activePopover,
|
|
setActivePopover,
|
|
closeActivePopover,
|
|
scheduleClose,
|
|
cancelClose,
|
|
} = usePopoverState();
|
|
|
|
const navigableChildren = computed(() => {
|
|
return props.children?.flatMap(child => child.children || child) || [];
|
|
});
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const isExpanded = computed(() => expandedItem.value === props.name);
|
|
const isExpandable = computed(() => props.children);
|
|
const hasChildren = computed(
|
|
() => Array.isArray(props.children) && props.children.length > 0
|
|
);
|
|
|
|
// Use shared popover state - only one popover can be open at a time
|
|
const isPopoverOpen = computed(() => activePopover.value === props.name);
|
|
const triggerRef = ref(null);
|
|
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
|
|
// The sort dropdown teleports outside the popover; keep the popover open while
|
|
// it is showing so moving the cursor onto it does not close everything.
|
|
const isSortMenuOpen = ref(false);
|
|
|
|
const openPopover = () => {
|
|
if (triggerRef.value) {
|
|
const rect = triggerRef.value.getBoundingClientRect();
|
|
triggerRect.value = {
|
|
top: rect.top,
|
|
left: rect.left,
|
|
bottom: rect.bottom,
|
|
right: rect.right,
|
|
};
|
|
}
|
|
setActivePopover(props.name);
|
|
};
|
|
|
|
const closePopover = () => {
|
|
if (activePopover.value === props.name) {
|
|
closeActivePopover();
|
|
}
|
|
};
|
|
|
|
const handleMouseEnter = () => {
|
|
if (!hasChildren.value || isResizing.value) return;
|
|
cancelClose();
|
|
openPopover();
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (!hasChildren.value || isSortMenuOpen.value) return;
|
|
scheduleClose(200);
|
|
};
|
|
|
|
const handlePopoverMouseEnter = () => {
|
|
cancelClose();
|
|
};
|
|
|
|
const handlePopoverMouseLeave = () => {
|
|
if (isSortMenuOpen.value) return;
|
|
scheduleClose(100);
|
|
};
|
|
|
|
const handleSortToggle = isOpen => {
|
|
isSortMenuOpen.value = isOpen;
|
|
cancelClose();
|
|
};
|
|
|
|
// Close popover when mouse leaves the window
|
|
const handleWindowBlur = () => {
|
|
closeActivePopover();
|
|
};
|
|
|
|
const hasAccessibleSubChildren = child => {
|
|
return child.children?.some(
|
|
subChild => subChild.to && isAllowed(subChild.to)
|
|
);
|
|
};
|
|
|
|
const visibleChildren = computed(() => {
|
|
if (!hasChildren.value) return [];
|
|
|
|
return props.children.filter(child => {
|
|
if (child.children) return hasAccessibleSubChildren(child);
|
|
|
|
return child.to && isAllowed(child.to);
|
|
});
|
|
});
|
|
|
|
const accessibleItems = computed(() => {
|
|
if (!hasChildren.value) return [];
|
|
|
|
return visibleChildren.value
|
|
.flatMap(child => child.children || child)
|
|
.filter(child => child.to && isAllowed(child.to));
|
|
});
|
|
|
|
const hasAccessibleChildren = computed(() => {
|
|
return visibleChildren.value.length > 0;
|
|
});
|
|
|
|
const isLastVisibleChild = child => {
|
|
const lastChild = visibleChildren.value[visibleChildren.value.length - 1];
|
|
return lastChild === child;
|
|
};
|
|
|
|
const isActive = computed(() => {
|
|
if (props.to) {
|
|
if (route.path === resolvePath(props.to)) return true;
|
|
|
|
return props.activeOn.includes(route.name);
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
// We could use the RouterLink isActive too, but our routes are not always
|
|
// nested correctly, so we need to check the active state ourselves
|
|
// TODO: Audit the routes and fix the nesting and remove this
|
|
const activeChild = computed(() => {
|
|
const pathSame = navigableChildren.value.find(
|
|
child => child.to && route.path === resolvePath(child.to)
|
|
);
|
|
if (pathSame) return pathSame;
|
|
|
|
// Rank the activeOn Prop higher than the path match
|
|
// There will be cases where the path name is the same but the params are different
|
|
// So we need to rank them based on the params
|
|
// For example, contacts segment list in the sidebar effectively has the same name
|
|
// But the params are different
|
|
const activeOnPages = navigableChildren.value.filter(child =>
|
|
child.activeOn?.includes(route.name)
|
|
);
|
|
|
|
if (activeOnPages.length > 0) {
|
|
const rankedPage = activeOnPages.find(child => {
|
|
return Object.keys(child.to.params)
|
|
.map(key => {
|
|
return String(child.to.params[key]) === String(route.params[key]);
|
|
})
|
|
.every(match => match);
|
|
});
|
|
|
|
// If there is no ranked page, return the first activeOn page anyway
|
|
// Since this takes higher precedence over the path match
|
|
// This is not perfect, ideally we should rank each route based on all the techniques
|
|
// and then return the highest ranked one
|
|
// But this is good enough for now
|
|
return rankedPage ?? activeOnPages[0];
|
|
}
|
|
|
|
return navigableChildren.value.find(child => {
|
|
if (!child.to) return false;
|
|
const childPath = resolvePath(child.to);
|
|
return route.path === childPath || route.path.startsWith(`${childPath}/`);
|
|
});
|
|
});
|
|
|
|
const hasActiveChild = computed(() => {
|
|
return activeChild.value !== undefined;
|
|
});
|
|
|
|
const handleCollapsedClick = () => {
|
|
if (hasChildren.value && hasAccessibleChildren.value) {
|
|
const firstItem = accessibleItems.value[0];
|
|
router.push(firstItem.to);
|
|
}
|
|
};
|
|
|
|
const toggleTrigger = () => {
|
|
if (
|
|
hasAccessibleChildren.value &&
|
|
!isExpanded.value &&
|
|
!hasActiveChild.value
|
|
) {
|
|
// if not already expanded, navigate to the first child
|
|
const firstItem = accessibleItems.value[0];
|
|
router.push(firstItem.to);
|
|
}
|
|
setExpandedItem(props.name);
|
|
};
|
|
|
|
onMounted(async () => {
|
|
await nextTick();
|
|
if (hasActiveChild.value) {
|
|
setExpandedItem(props.name);
|
|
}
|
|
window.addEventListener('blur', handleWindowBlur);
|
|
document.addEventListener('mouseleave', handleWindowBlur);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('blur', handleWindowBlur);
|
|
document.removeEventListener('mouseleave', handleWindowBlur);
|
|
});
|
|
|
|
watch(
|
|
hasActiveChild,
|
|
hasNewActiveChild => {
|
|
if (hasNewActiveChild && !isExpanded.value) {
|
|
setExpandedItem(props.name);
|
|
}
|
|
},
|
|
{ once: true }
|
|
);
|
|
</script>
|
|
|
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
|
<template>
|
|
<Policy
|
|
v-if="!hasChildren || hasAccessibleChildren"
|
|
:permissions="resolvePermissions(to)"
|
|
:feature-flag="resolveFeatureFlag(to)"
|
|
as="li"
|
|
class="grid gap-1 text-sm cursor-pointer select-none min-w-0"
|
|
>
|
|
<!-- Collapsed State -->
|
|
<template v-if="isCollapsed">
|
|
<div
|
|
class="relative"
|
|
@mouseenter="handleMouseEnter"
|
|
@mouseleave="handleMouseLeave"
|
|
>
|
|
<component
|
|
:is="to && !hasChildren ? 'router-link' : 'button'"
|
|
ref="triggerRef"
|
|
:to="to && !hasChildren ? to : undefined"
|
|
type="button"
|
|
class="flex items-center justify-center size-10 rounded-lg"
|
|
:class="{
|
|
'text-n-slate-12 bg-n-alpha-2': isActive || hasActiveChild,
|
|
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
|
|
}"
|
|
:title="label"
|
|
@click="hasChildren ? handleCollapsedClick() : undefined"
|
|
>
|
|
<Icon v-if="icon" :icon="icon" class="size-4" />
|
|
</component>
|
|
<SidebarCollapsedPopover
|
|
v-if="hasChildren && isPopoverOpen"
|
|
:label="label"
|
|
:children="children"
|
|
:active-child="activeChild"
|
|
:trigger-rect="triggerRect"
|
|
@close="closePopover"
|
|
@mouseenter="handlePopoverMouseEnter"
|
|
@mouseleave="handlePopoverMouseLeave"
|
|
@sort-toggle="handleSortToggle"
|
|
/>
|
|
</div>
|
|
</template>
|
|
<!-- Expanded State -->
|
|
<template v-else>
|
|
<SidebarGroupHeader
|
|
:icon
|
|
:name
|
|
:label
|
|
:to
|
|
:getter-keys="getterKeys"
|
|
:is-active="isActive"
|
|
:has-active-child="hasActiveChild"
|
|
:expandable="hasChildren"
|
|
:is-expanded="isExpanded"
|
|
@toggle="toggleTrigger"
|
|
/>
|
|
<ul
|
|
v-if="hasChildren"
|
|
v-show="isExpanded || hasActiveChild"
|
|
class="grid m-0 list-none min-w-0"
|
|
>
|
|
<template v-for="child in visibleChildren" :key="child.name">
|
|
<SidebarSubGroup
|
|
v-if="child.children"
|
|
:name="`${name}:${child.name}`"
|
|
:label="child.label"
|
|
:icon="child.icon"
|
|
:children="child.children"
|
|
:collapsible="child.collapsible"
|
|
:show-tree-line="child.showTreeLine"
|
|
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
|
|
:is-expanded="isExpanded"
|
|
:active-child="activeChild"
|
|
:sort-options="child.sortOptions"
|
|
:active-sort="child.activeSort"
|
|
@update-sort="child.onSortChange"
|
|
/>
|
|
<SidebarGroupLeaf
|
|
v-else-if="isAllowed(child.to)"
|
|
v-show="isExpanded || activeChild?.name === child.name"
|
|
v-bind="child"
|
|
:active="activeChild?.name === child.name"
|
|
/>
|
|
</template>
|
|
</ul>
|
|
<ul v-else-if="isExpandable && isExpanded">
|
|
<SidebarGroupEmptyLeaf />
|
|
</ul>
|
|
</template>
|
|
</Policy>
|
|
</template>
|