mirror of https://github.com/nocodb/nocodb
Raju Udava
10 months ago
committed by
GitHub
21 changed files with 1818 additions and 8 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,61 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import type { CommandPaletteType } from '~/lib' |
||||||
|
|
||||||
|
defineProps<{ |
||||||
|
activeCmd: CommandPaletteType |
||||||
|
setActiveCmdView: (cmd: CommandPaletteType) => void |
||||||
|
}>() |
||||||
|
|
||||||
|
const renderCmdOrCtrlKey = () => { |
||||||
|
return isMac() ? '⌘' : 'Ctrl' |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="cmdk-footer absolute inset-x-0 bottom-0 !bg-white"> |
||||||
|
<div class="flex justify-center w-full py-2"> |
||||||
|
<div |
||||||
|
class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center cursor-pointer" |
||||||
|
:class="activeCmd === 'cmd-j' ? 'text-brand-500' : ''" |
||||||
|
@click.stop="activeCmd !== 'cmd-j' ? setActiveCmdView('cmd-j') : () => undefined" |
||||||
|
> |
||||||
|
<MdiFileOutline class="h-4 w-4" /> |
||||||
|
Document |
||||||
|
<span |
||||||
|
class="text-sm px-1 rounded-md border-1" |
||||||
|
:class="activeCmd === 'cmd-j' ? 'bg-brand-500 border-brand-500 text-white' : 'bg-gray-100 border-gray-300'" |
||||||
|
> |
||||||
|
{{ renderCmdOrCtrlKey() }} + J |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<div |
||||||
|
class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center cursor-pointer" |
||||||
|
:class="activeCmd === 'cmd-k' ? 'text-brand-500' : ''" |
||||||
|
@click.stop="activeCmd !== 'cmd-k' ? setActiveCmdView('cmd-k') : () => undefined" |
||||||
|
> |
||||||
|
<MdiMapMarkerOutline class="h-4 w-4" /> |
||||||
|
Quick Navigation |
||||||
|
<span |
||||||
|
class="text-sm px-1 rounded-md border-1" |
||||||
|
:class="activeCmd === 'cmd-k' ? 'bg-brand-500 border-brand-500 text-white' : 'bg-gray-100 border-gray-300'" |
||||||
|
> |
||||||
|
{{ renderCmdOrCtrlKey() }} + K |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<div |
||||||
|
class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center cursor-pointer" |
||||||
|
:class="activeCmd === 'cmd-l' ? 'text-brand-500' : ''" |
||||||
|
@click.stop="activeCmd !== 'cmd-l' ? setActiveCmdView('cmd-l') : () => undefined" |
||||||
|
> |
||||||
|
<MdiClockOutline class="h-4 w-4" /> |
||||||
|
Recent |
||||||
|
<span |
||||||
|
class="text-sm px-1 rounded-md border-1" |
||||||
|
:class="activeCmd === 'cmd-l' ? 'bg-brand-500 border-brand-500 text-white' : 'bg-gray-100 border-gray-300'" |
||||||
|
> |
||||||
|
{{ renderCmdOrCtrlKey() }} + L |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
@ -0,0 +1,34 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
const modalEl = ref<HTMLElement | null>(null) |
||||||
|
const { user } = useGlobal() |
||||||
|
|
||||||
|
watch(user, () => { |
||||||
|
window.doc_enabled = !!user.value |
||||||
|
}) |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
docsearch({ |
||||||
|
container: '#searchbar', |
||||||
|
typesenseCollectionName: 'nocodb-oss-docs-index', |
||||||
|
typesenseServerConfig: { |
||||||
|
nodes: [ |
||||||
|
{ |
||||||
|
host: 'rqf5uvajyeczwt3xp-1.a1.typesense.net', |
||||||
|
port: 443, |
||||||
|
protocol: 'https', |
||||||
|
}, |
||||||
|
], |
||||||
|
apiKey: 'lNKDTZdJrE76Sg8WEyeN9mXT29l1xq7Q', |
||||||
|
}, |
||||||
|
typesenseSearchParameters: { |
||||||
|
// Optional. |
||||||
|
}, |
||||||
|
}) |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div id="searchbar" :ref="modalEl" class="hidden"></div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style></style> |
@ -0,0 +1,162 @@ |
|||||||
|
// this is derived from https://github.com/pacocoursey/cmdk
|
||||||
|
|
||||||
|
// The scores are arranged so that a continuous match of characters will
|
||||||
|
// result in a total score of 1.
|
||||||
|
//
|
||||||
|
// The best case, this character is a match, and either this is the start
|
||||||
|
// of the string, or the previous character was also a match.
|
||||||
|
const SCORE_CONTINUE_MATCH = 1.9 |
||||||
|
// A new match at the start of a word scores better than a new match
|
||||||
|
// elsewhere as it's more likely that the user will type the starts
|
||||||
|
// of fragments.
|
||||||
|
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets
|
||||||
|
// hyphens, etc.
|
||||||
|
const SCORE_SPACE_WORD_JUMP = 1.0 |
||||||
|
const SCORE_NON_SPACE_WORD_JUMP = 0.8 |
||||||
|
// Any other match isn't ideal, but we include it for completeness.
|
||||||
|
const SCORE_CHARACTER_JUMP = 0.2 |
||||||
|
// If the user transposed two letters, it should be significantly penalized.
|
||||||
|
//
|
||||||
|
// i.e. "ouch" is more likely than "curtain" when "uc" is typed.
|
||||||
|
const SCORE_TRANSPOSITION = 0.3 |
||||||
|
// The goodness of a match should decay slightly with each missing
|
||||||
|
// character.
|
||||||
|
//
|
||||||
|
// i.e. "bad" is more likely than "bard" when "bd" is typed.
|
||||||
|
//
|
||||||
|
// This will not change the order of suggestions based on SCORE_* until
|
||||||
|
// 100 characters are inserted between matches.
|
||||||
|
const PENALTY_SKIPPED = 0.999 |
||||||
|
// The goodness of an exact-case match should be higher than a
|
||||||
|
// case-insensitive match by a small amount.
|
||||||
|
//
|
||||||
|
// i.e. "HTML" is more likely than "haml" when "HM" is typed.
|
||||||
|
//
|
||||||
|
// This will not change the order of suggestions based on SCORE_* until
|
||||||
|
// 1000 characters are inserted between matches.
|
||||||
|
const PENALTY_CASE_MISMATCH = 0.999999 |
||||||
|
// Match higher for letters closer to the beginning of the word
|
||||||
|
// const PENALTY_DISTANCE_FROM_START = 0.9
|
||||||
|
// If the word has more characters than the user typed, it should
|
||||||
|
// be penalised slightly.
|
||||||
|
//
|
||||||
|
// i.e. "html" is more likely than "html5" if I type "html".
|
||||||
|
//
|
||||||
|
// However, it may well be the case that there's a sensible secondary
|
||||||
|
// ordering (like alphabetical) that it makes sense to rely on when
|
||||||
|
// there are many prefix matches, so we don't make the penalty increase
|
||||||
|
// with the number of tokens.
|
||||||
|
const PENALTY_NOT_COMPLETE = 0.98 |
||||||
|
|
||||||
|
const IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/ |
||||||
|
const COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g |
||||||
|
const IS_SPACE_REGEXP = /[\s-]/ |
||||||
|
const COUNT_SPACE_REGEXP = /[\s-]/g |
||||||
|
|
||||||
|
function commandScoreInner( |
||||||
|
string: string, |
||||||
|
abbreviation: string, |
||||||
|
lowerString: string, |
||||||
|
lowerAbbreviation: string, |
||||||
|
stringIndex: number, |
||||||
|
abbreviationIndex: number, |
||||||
|
memoizedResults: { [x: string]: number }, |
||||||
|
) { |
||||||
|
if (abbreviationIndex === abbreviation.length) { |
||||||
|
if (stringIndex === string.length) { |
||||||
|
return SCORE_CONTINUE_MATCH |
||||||
|
} |
||||||
|
return PENALTY_NOT_COMPLETE |
||||||
|
} |
||||||
|
|
||||||
|
const memoizeKey = `${stringIndex},${abbreviationIndex}` |
||||||
|
if (memoizedResults[memoizeKey] !== undefined) { |
||||||
|
return memoizedResults[memoizeKey] |
||||||
|
} |
||||||
|
|
||||||
|
const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex) |
||||||
|
let index = lowerString.indexOf(abbreviationChar, stringIndex) |
||||||
|
let highScore = 0 |
||||||
|
|
||||||
|
let score, transposedScore, wordBreaks, spaceBreaks |
||||||
|
|
||||||
|
while (index >= 0) { |
||||||
|
score = commandScoreInner( |
||||||
|
string, |
||||||
|
abbreviation, |
||||||
|
lowerString, |
||||||
|
lowerAbbreviation, |
||||||
|
index + 1, |
||||||
|
abbreviationIndex + 1, |
||||||
|
memoizedResults, |
||||||
|
) |
||||||
|
if (score > highScore) { |
||||||
|
if (index === stringIndex) { |
||||||
|
score *= SCORE_CONTINUE_MATCH |
||||||
|
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { |
||||||
|
score *= SCORE_NON_SPACE_WORD_JUMP |
||||||
|
wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP) |
||||||
|
if (wordBreaks && stringIndex > 0) { |
||||||
|
score *= PENALTY_SKIPPED ** wordBreaks.length |
||||||
|
} |
||||||
|
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { |
||||||
|
score *= SCORE_SPACE_WORD_JUMP |
||||||
|
spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP) |
||||||
|
if (spaceBreaks && stringIndex > 0) { |
||||||
|
score *= PENALTY_SKIPPED ** spaceBreaks.length |
||||||
|
} |
||||||
|
} else { |
||||||
|
score *= SCORE_CHARACTER_JUMP |
||||||
|
if (stringIndex > 0) { |
||||||
|
score *= PENALTY_SKIPPED ** (index - stringIndex) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { |
||||||
|
score *= PENALTY_CASE_MISMATCH |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ( |
||||||
|
(score < SCORE_TRANSPOSITION && lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) || |
||||||
|
(lowerAbbreviation.charAt(abbreviationIndex + 1) === lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
|
||||||
|
lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex)) |
||||||
|
) { |
||||||
|
transposedScore = commandScoreInner( |
||||||
|
string, |
||||||
|
abbreviation, |
||||||
|
lowerString, |
||||||
|
lowerAbbreviation, |
||||||
|
index + 1, |
||||||
|
abbreviationIndex + 2, |
||||||
|
memoizedResults, |
||||||
|
) |
||||||
|
|
||||||
|
if (transposedScore * SCORE_TRANSPOSITION > score) { |
||||||
|
score = transposedScore * SCORE_TRANSPOSITION |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (score > highScore) { |
||||||
|
highScore = score |
||||||
|
} |
||||||
|
|
||||||
|
index = lowerString.indexOf(abbreviationChar, index + 1) |
||||||
|
} |
||||||
|
|
||||||
|
memoizedResults[memoizeKey] = highScore |
||||||
|
return highScore |
||||||
|
} |
||||||
|
|
||||||
|
function formatInput(string: string) { |
||||||
|
// convert all valid space characters to space so they match each other
|
||||||
|
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ') |
||||||
|
} |
||||||
|
|
||||||
|
export function commandScore(string: string, abbreviation: string): number { |
||||||
|
/* NOTE: |
||||||
|
* in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() |
||||||
|
* was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. |
||||||
|
*/ |
||||||
|
return commandScoreInner(string, abbreviation, formatInput(string), formatInput(abbreviation), 0, 0, {}) |
||||||
|
} |
@ -1,3 +1,623 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { useMagicKeys, whenever } from '@vueuse/core' |
||||||
|
// import { useNuxtApp } from '#app' |
||||||
|
import { commandScore } from './command-score' |
||||||
|
import type { ComputedRef, VNode } from '#imports' |
||||||
|
import { iconMap, onClickOutside } from '#imports' |
||||||
|
import type { CommandPaletteType } from '~/lib' |
||||||
|
|
||||||
|
interface CmdAction { |
||||||
|
id: string |
||||||
|
title: string |
||||||
|
hotkey?: string |
||||||
|
parent?: string |
||||||
|
handler?: Function |
||||||
|
scopePayload?: any |
||||||
|
icon?: VNode | string |
||||||
|
keywords?: string[] |
||||||
|
section?: string |
||||||
|
is_default?: number | null |
||||||
|
} |
||||||
|
|
||||||
|
const props = defineProps<{ |
||||||
|
open: boolean |
||||||
|
data: CmdAction[] |
||||||
|
scope?: string |
||||||
|
placeholder?: string |
||||||
|
hotkey?: string |
||||||
|
loadTemporaryScope?: (scope: { scope: string; data: any }) => void |
||||||
|
setActiveCmdView: (cmd: CommandPaletteType) => void |
||||||
|
}>() |
||||||
|
|
||||||
|
const emits = defineEmits(['update:open', 'scope']) |
||||||
|
|
||||||
|
const vOpen = useVModel(props, 'open', emits) |
||||||
|
|
||||||
|
const { t } = useI18n() |
||||||
|
|
||||||
|
const activeScope = ref('root') |
||||||
|
|
||||||
|
const modalEl = ref<HTMLElement>() |
||||||
|
|
||||||
|
const cmdInputEl = ref<HTMLInputElement>() |
||||||
|
|
||||||
|
const cmdInput = ref('') |
||||||
|
|
||||||
|
const { user } = useGlobal() |
||||||
|
|
||||||
|
const selected = ref<string>() |
||||||
|
|
||||||
|
const formattedData: ComputedRef<(CmdAction & { weight: number })[]> = computed(() => { |
||||||
|
const rt: (CmdAction & { weight: number })[] = [] |
||||||
|
for (const el of props.data) { |
||||||
|
rt.push({ |
||||||
|
...el, |
||||||
|
title: el?.section === 'Views' && el?.is_default ? t('title.defaultView') : el.title, |
||||||
|
icon: el.section === 'Views' && el.is_default ? 'grid' : el.icon, |
||||||
|
parent: el.parent || 'root', |
||||||
|
weight: commandScore( |
||||||
|
`${el.section}${el?.section === 'Views' && el?.is_default ? t('title.defaultView') : el.title}${el.keywords?.join()}`, |
||||||
|
cmdInput.value, |
||||||
|
), |
||||||
|
}) |
||||||
|
} |
||||||
|
return rt |
||||||
|
}) |
||||||
|
|
||||||
|
const nestedScope = computed(() => { |
||||||
|
const rt = [] |
||||||
|
let parent = activeScope.value |
||||||
|
while (parent !== 'root') { |
||||||
|
const parentEl = formattedData.value.find((el) => el.id === parent) |
||||||
|
rt.push({ |
||||||
|
id: parent, |
||||||
|
label: parentEl?.title, |
||||||
|
icon: parentEl?.icon, |
||||||
|
}) |
||||||
|
parent = parentEl?.parent || 'root' |
||||||
|
} |
||||||
|
return rt.reverse() |
||||||
|
}) |
||||||
|
|
||||||
|
const isThereAnyActionInScope = (sc: string): boolean => { |
||||||
|
return formattedData.value.some((el) => { |
||||||
|
if (el.parent === sc) { |
||||||
|
if (!el.handler) { |
||||||
|
return isThereAnyActionInScope(el.id) |
||||||
|
} |
||||||
|
return true |
||||||
|
} |
||||||
|
return false |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const getAvailableScopes = (sc: string) => { |
||||||
|
const tempChildScopes = formattedData.value.filter((el) => el.parent === sc && !el.handler).map((el) => el.id) |
||||||
|
for (const el of tempChildScopes) { |
||||||
|
tempChildScopes.push(...getAvailableScopes(el)) |
||||||
|
} |
||||||
|
return tempChildScopes |
||||||
|
} |
||||||
|
|
||||||
|
const activeScopes = computed(() => { |
||||||
|
return getAvailableScopes(activeScope.value) |
||||||
|
}) |
||||||
|
|
||||||
|
const actionList = computed(() => { |
||||||
|
const sections = formattedData.value.filter((el) => el.section).map((el) => el.section) |
||||||
|
formattedData.value.sort((a, b) => { |
||||||
|
if (a.section && b.section) { |
||||||
|
if (sections.indexOf(a.section) < sections.indexOf(b.section)) return -1 |
||||||
|
if (sections.indexOf(a.section) > sections.indexOf(b.section)) return 1 |
||||||
|
return 0 |
||||||
|
} |
||||||
|
if (a.section) return 1 |
||||||
|
if (b.section) return -1 |
||||||
|
return 0 |
||||||
|
}) |
||||||
|
return formattedData.value.filter((el) => { |
||||||
|
if (cmdInput.value === '') { |
||||||
|
if (el.parent === activeScope.value) { |
||||||
|
if (!el.handler) { |
||||||
|
return isThereAnyActionInScope(el.id) |
||||||
|
} |
||||||
|
return true |
||||||
|
} |
||||||
|
return false |
||||||
|
} else { |
||||||
|
if (el.parent === activeScope.value || activeScopes.value.includes(el.parent || 'root')) { |
||||||
|
if (!el.handler) { |
||||||
|
return isThereAnyActionInScope(el.id) |
||||||
|
} |
||||||
|
return true |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
const searchedActionList = computed(() => { |
||||||
|
if (cmdInput.value === '') return actionList.value |
||||||
|
actionList.value.sort((a, b) => { |
||||||
|
if (a.weight > b.weight) return -1 |
||||||
|
if (a.weight < b.weight) return 1 |
||||||
|
return 0 |
||||||
|
}) |
||||||
|
return actionList.value |
||||||
|
.filter((el) => el.weight > 0) |
||||||
|
.sort((a, b) => b.section?.toLowerCase().localeCompare(a.section?.toLowerCase() as string) || 0) |
||||||
|
}) |
||||||
|
|
||||||
|
const actionListGroupedBySection = computed(() => { |
||||||
|
const rt: { [key: string]: CmdAction[] } = {} |
||||||
|
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) |
||||||
|
} |
||||||
|
}) |
||||||
|
return rt |
||||||
|
}) |
||||||
|
|
||||||
|
const keys = useMagicKeys() |
||||||
|
|
||||||
|
const setAction = (action: string) => { |
||||||
|
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' }) |
||||||
|
} else if (actionIndex === searchedActionList.value.length - 1) { |
||||||
|
document.querySelector('.cmdk-actions')?.scrollTo({ top: 999999, behavior: 'smooth' }) |
||||||
|
} else { |
||||||
|
document.querySelector('.cmdk-action.selected')?.scrollIntoView({ |
||||||
|
behavior: 'smooth', |
||||||
|
block: 'nearest', |
||||||
|
}) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const selectFirstAction = () => { |
||||||
|
if (searchedActionList.value.length > 0) { |
||||||
|
setAction(searchedActionList.value[0].id) |
||||||
|
} else { |
||||||
|
selected.value = undefined |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const setScope = (scope: string) => { |
||||||
|
activeScope.value = scope |
||||||
|
|
||||||
|
emits('scope', scope) |
||||||
|
|
||||||
|
nextTick(() => { |
||||||
|
cmdInputEl.value?.focus() |
||||||
|
selectFirstAction() |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const show = () => { |
||||||
|
if (!user.value) return |
||||||
|
if (props.scope === 'disabled') return |
||||||
|
vOpen.value = true |
||||||
|
cmdInput.value = '' |
||||||
|
nextTick(() => { |
||||||
|
setScope(props.scope || 'root') |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const hide = () => { |
||||||
|
vOpen.value = false |
||||||
|
} |
||||||
|
|
||||||
|
const fireAction = (action: CmdAction, preview = false) => { |
||||||
|
if (preview) { |
||||||
|
if (action?.scopePayload) { |
||||||
|
setScope(action.scopePayload.scope) |
||||||
|
if (action.scopePayload.data) { |
||||||
|
props.loadTemporaryScope?.(action.scopePayload) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
if (action?.handler) { |
||||||
|
action.handler() |
||||||
|
hide() |
||||||
|
} else { |
||||||
|
setScope(action.id) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
whenever(keys.ctrl_k, () => { |
||||||
|
show() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.meta_k, () => { |
||||||
|
show() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.Escape, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.ctrl_l, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.meta_l, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.ctrl_j, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.meta_j, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.arrowup, () => { |
||||||
|
if (vOpen.value) { |
||||||
|
const idx = searchedActionList.value.findIndex((el) => el.id === selected.value) |
||||||
|
if (idx > 0) { |
||||||
|
setAction(searchedActionList.value[idx - 1].id) |
||||||
|
} else if (idx === 0) { |
||||||
|
setAction(searchedActionList.value[searchedActionList.value.length - 1].id) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.arrowdown, () => { |
||||||
|
if (vOpen.value) { |
||||||
|
const idx = searchedActionList.value.findIndex((el) => el.id === selected.value) |
||||||
|
if (idx < searchedActionList.value.length - 1) { |
||||||
|
setAction(searchedActionList.value[idx + 1].id) |
||||||
|
} else if (idx === searchedActionList.value.length - 1) { |
||||||
|
setAction(searchedActionList.value[0].id) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.Enter, () => { |
||||||
|
if (vOpen.value) { |
||||||
|
const selectedEl = formattedData.value.find((el) => el.id === selected.value) |
||||||
|
cmdInput.value = '' |
||||||
|
if (selectedEl) { |
||||||
|
fireAction(selectedEl, keys.shift.value) |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.Backspace, () => { |
||||||
|
if (vOpen.value && cmdInput.value === '' && activeScope.value !== 'root') { |
||||||
|
const activeEl = formattedData.value.find((el) => el.id === activeScope.value) |
||||||
|
setScope(activeEl?.parent || 'root') |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
onClickOutside(modalEl, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
defineExpose({ |
||||||
|
open: show, |
||||||
|
close: hide, |
||||||
|
setScope, |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
<template> |
<template> |
||||||
<div /> |
<div v-show="vOpen" class="cmdk-modal" :class="{ 'cmdk-modal-active': vOpen }"> |
||||||
|
<div ref="modalEl" class="cmdk-modal-content h-[25.25rem]"> |
||||||
|
<div class="cmdk-header"> |
||||||
|
<div class="cmdk-input-wrapper"> |
||||||
|
<GeneralIcon icon="search" class="h-4 w-4 text-gray-500" /> |
||||||
|
<div |
||||||
|
v-for="el of nestedScope" |
||||||
|
:key="`cmdk-breadcrumb-${el.id}`" |
||||||
|
v-e="['a:cmdk:setScope']" |
||||||
|
class="text-gray-600 text-sm cursor-pointer flex gap-1 items-center font-medium capitalize" |
||||||
|
@click="setScope(el.id)" |
||||||
|
> |
||||||
|
<component |
||||||
|
:is="(iconMap as any)[el.icon]" |
||||||
|
v-if="el.icon && typeof el.icon === 'string' && (iconMap as any)[el.icon]" |
||||||
|
class="cmdk-action-icon" |
||||||
|
:class="{ |
||||||
|
'!text-blue-500': el.icon === 'grid', |
||||||
|
'!text-purple-500': el.icon === 'form', |
||||||
|
'!text-[#FF9052]': el.icon === 'kanban', |
||||||
|
'!text-pink-500': el.icon === 'gallery', |
||||||
|
}" |
||||||
|
/> |
||||||
|
<div v-else-if="el.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center"> |
||||||
|
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="el.icon" readonly /> |
||||||
|
</div> |
||||||
|
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg"> |
||||||
|
<template #title> |
||||||
|
{{ el.label }} |
||||||
</template> |
</template> |
||||||
|
<span class="truncate capitalize mr-4"> |
||||||
|
{{ el.label }} |
||||||
|
</span> |
||||||
|
</a-tooltip> |
||||||
|
|
||||||
|
<span class="text-gray-400 text-sm font-medium pl-1">/</span> |
||||||
|
</div> |
||||||
|
<input |
||||||
|
ref="cmdInputEl" |
||||||
|
v-model="cmdInput" |
||||||
|
class="cmdk-input" |
||||||
|
type="text" |
||||||
|
:placeholder="props.placeholder" |
||||||
|
@input="selectFirstAction" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="cmdk-body"> |
||||||
|
<div class="cmdk-actions nc-scrollbar-md"> |
||||||
|
<div v-if="searchedActionList.length === 0"> |
||||||
|
<div class="cmdk-action"> |
||||||
|
<div class="cmdk-action-content">No action found.</div> |
||||||
|
</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"> |
||||||
|
<component |
||||||
|
:is="(iconMap as any)[act.icon]" |
||||||
|
v-if="act.icon && typeof act.icon === 'string' && (iconMap as any)[act.icon]" |
||||||
|
class="cmdk-action-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', |
||||||
|
}" |
||||||
|
/> |
||||||
|
<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 /> |
||||||
|
</div> |
||||||
|
<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 |
||||||
|
<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> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<CmdFooter active-cmd="cmd-k" :set-active-cmd-view="setActiveCmdView" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"> |
||||||
|
/* TODO Move styles to Windi Classes */ |
||||||
|
:root { |
||||||
|
--cmdk-secondary-background-color: rgb(230, 230, 230); |
||||||
|
--cmdk-secondary-text-color: rgb(101, 105, 111); |
||||||
|
--cmdk-selected-background: rgb(245, 245, 245); |
||||||
|
|
||||||
|
--cmdk-icon-color: var(--cmdk-secondary-text-color); |
||||||
|
--cmdk-icon-size: 1.2em; |
||||||
|
|
||||||
|
--cmdk-modal-background: #fff; |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-modal { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
background-color: rgba(255, 255, 255, 0.5); |
||||||
|
z-index: 1000; |
||||||
|
|
||||||
|
color: rgb(60, 65, 73); |
||||||
|
font-size: 16px; |
||||||
|
|
||||||
|
.cmdk-key { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
width: auto; |
||||||
|
height: 1em; |
||||||
|
font-size: 1.25em; |
||||||
|
border-radius: 0.25em; |
||||||
|
background: var(--cmdk-secondary-background-color); |
||||||
|
color: var(--cmdk-secondary-text-color); |
||||||
|
margin-right: 0.2em; |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-modal-content { |
||||||
|
position: relative; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
flex-shrink: 1; |
||||||
|
-webkit-box-flex: 1; |
||||||
|
flex-grow: 1; |
||||||
|
padding: 0; |
||||||
|
overflow: hidden; |
||||||
|
margin: auto; |
||||||
|
box-shadow: rgb(0 0 0 / 50%) 0px 16px 70px; |
||||||
|
top: 20%; |
||||||
|
border-radius: 16px; |
||||||
|
max-width: 640px; |
||||||
|
background: var(--cmdk-modal-background); |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-input-wrapper { |
||||||
|
@apply py-2 px-4 flex items-center gap-2; |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-input { |
||||||
|
@apply text-sm; |
||||||
|
flex-grow: 1; |
||||||
|
flex-shrink: 0; |
||||||
|
margin: 0px; |
||||||
|
border: none; |
||||||
|
appearance: none; |
||||||
|
background: transparent; |
||||||
|
outline: none; |
||||||
|
box-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important; |
||||||
|
|
||||||
|
caret-color: pink; |
||||||
|
color: rgb(60, 65, 73); |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-input::placeholder { |
||||||
|
color: rgb(165, 165, 165); |
||||||
|
opacity: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-input:-ms-input-placeholder { |
||||||
|
color: rgb(165, 165, 165); |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-input::-ms-input-placeholder { |
||||||
|
color: rgb(165, 165, 165); |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-actions { |
||||||
|
max-height: 310px; |
||||||
|
margin: 0px; |
||||||
|
padding: 0.5em 0px; |
||||||
|
list-style: none; |
||||||
|
scroll-behavior: smooth; |
||||||
|
overflow: auto; |
||||||
|
position: relative; |
||||||
|
|
||||||
|
--scrollbar-track: initial; |
||||||
|
--scrollbar-thumb: initial; |
||||||
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); |
||||||
|
overflow: overlay; |
||||||
|
scrollbar-width: auto; |
||||||
|
scrollbar-width: thin; |
||||||
|
--scrollbar-thumb: #e1e3e6; |
||||||
|
--scrollbar-track: #fff; |
||||||
|
|
||||||
|
.cmdk-action { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: flex-start; |
||||||
|
justify-content: center; |
||||||
|
outline: none; |
||||||
|
transition: color 0s ease 0s; |
||||||
|
width: 100%; |
||||||
|
font-size: 0.9em; |
||||||
|
border-left: 4px solid transparent; |
||||||
|
|
||||||
|
.cmdk-keyboard { |
||||||
|
display: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
&.selected { |
||||||
|
cursor: pointer; |
||||||
|
background-color: #f4f4f5; |
||||||
|
border-left: 4px solid var(--ant-primary-color); |
||||||
|
outline: none; |
||||||
|
|
||||||
|
.cmdk-keyboard { |
||||||
|
display: flex; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-action-content { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
flex-shrink: 0.01; |
||||||
|
flex-grow: 1; |
||||||
|
white-space: nowrap; |
||||||
|
overflow: hidden; |
||||||
|
text-overflow: ellipsis; |
||||||
|
padding: 0.75em 1em; |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-action-icon { |
||||||
|
margin-right: 0.4em; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-action-section { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
width: 100%; |
||||||
|
|
||||||
|
.cmdk-action-section-header { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
padding: 8px 16px; |
||||||
|
font-size: 14px; |
||||||
|
color: #6a7184; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-footer { |
||||||
|
display: flex; |
||||||
|
border-top: 1px solid rgb(230, 230, 230); |
||||||
|
background: rgba(242, 242, 242, 0.4); |
||||||
|
font-size: 0.8em; |
||||||
|
padding: 0.3em 0.6em; |
||||||
|
color: var(--cmdk-secondary-text-color); |
||||||
|
.cmdk-footer-left { |
||||||
|
display: flex; |
||||||
|
flex-grow: 1; |
||||||
|
align-items: center; |
||||||
|
.cmdk-key-helper { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
margin-right: 1.5em; |
||||||
|
} |
||||||
|
} |
||||||
|
.cmdk-footer-right { |
||||||
|
display: flex; |
||||||
|
flex-grow: 1; |
||||||
|
justify-content: flex-end; |
||||||
|
align-items: center; |
||||||
|
.nc-brand-icon { |
||||||
|
margin-left: 0.5em; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
||||||
|
@ -0,0 +1,307 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { onKeyUp, useDebounceFn, useMagicKeys, useVModel, whenever } from '@vueuse/core' |
||||||
|
import { onClickOutside } from '#imports' |
||||||
|
import type { CommandPaletteType } from '~/lib' |
||||||
|
|
||||||
|
const props = defineProps<{ |
||||||
|
open: boolean |
||||||
|
setActiveCmdView: (cmd: CommandPaletteType) => void |
||||||
|
}>() |
||||||
|
|
||||||
|
const emits = defineEmits(['update:open']) |
||||||
|
|
||||||
|
const vOpen = useVModel(props, 'open', emits) |
||||||
|
|
||||||
|
const modalEl = ref<HTMLElement>() |
||||||
|
|
||||||
|
const { user } = useGlobal() |
||||||
|
|
||||||
|
const viewStore = useViewsStore() |
||||||
|
|
||||||
|
const { recentViews, activeView } = storeToRefs(viewStore) |
||||||
|
|
||||||
|
const selected: Ref<string> = ref('') |
||||||
|
|
||||||
|
const newView: Ref< |
||||||
|
| { |
||||||
|
viewId: string | null |
||||||
|
tableId: string |
||||||
|
baseId: string |
||||||
|
} |
||||||
|
| undefined |
||||||
|
> = ref() |
||||||
|
|
||||||
|
const changeView = useDebounceFn( |
||||||
|
async ({ viewId, tableId, baseId }: { viewId: string | null; tableId: string; baseId: string }) => { |
||||||
|
await viewStore.changeView({ viewId, tableId, baseId }) |
||||||
|
vOpen.value = false |
||||||
|
}, |
||||||
|
200, |
||||||
|
) |
||||||
|
|
||||||
|
const keys = useMagicKeys() |
||||||
|
|
||||||
|
const { current } = keys |
||||||
|
|
||||||
|
onKeyUp('Enter', async () => { |
||||||
|
if (vOpen.value && newView.value) { |
||||||
|
await changeView({ viewId: newView.value.viewId, tableId: newView.value.tableId, baseId: newView.value.baseId }) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
function scrollToTarget() { |
||||||
|
const element = document.querySelector('.cmdk-action.selected') |
||||||
|
const headerOffset = 45 |
||||||
|
const elementPosition = element?.getBoundingClientRect().top |
||||||
|
const offsetPosition = elementPosition! + window.pageYOffset - headerOffset |
||||||
|
|
||||||
|
window.scrollTo({ |
||||||
|
top: offsetPosition, |
||||||
|
behavior: 'smooth', |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const moveUp = () => { |
||||||
|
if (!recentViews.value.length) return |
||||||
|
const index = recentViews.value.findIndex((v) => v.tableID + v.viewName === selected.value) |
||||||
|
if (index === 0) { |
||||||
|
selected.value = |
||||||
|
recentViews.value[recentViews.value.length - 1].tableID + recentViews.value[recentViews.value.length - 1].viewName |
||||||
|
|
||||||
|
const cmdOption = recentViews.value[recentViews.value.length - 1] |
||||||
|
newView.value = { |
||||||
|
viewId: cmdOption.viewId ?? null, |
||||||
|
tableId: cmdOption.tableID, |
||||||
|
baseId: cmdOption.baseId, |
||||||
|
} |
||||||
|
document.querySelector('.actions')?.scrollTo({ top: 99999, behavior: 'smooth' }) |
||||||
|
} else { |
||||||
|
selected.value = recentViews.value[index - 1].tableID + recentViews.value[index - 1].viewName |
||||||
|
const cmdOption = recentViews.value[index - 1] |
||||||
|
scrollToTarget() |
||||||
|
|
||||||
|
newView.value = { |
||||||
|
viewId: cmdOption.viewId ?? null, |
||||||
|
tableId: cmdOption.tableID, |
||||||
|
baseId: cmdOption.baseId, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const moveDown = () => { |
||||||
|
if (!recentViews.value.length) return |
||||||
|
const index = recentViews.value.findIndex((v) => v.tableID + v.viewName === selected.value) |
||||||
|
if (index === recentViews.value.length - 1) { |
||||||
|
selected.value = recentViews.value[0].tableID + recentViews.value[0].viewName |
||||||
|
|
||||||
|
const cmdOption = recentViews.value[0] |
||||||
|
newView.value = { |
||||||
|
viewId: cmdOption.viewId ?? null, |
||||||
|
tableId: cmdOption.tableID, |
||||||
|
baseId: cmdOption.baseId, |
||||||
|
} |
||||||
|
document.querySelector('.actions')?.scrollTo({ top: 0, behavior: 'smooth' }) |
||||||
|
} else { |
||||||
|
selected.value = recentViews.value[index + 1].tableID + recentViews.value[index + 1].viewName |
||||||
|
const cmdOption = recentViews.value[index + 1] |
||||||
|
|
||||||
|
scrollToTarget() |
||||||
|
|
||||||
|
newView.value = { |
||||||
|
viewId: cmdOption.viewId ?? null, |
||||||
|
tableId: cmdOption.tableID, |
||||||
|
baseId: cmdOption.baseId, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
whenever(keys['Ctrl+Shift+L'], async () => { |
||||||
|
if (!user.value) return |
||||||
|
vOpen.value = true |
||||||
|
moveUp() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys['Meta+Shift+L'], async () => { |
||||||
|
if (!user.value) return |
||||||
|
vOpen.value = true |
||||||
|
moveUp() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.ctrl_l, async () => { |
||||||
|
if (!user.value) return |
||||||
|
if (current.has('shift')) return |
||||||
|
vOpen.value = true |
||||||
|
moveDown() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.meta_l, async () => { |
||||||
|
if (!user.value) return |
||||||
|
if (current.has('shift')) return |
||||||
|
vOpen.value = true |
||||||
|
moveDown() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.arrowup, () => { |
||||||
|
if (vOpen.value) moveUp() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.arrowdown, () => { |
||||||
|
if (vOpen.value) moveDown() |
||||||
|
}) |
||||||
|
|
||||||
|
const hide = () => { |
||||||
|
vOpen.value = false |
||||||
|
} |
||||||
|
|
||||||
|
whenever(keys.Escape, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.ctrl_k, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.meta_k, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.ctrl_j, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
whenever(keys.meta_j, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
onClickOutside(modalEl, () => { |
||||||
|
if (vOpen.value) hide() |
||||||
|
}) |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
document.querySelector('.cmdOpt-list')?.focus() |
||||||
|
if (!activeView.value) return |
||||||
|
const index = recentViews.value.findIndex( |
||||||
|
(v) => v.viewName === activeView.value?.name && v.tableID === activeView.value?.tableId, |
||||||
|
) |
||||||
|
if (index + 1 > recentViews.value.length) { |
||||||
|
selected.value = recentViews.value[0].tableID + recentViews.value[0].viewName |
||||||
|
} else { |
||||||
|
selected.value = recentViews.value[index + 1].tableID + recentViews.value[index + 1].viewName |
||||||
|
} |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div v-if="vOpen" class="cmdk-modal cmdl-modal" :class="{ 'cmdk-modal-active cmdl-modal-active': vOpen }"> |
||||||
|
<div ref="modalEl" class="cmdk-modal-content cmdl-modal-content relative h-[25.25rem]"> |
||||||
|
<div class="flex items-center bg-white w-full z-[50]"> |
||||||
|
<div class="text-sm p-4 text-gray-500">Recent Views</div> |
||||||
|
</div> |
||||||
|
<div class="flex flex-col shrink grow overflow-hidden shadow-[rgb(0_0_0_/_50%)_0px_16px_70px] max-w-[650px] p-0"> |
||||||
|
<div class="scroll-smooth actions overflow-auto nc-scrollbar-md relative m-0 px-0 py-2"> |
||||||
|
<div v-if="recentViews.length < 1" class="flex flex-col p-4 items-start justify-center text-md">No recent views</div> |
||||||
|
<div v-else class="flex mb-10 flex-col cmdOpt-list w-full"> |
||||||
|
<div |
||||||
|
v-for="cmdOption of recentViews" |
||||||
|
:key="cmdOption.tableID + cmdOption.viewName" |
||||||
|
v-e="['a:cmdL:changeView']" |
||||||
|
:class="{ |
||||||
|
selected: selected === cmdOption.tableID + cmdOption.viewName, |
||||||
|
}" |
||||||
|
class="cmdk-action" |
||||||
|
@click="changeView({ viewId: cmdOption.viewId, tableId: cmdOption.tableID, baseId: cmdOption.baseId })" |
||||||
|
> |
||||||
|
<div class="cmdk-action-content !flex w-full"> |
||||||
|
<div class="flex gap-2 w-full flex-grow-1 items-center"> |
||||||
|
<GeneralViewIcon :meta="{ type: cmdOption.viewType }" /> |
||||||
|
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg"> |
||||||
|
<template #title> |
||||||
|
{{ cmdOption.isDefault ? $t('title.defaultView') : cmdOption.viewName }} |
||||||
|
</template> |
||||||
|
<span class="max-w- truncate capitalize"> |
||||||
|
{{ cmdOption.isDefault ? $t('title.defaultView') : cmdOption.viewName }} |
||||||
|
</span> |
||||||
|
</a-tooltip> |
||||||
|
</div> |
||||||
|
<div class="flex gap-2 bg-gray-100 px-2 py-1 rounded-md text-gray-600 items-center"> |
||||||
|
<component :is="iconMap.project" class="w-4 h-4 text-transparent" /> |
||||||
|
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg"> |
||||||
|
<template #title> |
||||||
|
{{ cmdOption.baseName }} |
||||||
|
</template> |
||||||
|
<span class="max-w-32 truncate capitalize"> |
||||||
|
{{ cmdOption.baseName }} |
||||||
|
</span> |
||||||
|
</a-tooltip> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<CmdFooter active-cmd="cmd-l" :set-active-cmd-view="setActiveCmdView" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"> |
||||||
|
/* TODO Move styles to Windi Classes */ |
||||||
|
|
||||||
|
:root { |
||||||
|
--cmdk-secondary-background-color: rgb(230, 230, 230); |
||||||
|
--cmdk-secondary-text-color: rgb(101, 105, 111); |
||||||
|
--cmdk-selected-background: rgb(245, 245, 245); |
||||||
|
|
||||||
|
--cmdk-icon-color: var(--cmdk-secondary-text-color); |
||||||
|
--cmdk-icon-size: 1.2em; |
||||||
|
|
||||||
|
--cmdk-modal-background: #fff; |
||||||
|
} |
||||||
|
.cmdk-modal { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
width: 100%; |
||||||
|
height: 100%; |
||||||
|
background-color: rgba(255, 255, 255, 0.5); |
||||||
|
z-index: 1000; |
||||||
|
|
||||||
|
color: rgb(60, 65, 73); |
||||||
|
font-size: 16px; |
||||||
|
|
||||||
|
.cmdk-action { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
align-items: flex-start; |
||||||
|
justify-content: center; |
||||||
|
outline: none; |
||||||
|
transition: color 0s ease 0s; |
||||||
|
width: 100%; |
||||||
|
font-size: 0.9em; |
||||||
|
border-left: 4px solid transparent; |
||||||
|
|
||||||
|
&.selected { |
||||||
|
cursor: pointer; |
||||||
|
background-color: rgb(248, 249, 251); |
||||||
|
border-left: 4px solid #36f; |
||||||
|
outline: none; |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-action-content { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
flex-shrink: 0.01; |
||||||
|
flex-grow: 1; |
||||||
|
white-space: nowrap; |
||||||
|
overflow: hidden; |
||||||
|
text-overflow: ellipsis; |
||||||
|
padding: 0.75em 1em; |
||||||
|
width: 640px; |
||||||
|
} |
||||||
|
|
||||||
|
.cmdk-action-icon { |
||||||
|
margin-right: 0.4em; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
@ -1 +1,26 @@ |
|||||||
<template><span /></template> |
<script setup lang="ts"> |
||||||
|
const { commandPalette } = useCommandPalette() |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<NcButton |
||||||
|
v-e="['c:quick-actions']" |
||||||
|
type="text" |
||||||
|
size="small" |
||||||
|
class="nc-sidebar-top-button w-full !hover:bg-gray-200 !rounded-md !xs:hidden" |
||||||
|
data-testid="nc-sidebar-search-btn" |
||||||
|
:centered="false" |
||||||
|
@click="commandPalette?.open()" |
||||||
|
> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<MaterialSymbolsSearch class="!h-3.9" /> |
||||||
|
Quick Actions |
||||||
|
<div |
||||||
|
class="inline-flex gap-1 justify-center text-xs px-[8px] py-[1px] uppercase border-1 border-gray-300 rounded-md bg-slate-150 text-gray-500" |
||||||
|
> |
||||||
|
<kbd class="text-[16px] mt-[0.5px]">⌘</kbd> |
||||||
|
<kbd class="!leading-4">K</kbd> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</NcButton> |
||||||
|
</template> |
||||||
|
@ -0,0 +1,77 @@ |
|||||||
|
import { navigateTo } from '#imports' |
||||||
|
|
||||||
|
export const homeCommands = [ |
||||||
|
{ |
||||||
|
id: 'user', |
||||||
|
title: 'Account', |
||||||
|
icon: 'account', |
||||||
|
section: 'Accounts', |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'user_account-settings', |
||||||
|
title: 'Account Settings', |
||||||
|
icon: 'settings', |
||||||
|
parent: 'user', |
||||||
|
section: 'Account', |
||||||
|
handler: () => { |
||||||
|
navigateTo('/account/profile') |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'user_account-logout', |
||||||
|
title: 'Logout', |
||||||
|
icon: 'signout', |
||||||
|
parent: 'user', |
||||||
|
section: 'Account', |
||||||
|
handler: () => {}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'user_account-discord', |
||||||
|
title: 'Discord', |
||||||
|
icon: 'discord', |
||||||
|
parent: 'user', |
||||||
|
section: 'Community', |
||||||
|
handler: () => { |
||||||
|
navigateTo('https://discord.gg/8jX2GQn', { external: true }) |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'user_account-twitter', |
||||||
|
title: '(formerly Twitter)', |
||||||
|
icon: 'twitter', |
||||||
|
parent: 'user', |
||||||
|
section: 'Community', |
||||||
|
handler: () => { |
||||||
|
navigateTo('https://twitter.com/NocoDB', { external: true }) |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'user_account-reddit', |
||||||
|
title: 'Reddit', |
||||||
|
icon: 'reddit', |
||||||
|
parent: 'user', |
||||||
|
section: 'Community', |
||||||
|
handler: () => { |
||||||
|
navigateTo('https://www.reddit.com/r/NocoDB/', { external: true }) |
||||||
|
}, |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
/* |
||||||
|
Here is a list of all the available commands defined throughout the app. |
||||||
|
Commands prefixed with a '-' are static commands that are always available. |
||||||
|
Commands prefixed with a '+' are dynamic commands that are only available when the user is in a specific context. |
||||||
|
Commands prefixed with a '*' are scopes |
||||||
|
|
||||||
|
Commands: |
||||||
|
* home (Navigate Home) |
||||||
|
+ workspaces (Workspaces - EE) |
||||||
|
+ bases (Projects) |
||||||
|
* workspace (Workspace - EE) |
||||||
|
+ tables (Tables) |
||||||
|
+ views (Views) |
||||||
|
* account_settings (Account Settings) |
||||||
|
* account_settings-users (Users) |
||||||
|
- account_settings-users-reset_password (Reset Password) |
||||||
|
- account_settings-tokens (Tokens) |
||||||
|
*/ |
File diff suppressed because one or more lines are too long
@ -0,0 +1,19 @@ |
|||||||
|
import { Test } from '@nestjs/testing'; |
||||||
|
import { CommandPaletteController } from './command-palette.controller'; |
||||||
|
import type { TestingModule } from '@nestjs/testing'; |
||||||
|
|
||||||
|
describe('CommandPaletteController', () => { |
||||||
|
let controller: CommandPaletteController; |
||||||
|
|
||||||
|
beforeEach(async () => { |
||||||
|
const module: TestingModule = await Test.createTestingModule({ |
||||||
|
controllers: [CommandPaletteController], |
||||||
|
}).compile(); |
||||||
|
|
||||||
|
controller = module.get<CommandPaletteController>(CommandPaletteController); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should be defined', () => { |
||||||
|
expect(controller).toBeDefined(); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,27 @@ |
|||||||
|
import { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common'; |
||||||
|
import { Request } from 'express'; |
||||||
|
import type { UserType } from 'nocodb-sdk'; |
||||||
|
import { GlobalGuard } from '~/guards/global/global.guard'; |
||||||
|
import { CommandPaletteService } from '~/services/command-palette.service'; |
||||||
|
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; |
||||||
|
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; |
||||||
|
|
||||||
|
@Controller() |
||||||
|
@UseGuards(MetaApiLimiterGuard, GlobalGuard) |
||||||
|
export class CommandPaletteController { |
||||||
|
constructor(private commandPaletteService: CommandPaletteService) {} |
||||||
|
|
||||||
|
@Post('/api/v1/command_palette') |
||||||
|
@Acl('commandPalette', { |
||||||
|
scope: 'org', |
||||||
|
}) |
||||||
|
@HttpCode(200) |
||||||
|
async commandPalette(@Req() req: Request) { |
||||||
|
const data = this.commandPaletteService.commandPalette({ |
||||||
|
user: req?.user as UserType, |
||||||
|
body: req.body, |
||||||
|
}); |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
import { Test } from '@nestjs/testing'; |
||||||
|
import { CommandPaletteService } from './command-palette.service'; |
||||||
|
import type { TestingModule } from '@nestjs/testing'; |
||||||
|
|
||||||
|
describe('CommandPaletteService', () => { |
||||||
|
let service: CommandPaletteService; |
||||||
|
|
||||||
|
beforeEach(async () => { |
||||||
|
const module: TestingModule = await Test.createTestingModule({ |
||||||
|
providers: [CommandPaletteService], |
||||||
|
}).compile(); |
||||||
|
|
||||||
|
service = module.get<CommandPaletteService>(CommandPaletteService); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should be defined', () => { |
||||||
|
expect(service).toBeDefined(); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,114 @@ |
|||||||
|
import { Injectable } from '@nestjs/common'; |
||||||
|
import { type UserType, ViewTypes } from 'nocodb-sdk'; |
||||||
|
import { Base } from '~/models'; |
||||||
|
import { TablesService } from '~/services/tables.service'; |
||||||
|
|
||||||
|
const viewTypeAlias: Record<number, string> = { |
||||||
|
[ViewTypes.GRID]: 'grid', |
||||||
|
[ViewTypes.FORM]: 'form', |
||||||
|
[ViewTypes.GALLERY]: 'gallery', |
||||||
|
[ViewTypes.KANBAN]: 'kanban', |
||||||
|
[ViewTypes.MAP]: 'map', |
||||||
|
}; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class CommandPaletteService { |
||||||
|
constructor(private tablesService: TablesService) {} |
||||||
|
|
||||||
|
async commandPalette(param: { body: any; user: UserType }) { |
||||||
|
const cmdData = []; |
||||||
|
try { |
||||||
|
const { scope } = param.body; |
||||||
|
|
||||||
|
if (scope === 'root') { |
||||||
|
const bases = await Base.list({ user: param.user }); |
||||||
|
|
||||||
|
for (const base of bases) { |
||||||
|
cmdData.push({ |
||||||
|
id: `p-${base.id}`, |
||||||
|
title: base.title, |
||||||
|
icon: 'project', |
||||||
|
section: 'Bases', |
||||||
|
scopePayload: { |
||||||
|
scope: `p-${base.id}`, |
||||||
|
data: { |
||||||
|
base_id: base.id, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}); |
||||||
|
} |
||||||
|
} else if (scope.startsWith('p-')) { |
||||||
|
const allBases = []; |
||||||
|
|
||||||
|
const bases = await Base.list({ user: param.user }); |
||||||
|
|
||||||
|
allBases.push(...bases); |
||||||
|
|
||||||
|
const viewList = []; |
||||||
|
|
||||||
|
for (const base of bases) { |
||||||
|
viewList.push( |
||||||
|
...( |
||||||
|
(await this.tablesService.xcVisibilityMetaGet( |
||||||
|
base.id, |
||||||
|
null, |
||||||
|
false, |
||||||
|
)) as any[] |
||||||
|
).filter((v) => { |
||||||
|
return Object.keys(param.user.roles).some( |
||||||
|
(role) => param.user.roles[role] && !v.disabled[role], |
||||||
|
); |
||||||
|
}), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const tableList = []; |
||||||
|
const vwList = []; |
||||||
|
|
||||||
|
for (const b of allBases) { |
||||||
|
cmdData.push({ |
||||||
|
id: `p-${b.id}`, |
||||||
|
title: b.title, |
||||||
|
icon: 'project', |
||||||
|
section: 'Bases', |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
for (const v of viewList) { |
||||||
|
if (!tableList.find((el) => el.id === `tbl-${v.fk_model_id}`)) { |
||||||
|
tableList.push({ |
||||||
|
id: `tbl-${v.fk_model_id}`, |
||||||
|
title: v._ptn, |
||||||
|
parent: `p-${v.base_id}`, |
||||||
|
icon: v?.table_meta?.icon || v.ptype, |
||||||
|
projectName: bases.find((el) => el.id === v.base_id)?.title, |
||||||
|
section: 'Tables', |
||||||
|
}); |
||||||
|
} |
||||||
|
vwList.push({ |
||||||
|
id: `vw-${v.id}`, |
||||||
|
title: `${v.title}`, |
||||||
|
parent: `tbl-${v.fk_model_id}`, |
||||||
|
icon: v?.meta?.icon || viewTypeAlias[v.type] || 'table', |
||||||
|
projectName: bases.find((el) => el.id === v.base_id)?.title, |
||||||
|
section: 'Views', |
||||||
|
is_default: v?.is_default, |
||||||
|
handler: { |
||||||
|
type: 'navigate', |
||||||
|
payload: `/nc/${v.base_id}/${v.fk_model_id}/${encodeURIComponent( |
||||||
|
v.id, |
||||||
|
)}`,
|
||||||
|
}, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
cmdData.push(...tableList); |
||||||
|
cmdData.push(...vwList); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.log(e); |
||||||
|
return []; |
||||||
|
} |
||||||
|
return cmdData; |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue