Browse Source

fix: cmdk improvements (#9575)

* fix: cmdk improvements

Signed-off-by: mertmit <mertmit99@gmail.com>

* chore: generate Api.ts

Signed-off-by: mertmit <mertmit99@gmail.com>

---------

Signed-off-by: mertmit <mertmit99@gmail.com>
pull/9531/merge
Mert E. 2 months ago committed by GitHub
parent
commit
bede42aef1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 280
      packages/nc-gui/components/cmd-k/index.vue
  2. 42
      packages/nc-gui/composables/useCommandPalette/index.ts
  3. 2
      packages/nc-gui/utils/filterUtils.ts
  4. 28
      packages/nocodb-sdk/src/lib/Api.ts
  5. 3
      packages/nocodb/src/helpers/commandPaletteHelpers.ts
  6. 1
      packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts
  7. 5
      packages/nocodb/src/services/api-docs/swaggerV2/swagger-base.json
  8. 6
      packages/nocodb/src/services/command-palette.service.ts
  9. 1
      tests/playwright/pages/Dashboard/Command/CmdKPage.ts

280
packages/nc-gui/components/cmd-k/index.vue

@ -40,10 +40,22 @@ const cmdInputEl = ref<HTMLInputElement>()
const cmdInput = ref('')
const debouncedCmdInput = ref('')
const { user } = useGlobal()
const selected = ref<string>()
const cmdkActionsRef = ref<HTMLElement>()
const cmdkActionSelectedRef = ref<HTMLElement>()
const ACTION_HEIGHT = 48
const WRAPPER_HEIGHT = 300
const SCROLL_MARGIN = ACTION_HEIGHT / 2
const { cmdPlaceholder, loadScope, cmdLoading } = useCommandPalette()
const formattedData: ComputedRef<(CmdAction & { weight: number })[]> = computed(() => {
@ -56,7 +68,7 @@ const formattedData: ComputedRef<(CmdAction & { weight: number })[]> = computed(
parent: el.parent || 'root',
weight: commandScore(
`${el.section}${el?.section === 'Views' && el?.is_default ? t('title.defaultView') : el.title}${el.keywords?.join()}`,
cmdInput.value,
debouncedCmdInput.value,
),
})
}
@ -117,7 +129,7 @@ const actionList = computed(() => {
return 0
})
return formattedData.value.filter((el) => {
if (cmdInput.value === '') {
if (debouncedCmdInput.value === '') {
if (el.parent === activeScope.value) {
if (!el.handler) {
return isThereAnyActionInScope(el.id)
@ -138,7 +150,7 @@ const actionList = computed(() => {
})
const searchedActionList = computed(() => {
if (cmdInput.value === '') return actionList.value
if (debouncedCmdInput.value === '') return actionList.value
actionList.value.sort((a, b) => {
if (a.weight > b.weight) return -1
if (a.weight < b.weight) return 1
@ -149,49 +161,90 @@ const searchedActionList = computed(() => {
.sort((a, b) => b.section?.toLowerCase().localeCompare(a.section?.toLowerCase() as string) || 0)
})
const actionListGroupedBySection = computed(() => {
const rt: { [key: string]: CmdAction[] } = {}
const visibleSections = computed(() => {
const sections: string[] = []
searchedActionList.value.forEach((el) => {
if (el.section === 'hidden') return
if (el.section) {
if (!rt[el.section]) rt[el.section] = []
rt[el.section].push(el)
} else {
if (!rt.default) rt.default = []
rt.default.push(el)
if (el.section && !sections.includes(el.section)) {
sections.push(el.section)
}
})
return sections
})
const actionListNormalized = computed(() => {
const rt: (CmdAction | { sectionTitle: string })[] = []
visibleSections.value.forEach((el) => {
rt.push({ sectionTitle: el || 'default' })
rt.push(...searchedActionList.value.filter((el2) => el2.section === el))
})
return rt
})
const { list, containerProps, wrapperProps } = useVirtualList(actionListNormalized, {
itemHeight: ACTION_HEIGHT,
})
const keys = useMagicKeys()
const shiftModifier = keys.shift
const setAction = (action: string) => {
const oldActionIndex = searchedActionList.value.findIndex((el) => el.id === selected.value)
selected.value = action
nextTick(() => {
const actionIndex = searchedActionList.value.findIndex((el) => el.id === action)
if (actionIndex === -1) return
if (actionIndex === 0) {
document.querySelector('.cmdk-actions')?.scrollTo({ top: 0, behavior: 'smooth' })
containerProps.ref.value?.scrollTo({ top: 0, behavior: 'smooth' })
return
} else if (actionIndex === searchedActionList.value.length - 1) {
document.querySelector('.cmdk-actions')?.scrollTo({ top: 999999, behavior: 'smooth' })
} else {
document.querySelector('.cmdk-action.selected')?.scrollIntoView({
containerProps.ref.value?.scrollTo({
top: actionIndex * ACTION_HEIGHT,
behavior: 'smooth',
block: 'nearest',
})
return
}
})
}
const selectFirstAction = () => {
if (searchedActionList.value.length > 0) {
setAction(searchedActionList.value[0].id)
} else {
selected.value = undefined
}
// check if selected action rendered
const actionEl = Array.isArray(cmdkActionSelectedRef.value) ? cmdkActionSelectedRef.value[0] : cmdkActionSelectedRef.value
if (!actionEl || !actionEl.classList?.contains('selected')) {
// if above the old selected action
if (actionIndex < oldActionIndex) {
containerProps.ref.value?.scrollTo({ top: (actionIndex + 1) * ACTION_HEIGHT - SCROLL_MARGIN, behavior: 'smooth' })
} else {
containerProps.ref.value?.scrollTo({
top: (actionIndex + 2) * ACTION_HEIGHT - WRAPPER_HEIGHT + SCROLL_MARGIN,
behavior: 'smooth',
})
}
return
}
// count sections before the selected action
const sectionBefore = visibleSections.value.findIndex((el) => el === searchedActionList.value[actionIndex].section) + 1
// check if selected action is visible in the list
const actionRect = actionEl?.getBoundingClientRect()
const listRect = cmdkActionsRef.value?.getBoundingClientRect()
if (actionRect && listRect) {
if (actionRect.top < listRect.top || actionRect.bottom > listRect.bottom) {
// if above the old selected action
if (actionIndex < oldActionIndex) {
containerProps.ref.value?.scrollTo({
top: (actionIndex + sectionBefore) * ACTION_HEIGHT - SCROLL_MARGIN,
behavior: 'smooth',
})
} else {
containerProps.ref.value?.scrollTo({
top: (actionIndex + 1 + sectionBefore) * ACTION_HEIGHT - WRAPPER_HEIGHT + SCROLL_MARGIN,
behavior: 'smooth',
})
}
}
}
})
}
const setScope = (scope: string) => {
@ -201,7 +254,6 @@ const setScope = (scope: string) => {
nextTick(() => {
cmdInputEl.value?.focus()
selectFirstAction()
})
}
@ -241,6 +293,22 @@ const fireAction = (action: CmdAction, preview = false) => {
}
}
const updateDebouncedInput = useDebounceFn(() => {
debouncedCmdInput.value = cmdInput.value
nextTick(() => {
cmdInputEl.value?.focus()
})
}, 100)
watch(cmdInput, () => {
if (cmdInput.value === '') {
debouncedCmdInput.value = ''
} else {
updateDebouncedInput()
}
})
whenever(keys.ctrl_k, () => {
show()
})
@ -312,6 +380,12 @@ onClickOutside(modalEl, () => {
if (vOpen.value) hide()
})
watch(searchedActionList, () => {
if (searchedActionList.value.length > 0) {
setAction(searchedActionList.value[0].id)
}
})
defineExpose({
open: show,
close: hide,
@ -382,18 +456,11 @@ defineExpose({
<span class="text-gray-700 text-sm pl-1 font-medium">/</span>
</div>
<input
ref="cmdInputEl"
v-model="cmdInput"
class="cmdk-input"
type="text"
:placeholder="cmdPlaceholder"
@input="selectFirstAction"
/>
<input ref="cmdInputEl" v-model="cmdInput" class="cmdk-input" type="text" :placeholder="cmdPlaceholder" />
</div>
</div>
<div class="cmdk-body">
<div class="cmdk-actions nc-scrollbar-md">
<div ref="cmdkActionsRef" class="cmdk-actions nc-scrollbar-md">
<div v-if="searchedActionList.length === 0 && cmdLoading" class="w-full h-[250px] flex justify-center items-center">
<GeneralLoader :size="30" />
</div>
@ -403,73 +470,92 @@ defineExpose({
</div>
</div>
<template v-else>
<div
v-for="[title, section] of Object.entries(actionListGroupedBySection)"
:key="`cmdk-section-${title}`"
class="cmdk-action-section border-t-1 border-gray-200"
>
<div v-if="title !== 'default'" class="cmdk-action-section-header capitalize">{{ title }}</div>
<div class="cmdk-action-section-body">
<div
v-for="act of section"
:key="act.id"
v-e="['a:cmdk:action']"
class="cmdk-action group"
:class="{ selected: selected === act.id }"
@mouseenter="setAction(act.id)"
@click="fireAction(act)"
>
<div class="cmdk-action-content w-full">
<GeneralWorkspaceIcon
v-if="act.icon && act.id.startsWith('ws')"
:workspace="{
id: act.id.split('-')[2],
meta: {
color: act?.iconColor,
},
}"
class="mr-2"
size="small"
/>
<template v-else-if="title === 'Bases' || act.icon === 'project'">
<GeneralBaseIconColorPicker :key="act.iconColor" :model-value="act.iconColor" type="database" readonly>
</GeneralBaseIconColorPicker>
</template>
<template v-else>
<component
:is="(iconMap as any)[act.icon]"
v-if="act.icon && typeof act.icon === 'string' && (iconMap as any)[act.icon]"
:class="{
'!text-blue-500': act.icon === 'grid',
'!text-purple-500': act.icon === 'form',
'!text-[#FF9052]': act.icon === 'kanban',
'!text-pink-500': act.icon === 'gallery',
'!text-maroon-500 w-4 h-4': act.icon === 'calendar',
<div class="cmdk-action-list border-t-1 border-gray-200">
<div v-bind="containerProps" :style="`height: ${WRAPPER_HEIGHT}px`">
<div v-bind="wrapperProps">
<div v-for="item in list" :key="item.index" :style="`height: ${ACTION_HEIGHT}px`">
<template v-if="'sectionTitle' in item.data">
<div
class="cmdk-action-section-header capitalize"
:style="{
height: `${ACTION_HEIGHT}px`,
}"
class="cmdk-action-icon"
/>
<div v-else-if="act.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="act.icon" readonly />
>
{{ item.data.sectionTitle }}
</div>
</template>
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ act.title }}
</template>
<span class="truncate capitalize mr-4 py-0.5">
{{ act.title }}
</span>
</a-tooltip>
<div
class="bg-gray-200 text-gray-600 cmdk-keyboard hidden text-xs gap-2 p-0.5 items-center justify-center rounded-md ml-auto pl-2"
>
Enter
<template v-else>
<div
class="bg-white border-1 items-center flex justify-center border-gray-300 text-gray-700 rounded h-5 w-5 px-0.25"
:ref="item.data.id === selected ? 'cmdkActionSelectedRef' : undefined"
:key="`${item.data.id}-${item.data.id === selected}`"
v-e="['a:cmdk:action']"
class="cmdk-action group flex items-center"
:style="{
height: `${ACTION_HEIGHT}px`,
}"
:class="{ selected: selected === item.data.id }"
@mouseenter="setAction(item.data.id)"
@click="fireAction(item.data)"
>
<div class="cmdk-action-content w-full">
<GeneralWorkspaceIcon
v-if="item.data.icon && item.data.id.startsWith('ws')"
:workspace="{
id: item.data.id.split('-')[2],
meta: {
color: item.data?.iconColor,
},
}"
class="mr-2"
size="small"
/>
<template v-else-if="item.data.section === 'Bases' || item.data.icon === 'project'">
<GeneralBaseIconColorPicker
:key="item.data.iconColor"
:model-value="item.data.iconColor"
type="database"
readonly
>
</GeneralBaseIconColorPicker>
</template>
<template v-else>
<component
:is="(iconMap as any)[item.data.icon]"
v-if="item.data.icon && typeof item.data.icon === 'string' && (iconMap as any)[item.data.icon]"
:class="{
'!text-blue-500': item.data.icon === 'grid',
'!text-purple-500': item.data.icon === 'form',
'!text-[#FF9052]': item.data.icon === 'kanban',
'!text-pink-500': item.data.icon === 'gallery',
'!text-maroon-500 w-4 h-4': item.data.icon === 'calendar',
}"
class="cmdk-action-icon"
/>
<div v-else-if="item.data.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="item.data.icon" readonly />
</div>
</template>
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ item.data.title }}
</template>
<span class="truncate capitalize mr-4 py-0.5">
{{ item.data.title }}
</span>
</a-tooltip>
<div
class="bg-gray-200 text-gray-600 cmdk-keyboard hidden text-xs gap-2 p-0.5 items-center justify-center rounded-md ml-auto pl-2"
>
Enter
<div
class="bg-white border-1 items-center flex justify-center border-gray-300 text-gray-700 rounded h-5 w-5 px-0.25"
>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
@ -630,7 +716,7 @@ defineExpose({
}
}
.cmdk-action-section {
.cmdk-action-list {
display: flex;
flex-direction: column;
width: 100%;

42
packages/nc-gui/composables/useCommandPalette/index.ts

@ -36,6 +36,26 @@ export const useCommandPalette = createSharedComposable(() => {
const { workspacesList } = storeToRefs(useWorkspace())
const workspacesCmd = computed(() =>
(workspacesList.value || []).map((workspace: { id: string; title: string; meta?: { color: string } }) => ({
id: `ws-nav-${workspace.id}`,
title: workspace.title,
icon: 'workspace',
iconColor: workspace.meta?.color,
section: 'Workspaces',
scopePayload: {
scope: `ws-${workspace.id}`,
data: {
workspace_id: workspace.id,
},
},
handler: processHandler({
type: 'navigate',
payload: `/${workspace.id}/settings`,
}),
})),
)
const commands = ref({
homeCommands,
baseCommands: [],
@ -63,25 +83,7 @@ export const useCommandPalette = createSharedComposable(() => {
staticCmd.push(...commands.value.baseCommands)
return (workspacesList.value || [])
.map((workspace: { id: string; title: string; meta?: { color: string } }) => ({
id: `ws-nav-${workspace.id}`,
title: workspace.title,
icon: 'workspace',
iconColor: workspace.meta?.color,
section: 'Workspaces',
scopePayload: {
scope: `ws-${workspace.id}`,
data: {
workspace_id: workspace.id,
},
},
handler: processHandler({
type: 'navigate',
payload: `/${workspace.id}/settings`,
}),
}))
.concat(staticCmd)
return workspacesCmd.value.concat(staticCmd)
})
const dynamicData = ref<any>([])
@ -188,6 +190,8 @@ export const useCommandPalette = createSharedComposable(() => {
scope: `ws-${route.value.params.typeOrId}`,
data: { workspace_id: route.value.params.typeOrId },
}
refreshCommandPalette.trigger()
} else if (route.value.params.typeOrId === 'nc') {
if (activeScope.value.data.base_id === route.value.params.baseId) return

2
packages/nc-gui/utils/filterUtils.ts

@ -47,7 +47,7 @@ const getLikeText = (fieldUiType: UITypes) => {
const getNotLikeText = (fieldUiType: UITypes) => {
if (fieldUiType === UITypes.Attachment) {
return "filenames doesn't contain"
return "filenames don't contain"
}
return 'is not like'
}

28
packages/nocodb-sdk/src/lib/Api.ts

@ -765,6 +765,8 @@ export interface FilterType {
| 'tomorrow'
| ('yesterday' & null)
);
/** Foreign Key to parent column */
fk_parent_column_id?: StringOrNullType;
/** Foreign Key to Column */
fk_column_id?: StringOrNullType;
/** Foreign Key to Hook */
@ -789,6 +791,11 @@ export interface FilterType {
base_id?: string;
/** The filter value. Can be NULL for some operators. */
value?: any;
/**
* The order of the filter
* @example 1
*/
order?: number;
}
/**
@ -4198,7 +4205,7 @@ export class Api<
* @tags Org Tokens
* @name Delete
* @summary Delete Organisation API Tokens
* @request DELETE:/api/v1/tokens/{token}
* @request DELETE:/api/v1/tokens/{tokenId}
* @response `200` `number` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
@ -4206,7 +4213,7 @@ export class Api<
}`
*/
delete: (token: string, params: RequestParams = {}) =>
delete: (tokenId: string, params: RequestParams = {}) =>
this.request<
number,
{
@ -4214,7 +4221,7 @@ export class Api<
msg: string;
}
>({
path: `/api/v1/tokens/${token}`,
path: `/api/v1/tokens/${tokenId}`,
method: 'DELETE',
format: 'json',
...params,
@ -7757,7 +7764,13 @@ export class Api<
}`
*/
read: (viewId: string, params: RequestParams = {}) =>
read: (
viewId: string,
query?: {
includeAllFilters?: boolean;
},
params: RequestParams = {}
) =>
this.request<
FilterListType,
{
@ -7767,6 +7780,7 @@ export class Api<
>({
path: `/api/v1/db/meta/views/${viewId}/filters`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
@ -11546,7 +11560,7 @@ export class Api<
* @tags API Token
* @name Delete
* @summary Delete API Token
* @request DELETE:/api/v1/db/meta/projects/{baseId}/api-tokens/{token}
* @request DELETE:/api/v1/db/meta/projects/{baseId}/api-tokens/{tokenId}
* @response `200` `number` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
@ -11554,7 +11568,7 @@ export class Api<
}`
*/
delete: (baseId: IdType, token: string, params: RequestParams = {}) =>
delete: (baseId: IdType, tokenId: string, params: RequestParams = {}) =>
this.request<
number,
{
@ -11562,7 +11576,7 @@ export class Api<
msg: string;
}
>({
path: `/api/v1/db/meta/projects/${baseId}/api-tokens/${token}`,
path: `/api/v1/db/meta/projects/${baseId}/api-tokens/${tokenId}`,
method: 'DELETE',
format: 'json',
...params,

3
packages/nocodb/src/helpers/commandPaletteHelpers.ts

@ -1,3 +1,4 @@
import { ProjectRoles } from 'nocodb-sdk';
import { CacheGetType, CacheScope, MetaTable } from '~/utils/globals';
import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache';
@ -45,7 +46,7 @@ export async function getCommandPaletteForUserWorkspace(
);
})
.where('bu.fk_user_id', userId)
.andWhereNot('bu.roles', 'no_access')
.andWhereNot('bu.roles', ProjectRoles.NO_ACCESS)
.andWhere('t.mm', false)
.andWhere(function () {
this.where('dm.disabled', false).orWhereNull('dm.disabled');

1
packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts

@ -176,6 +176,7 @@ export class ImportService {
: true),
);
// create table with static columns
const table =
param.existingModel ||
(await this.tablesService.tableCreate(context, {

5
packages/nocodb/src/services/api-docs/swaggerV2/swagger-base.json

@ -111,11 +111,8 @@
}
},
"security": [
{
"xcAuth": []
},
{
"xcToken": []
}
]
}
}

6
packages/nocodb/src/services/command-palette.service.ts

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { type UserType, ViewTypes } from 'nocodb-sdk';
import { deserializeJSON } from '~/utils/serialize';
import { getCommandPaletteForUserWorkspace } from '~/helpers/commandPaletteHelpers';
@ -14,7 +14,7 @@ const viewTypeAlias: Record<number, string> = {
@Injectable()
export class CommandPaletteService {
constructor() {}
logger = new Logger('CommandPaletteService');
async commandPalette(param: { body: any; user: UserType }) {
const cmdData = [];
@ -137,7 +137,7 @@ export class CommandPaletteService {
});
}
} catch (e) {
console.log(e);
this.logger.warn(e);
return [];
}
return cmdData;

1
tests/playwright/pages/Dashboard/Command/CmdKPage.ts

@ -19,6 +19,7 @@ export class CmdK extends BasePage {
async searchText(text: string) {
await this.dashboardPage.rootPage.fill('.cmdk-input', text);
await this.dashboardPage.rootPage.waitForTimeout(1000);
await this.rootPage.keyboard.press('Enter');
await this.rootPage.keyboard.press('Enter');
}

Loading…
Cancel
Save