Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add DataSelector component for improved menu selection functionality #5376

Merged
merged 8 commits into from
Jan 2, 2025
90 changes: 90 additions & 0 deletions apps/web/src/common/components/select/DataSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script setup lang="ts">
import {
ref, onMounted, toRef, watch,
} from 'vue';

import { debounce } from 'lodash';

import { PFieldTitle, PContextMenu, useContextMenuItems } from '@cloudforet/mirinae';
import type { MenuAttachHandler } from '@cloudforet/mirinae/types/hooks/use-context-menu-attach/use-context-menu-attach';

import type { DataSelectorItem } from '@/common/components/select/type';

const props = defineProps<{
label?: string;
menu?: DataSelectorItem[];
handler?: MenuAttachHandler<DataSelectorItem>;
}>();
const emit = defineEmits<{(e: 'update:selected', value: DataSelectorItem[]): void;
}>();

const searchText = ref('');
const selected = ref<DataSelectorItem[]>([]);

const {
refinedMenu,
loading,
initiateMenu,
showMoreMenu,
reloadMenu,
} = useContextMenuItems<DataSelectorItem>({
menu: toRef(props, 'menu'),
handler: toRef(props, 'handler'),
selected,
useMenuFiltering: true,
searchText,
pageSize: 10,
hideHeaderWithoutItems: true,
});
const handleUpdateSearchText = debounce((text: string) => {
searchText.value = text;
reloadMenu();
}, 200);

const handleUpdateSelected = (items: DataSelectorItem[]) => {
selected.value = items;
emit('update:selected', selected.value);
};

onMounted(() => {
selected.value = [];
emit('update:selected', selected.value);
initiateMenu();
});

watch([() => props.menu, () => props.handler], () => {
selected.value = [];
emit('update:selected', selected.value);
initiateMenu();
});
</script>

<template>
<div>
<div class="flex flex-col gap-2">
<p-field-title class="py-0 px-3"
:label="props.label"
required
/>
<p-context-menu :menu="refinedMenu"
class="data-selector-context-menu"
:loading="loading"
:search-text="searchText"
searchable
:selected="selected"
@click-show-more="showMoreMenu()"
@update:search-text="handleUpdateSearchText"
@update:selected="handleUpdateSelected"
/>
</div>
</div>
</template>

<style lang="postcss">
.data-selector-context-menu {
border: none;
min-height: 16rem;
max-height: 360px;
overflow-y: auto;
}
</style>
6 changes: 6 additions & 0 deletions apps/web/src/common/components/select/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { MenuItem } from '@cloudforet/mirinae/types/controls/context-menu/type';

export interface DataSelectorItem extends MenuItem {
name: string;
label: string;
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
<script lang="ts" setup>
import {
computed, onMounted, reactive, toRef, watch,
computed, reactive, watch,
} from 'vue';

import { debounce } from 'lodash';

import {
PFieldTitle, PContextMenu, useContextMenuController,
} from '@cloudforet/mirinae';
import type { MenuItem } from '@cloudforet/mirinae/types/controls/context-menu/type';
import type { AutocompleteHandler } from '@cloudforet/mirinae/types/controls/dropdown/select-dropdown/type';

Expand All @@ -24,6 +19,7 @@ import {
getVariableModelMenuHandler,
} from '@/lib/variable-models/variable-model-menu-handler';

import DataSelector from '@/common/components/select/DataSelector.vue';
import { useProxyValue } from '@/common/composables/proxy-state';


Expand Down Expand Up @@ -52,7 +48,6 @@ const state = reactive({
};
return getVariableModelMenuHandler([variableModelInfo]);
}),
dataSourceSearchText: '',
// data type
dataTypeMenuItems: computed<MenuItem[]>(() => {
if (!state.selectedDataSource.length) return [];
Expand All @@ -69,38 +64,17 @@ const state = reactive({
{ type: 'item', name: 'usage_quantity', label: 'Usage' },
...(additionalMenuItems || []),
];
return dataTypeItems.filter((d) => d.label.toLowerCase().includes(state.dataTypeSearchText.toLowerCase()));
return dataTypeItems;
}),
dataTypeSearchText: '',
selectedDataType: [] as MenuItem[],
});
const {
refinedMenu,
initiateMenu,
reloadMenu,
} = useContextMenuController({
targetRef: toRef(state, 'targetRef'),
searchText: toRef(state, 'dataSourceSearchText'),
handler: toRef(state, 'dataSourceMenuHandler'),
selected: toRef(state, 'selectedDataSource'),
pageSize: 10,
});

/* Event */
const handleUpdateCostDataSourceSearchText = debounce((text: string) => {
state.dataSourceSearchText = text;
reloadMenu();
}, 200);
const handleSelectDataSource = () => {
const handleSelectDataSource = (items: MenuItem[]) => {
state.selectedDataSource = items;
state.selectedDataType = [];
};

onMounted(() => {
state.selectedDataSource = [];
state.selectedDataType = [];
initiateMenu();
});

/* Watcher */
watch(() => state.selectedDataSource, (val) => {
state.proxySelectedCostDataSourceId = val[0]?.name;
Expand All @@ -113,27 +87,15 @@ watch(() => state.selectedDataType, (val) => {
<template>
<div class="widget-form-cost-data-source-popper">
<div class="data-source-select-col">
<p-field-title class="field-title"
:label="i18n.t('Data Source')"
required
/>
<p-context-menu :menu="refinedMenu"
:search-text="state.dataSourceSearchText"
searchable
:selected.sync="state.selectedDataSource"
@update:search-text="handleUpdateCostDataSourceSearchText"
@select="handleSelectDataSource"
<data-selector :label="i18n.t('Data Source')"
:handler="state.dataSourceMenuHandler"
@update:selected="handleSelectDataSource"
/>
</div>
<div class="data-source-select-col">
<p-field-title class="field-title"
:label="i18n.t('Data Type')"
required
/>
<p-context-menu :menu="state.dataTypeMenuItems"
:search-text.sync="state.dataTypeSearchText"
searchable
:selected.sync="state.selectedDataType"
<data-selector :label="i18n.t('Data Type')"
:menu="state.dataTypeMenuItems"
@update:selected="state.selectedDataType = $event"
/>
</div>
</div>
Expand All @@ -151,17 +113,9 @@ watch(() => state.selectedDataType, (val) => {
gap: 0.5rem;
width: 16rem;
padding: 0.75rem 0;
.field-title {
padding: 0 0.75rem;
}
&:last-child {
@apply border-r-0;
}
}
}

/* custom design-system component - p-context-menu */
:deep(.p-context-menu) {
border: none;
}
</style>
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { Ref } from 'vue';
import { isRef } from 'vue';

import type { AutocompleteHandler } from '@cloudforet/mirinae/types/controls/dropdown/select-dropdown/type';

import type { VariableModel } from '@/lib/variable-models/_base/types';
Expand All @@ -15,15 +18,16 @@ const _getTitle = (modelInfo: VariableModelMenuHandlerInfo) => {
return _dataKey ? modelInfo.variableModel[_dataKey].name : modelInfo.variableModel._meta?.name;
};

export const getVariableModelMenuHandler = (variableModelInfoList: VariableModelMenuHandlerInfo[], options: Record<string, any> = {}): AutocompleteHandler => {
type Options = Record<string, any>;
export const getVariableModelMenuHandler = (variableModelInfoList: VariableModelMenuHandlerInfo[], options: Options|Ref<Options> = {}): AutocompleteHandler => {
const _variableModelInfoList = variableModelInfoList;
return async (inputText: string, pageStart, pageLimit, filters, resultIndex) => {
const _query = {
start: pageStart,
limit: pageLimit ?? 10,
search: inputText,
filters: filters?.length ? filters.map((f) => f.name as string) : undefined,
options,
options: isRef<Options>(options) ? options.value : options,
};

// if resultIndex is empty, it means that the handler is called for the first time. so, we need to call all variableModels' list().
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MenuItem } from '@/controls/context-menu/type';
import type { MenuAttachHandler, MenuAttachHandlerRes } from '@/hooks/use-context-menu-controller/use-context-menu-attach';
import type { MenuAttachHandler, MenuAttachHandlerRes } from '@/hooks/use-context-menu-attach/use-context-menu-attach';

export interface SelectDropdownMenuItem extends MenuItem {
name: string;
Expand Down
2 changes: 2 additions & 0 deletions packages/mirinae/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './use-context-menu-controller/use-context-menu-controller';
export * from './use-context-menu-attach/use-context-menu-attach';
export * from './use-context-menu-items/use-context-menu-items';
export * from './use-context-menu-style/use-context-menu-style';
export * from './use-query-search/use-query-search';
export * from './use-ignore-window-arrow-keydown-events/use-ignore-window-arrow-keydown-events';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{/* useContextMenuAttach.mdx */}

import {Canvas, Meta, Controls} from '@storybook/blocks';

import * as useContextMenuAttachStories from './use-context-menu-attach.stories';

<Meta of={useContextMenuAttachStories}/>


# useContextMenuAttach

Handles attaching context menus to DOM elements dynamically based on interactions.

## Type Declarations

```typescript
interface UseContextMenuAttachOptions<Item extends MenuItem = MenuItem> {
attachHandler?: Ref<MenuAttachHandler<Item>|undefined>; // custom handler
menu?: Ref<Item[]>; // required when to use default attach handler. one of menu or attachHandler is required.
searchText?: Ref<string>; // it will be passed to the attach handler as the argument, so the handler can filter the items based on this text.
pageSize?: Ref<number|undefined>|number; // required when to use show more button to attach items
filterItems?: Ref<Item[]>; // items to be filtered out from the attached menu
}

interface UseContextMenuAttachReturns<Item> {
attachedMenu: Ref<Item[]>;
attachLoading: Ref<boolean>;
resetMenuAndPagination: () => void;
attachMenuItems: (resultIndex?: number) => Promise<void>;
}
```

## Usage
<br/>

### Basic
<Canvas of={useContextMenuAttachStories.Basic}/>

### Playground
<Canvas of={useContextMenuAttachStories.Playground}/>
<Controls of={useContextMenuAttachStories.Playground}/>

<br/>
<br/>
Loading
Loading