mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-28 12:35:02 +02:00
UBERF-4165: Add search to actions popup (#4057)
Signed-off-by: Maxim Karmatskikh <mkarmatskih@gmail.com>
This commit is contained in:
@@ -13,9 +13,14 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { WithLookup, Doc, Ref } from '@hcengineering/core'
|
||||
import { WithLookup, Doc, Ref, Class, SearchResultDoc } from '@hcengineering/core'
|
||||
import { getResource, translate } from '@hcengineering/platform'
|
||||
import { createQuery, getClient, ActionContext } from '@hcengineering/presentation'
|
||||
import presentation, {
|
||||
createQuery,
|
||||
getClient,
|
||||
ActionContext,
|
||||
ObjectSearchCategory
|
||||
} from '@hcengineering/presentation'
|
||||
import ui, {
|
||||
Button,
|
||||
closePopup,
|
||||
@@ -23,27 +28,35 @@
|
||||
Icon,
|
||||
IconArrowLeft,
|
||||
Label,
|
||||
EditWithIcon,
|
||||
IconSearch,
|
||||
deviceOptionsStore,
|
||||
capitalizeFirstLetter,
|
||||
formatKey,
|
||||
themeStore
|
||||
} from '@hcengineering/ui'
|
||||
import { Action, ViewContext } from '@hcengineering/view'
|
||||
import { Action, ActionCategory, ViewContext } from '@hcengineering/view'
|
||||
import { filterActions, getSelection } from '../actions'
|
||||
import view from '../plugin'
|
||||
import { focusStore, selectionStore } from '../selection'
|
||||
import { openDoc } from '../utils'
|
||||
import { ListView, resizeObserver } from '@hcengineering/ui'
|
||||
import ObjectPresenter from './ObjectPresenter.svelte'
|
||||
import { tick } from 'svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, tick } from 'svelte'
|
||||
|
||||
import {
|
||||
type SearchSection,
|
||||
type SearchItem,
|
||||
packSearchResultsForListView,
|
||||
doFulltextSearch,
|
||||
MentionResult
|
||||
} from '@hcengineering/text-editor'
|
||||
|
||||
import ChevronDown from './icons/ChevronDown.svelte'
|
||||
import ChevronUp from './icons/ChevronUp.svelte'
|
||||
|
||||
export let viewContext: ViewContext
|
||||
|
||||
let search: string = ''
|
||||
let actions: Array<WithLookup<Action>> = []
|
||||
let input: EditWithIcon
|
||||
|
||||
const query = createQuery()
|
||||
|
||||
@@ -66,11 +79,14 @@
|
||||
let supportedActions: Array<WithLookup<Action>> = []
|
||||
let filteredActions: Array<WithLookup<Action>> = []
|
||||
|
||||
async function filterVisibleActions (actions: Array<WithLookup<Action>>, docs: Doc[]) {
|
||||
async function filterVisibleActions (
|
||||
actions: Array<WithLookup<Action>>,
|
||||
docs: Doc[]
|
||||
): Promise<Array<WithLookup<Action>>> {
|
||||
const resultActions: Array<WithLookup<Action>> = []
|
||||
|
||||
for (const action of actions) {
|
||||
if (!action.visibilityTester) {
|
||||
if (action.visibilityTester === undefined) {
|
||||
resultActions.push(action)
|
||||
} else {
|
||||
const visibilityTester = await getResource(action.visibilityTester)
|
||||
@@ -85,7 +101,7 @@
|
||||
|
||||
const client = getClient()
|
||||
|
||||
async function getSupportedActions (actions: Array<WithLookup<Action>>) {
|
||||
async function getSupportedActions (actions: Array<WithLookup<Action>>): Promise<void> {
|
||||
const docs = getSelection($focusStore, $selectionStore)
|
||||
let fActions: Array<WithLookup<Action>> = actions
|
||||
|
||||
@@ -114,15 +130,18 @@
|
||||
supportedActions = fActions.sort((a, b) => a.category.localeCompare(b.category))
|
||||
}
|
||||
|
||||
$: getSupportedActions(actions)
|
||||
$: void getSupportedActions(actions)
|
||||
|
||||
async function filterSearchActions (actions: Array<WithLookup<Action>>, search: string): Promise<void> {
|
||||
const res: Array<WithLookup<Action>> = []
|
||||
search = search.trim().toLowerCase()
|
||||
if (search.length > 0) {
|
||||
let preparedSearch = search.trim().toLowerCase()
|
||||
if (preparedSearch.charAt(0) === '/') {
|
||||
preparedSearch = preparedSearch.substring(1)
|
||||
}
|
||||
if (preparedSearch.length > 0) {
|
||||
for (const a of actions) {
|
||||
const tr = await translate(a.label, {}, $themeStore.language)
|
||||
if (tr.toLowerCase().includes(search)) {
|
||||
if (tr.toLowerCase().includes(preparedSearch)) {
|
||||
res.push(a)
|
||||
}
|
||||
}
|
||||
@@ -131,7 +150,7 @@
|
||||
filteredActions = actions
|
||||
}
|
||||
}
|
||||
$: filterSearchActions(supportedActions, search)
|
||||
$: void filterSearchActions(supportedActions, search)
|
||||
|
||||
let selection = 0
|
||||
let list: ListView
|
||||
@@ -140,24 +159,35 @@
|
||||
let activeAction: Action | undefined
|
||||
|
||||
async function handleSelection (evt: Event, selection: number): Promise<void> {
|
||||
const action = filteredActions[selection]
|
||||
if (action.actionPopup !== undefined) {
|
||||
activeAction = action
|
||||
return
|
||||
}
|
||||
const docs = getSelection($focusStore, $selectionStore)
|
||||
if (action.input === 'focus') {
|
||||
const impl = await getResource(action.action)
|
||||
if (impl !== undefined) {
|
||||
closePopup()
|
||||
impl(docs[0], evt, { ...action.actionProps, action })
|
||||
const item = items[selection]
|
||||
|
||||
if (item.item !== undefined) {
|
||||
const doc = item.item.doc
|
||||
void client.findOne(doc._class, { _id: doc._id }).then((value) => {
|
||||
if (value !== undefined) {
|
||||
void openDoc(client.getHierarchy(), value)
|
||||
}
|
||||
})
|
||||
} else if (item.action !== undefined) {
|
||||
const action = item.action
|
||||
if (action.actionPopup !== undefined) {
|
||||
activeAction = action
|
||||
return
|
||||
}
|
||||
}
|
||||
if (action.input === 'selection' || action.input === 'any' || action.input === 'none') {
|
||||
const impl = await getResource(action.action)
|
||||
if (impl !== undefined) {
|
||||
closePopup()
|
||||
impl(docs, evt, { ...action.actionProps, action })
|
||||
const docs = getSelection($focusStore, $selectionStore)
|
||||
if (action.input === 'focus') {
|
||||
const impl = await getResource(action.action)
|
||||
if (impl !== undefined) {
|
||||
closePopup()
|
||||
void impl(docs[0], evt, { ...action.actionProps, action })
|
||||
}
|
||||
}
|
||||
if (action.input === 'selection' || action.input === 'any' || action.input === 'none') {
|
||||
const impl = await getResource(action.action)
|
||||
if (impl !== undefined) {
|
||||
closePopup()
|
||||
void impl(docs, evt, { ...action.actionProps, action })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,11 +206,87 @@
|
||||
if (key.code === 'Enter') {
|
||||
key.preventDefault()
|
||||
key.stopPropagation()
|
||||
handleSelection(key, selection)
|
||||
void handleSelection(key, selection)
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
interface SearchActionItem {
|
||||
num: number
|
||||
item?: SearchResultDoc
|
||||
category?: ObjectSearchCategory
|
||||
action?: WithLookup<Action>
|
||||
actionCategory?: ActionCategory | WithLookup<ActionCategory>
|
||||
}
|
||||
|
||||
function packSearchAndActions (
|
||||
searchItems: SearchItem[],
|
||||
filteredActions: Array<WithLookup<Action>>
|
||||
): SearchActionItem[] {
|
||||
let iter = -1
|
||||
const mappedActions: SearchActionItem[] = filteredActions.map((action, num: number) => {
|
||||
if (num > 0 && filteredActions[num - 1].$lookup?.category?.label !== action.$lookup?.category?.label) {
|
||||
iter = 0
|
||||
} else {
|
||||
iter++
|
||||
}
|
||||
return {
|
||||
num: iter,
|
||||
action,
|
||||
actionCategory: action.$lookup?.category
|
||||
}
|
||||
})
|
||||
return ([] as SearchActionItem[]).concat(searchItems).concat(mappedActions)
|
||||
}
|
||||
|
||||
let items: SearchActionItem[] = []
|
||||
let categories: ObjectSearchCategory[] = []
|
||||
|
||||
client
|
||||
.findAll(presentation.class.ObjectSearchCategory, { context: 'spotlight' })
|
||||
.then(async (results) => {
|
||||
categories = results
|
||||
await updateItems(search, filteredActions)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
})
|
||||
|
||||
async function updateItems (query: string, filteredActions: Array<WithLookup<Action>>): Promise<void> {
|
||||
const classesToSearch: Array<Ref<Class<Doc>>> = []
|
||||
for (const cat of categories) {
|
||||
if (cat.classToSearch !== undefined) {
|
||||
classesToSearch.push(cat.classToSearch)
|
||||
}
|
||||
}
|
||||
|
||||
let sections: SearchSection[] = []
|
||||
if (query !== '' && query.indexOf('/') !== 0) {
|
||||
sections = await doFulltextSearch(client, classesToSearch, query, categories)
|
||||
}
|
||||
|
||||
const searchItems = packSearchResultsForListView(sections)
|
||||
items = packSearchAndActions(searchItems, filteredActions)
|
||||
}
|
||||
|
||||
$: void updateItems(search, filteredActions)
|
||||
|
||||
let textHTML: HTMLInputElement
|
||||
let phTraslate: string = ''
|
||||
let autoFocus = !$deviceOptionsStore.isMobile
|
||||
|
||||
export function focus (): void {
|
||||
textHTML.focus()
|
||||
autoFocus = false
|
||||
}
|
||||
$: if (textHTML !== undefined) {
|
||||
if (autoFocus) focus()
|
||||
}
|
||||
|
||||
$: void translate(view.string.ActionPlaceholder, {}).then((res) => {
|
||||
phTraslate = res
|
||||
})
|
||||
</script>
|
||||
|
||||
<ActionContext
|
||||
@@ -191,13 +297,13 @@
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="selectPopup width-40"
|
||||
class="selectPopup width-40 actionsPopup"
|
||||
style:width="15rem"
|
||||
on:keydown={onKeydown}
|
||||
use:resizeObserver={() => dispatch('changeContent')}
|
||||
>
|
||||
{#if $selectionStore.docs.length > 0 || $focusStore.focus !== undefined || activeAction?.actionPopup !== undefined}
|
||||
<div class="mt-2 ml-2 flex-between flex-no-shrink">
|
||||
<div class="actionsDoc flex-between flex-no-shrink">
|
||||
{#if $selectionStore.docs.length > 0}
|
||||
<div class="item-box">
|
||||
<Label label={view.string.NumberItems} params={{ count: $selectionStore.docs.length }} />
|
||||
@@ -241,82 +347,189 @@
|
||||
on:close={async () => {
|
||||
activeAction = undefined
|
||||
await tick()
|
||||
input?.focus()
|
||||
textHTML?.focus()
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div class="header">
|
||||
<EditWithIcon
|
||||
bind:this={input}
|
||||
icon={IconSearch}
|
||||
size={'large'}
|
||||
width={'100%'}
|
||||
autoFocus={!$deviceOptionsStore.isMobile}
|
||||
<div class="header actionsHeader">
|
||||
<input
|
||||
class="actionsInput"
|
||||
bind:this={textHTML}
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder={view.string.ActionPlaceholder}
|
||||
placeholder={phTraslate}
|
||||
on:change
|
||||
on:input
|
||||
on:keydown
|
||||
/>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<div class="box">
|
||||
<ListView
|
||||
bind:this={list}
|
||||
count={filteredActions.length}
|
||||
count={items.length}
|
||||
bind:selection
|
||||
on:click={async (evt) => {
|
||||
await handleSelection(evt, evt.detail)
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="category" let:item>
|
||||
{@const action = filteredActions[item]}
|
||||
{#if item === 0 || (item > 0 && filteredActions[item - 1].$lookup?.category?.label !== action.$lookup?.category?.label)}
|
||||
<!--Category for first item-->
|
||||
{#if action.$lookup?.category}
|
||||
<div class="category-box">
|
||||
<Label label={action.$lookup.category.label} />
|
||||
<svelte:fragment slot="category" let:item={num}>
|
||||
{@const item = items[num]}
|
||||
{#if item.num === 0}
|
||||
{#if item.category !== undefined}
|
||||
<div class="actionsCategory">
|
||||
<Label label={item.category.title} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.actionCategory}
|
||||
<div class="actionsCategory">
|
||||
<Label label={item.actionCategory.label} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="item" let:item>
|
||||
{@const action = filteredActions[item]}
|
||||
<div class="flex-row-center flex-between flex-grow ml-2 p-3 text-base cursor-pointer">
|
||||
<div class="mr-4 {selection === item ? 'caption-color' : 'content-dark-color'}">
|
||||
<Icon icon={action.icon ?? IconArrowLeft} size={'small'} />
|
||||
<svelte:fragment slot="item" let:item={num}>
|
||||
{@const item = items[num]}
|
||||
{#if item.item !== undefined}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="ap-menuItem withComp actionsSearchItem">
|
||||
<MentionResult value={item.item} />
|
||||
</div>
|
||||
<div class="flex-grow {selection === item ? 'caption-color' : 'content-color'}">
|
||||
<Label label={action.label} />
|
||||
</div>
|
||||
<div class="mr-2 text-md flex-row-center">
|
||||
{#if action.keyBinding}
|
||||
{#each action.keyBinding as key, i}
|
||||
{#if i !== 0}
|
||||
<div class="ml-2 mr-2 lower"><Label label={view.string.Or} /></div>
|
||||
{/if}
|
||||
<div class="flex-row-center">
|
||||
{#each formatKey(key) as k, jj}
|
||||
{#if jj !== 0}
|
||||
<div class="ml-1 mr-1 lower"><Label label={view.string.Then} /></div>
|
||||
{/if}
|
||||
{#each k as kk, j}
|
||||
<div class="flex-center text-sm key-box">
|
||||
{capitalizeFirstLetter(kk.trim())}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.action !== undefined}
|
||||
{@const action = item.action}
|
||||
<div class="flex-row-center flex-between flex-grow ml-2 text-base cursor-pointer actionsitem">
|
||||
<div class="mr-4 {selection === num ? 'caption-color' : 'content-dark-color'}">
|
||||
<Icon icon={action.icon ?? IconArrowLeft} size={'small'} />
|
||||
</div>
|
||||
<div class="flex-grow {selection === num ? 'caption-color' : 'content-color'}">
|
||||
<Label label={action.label} />
|
||||
</div>
|
||||
<div class="mr-2 text-md flex-row-center">
|
||||
{#if action.keyBinding}
|
||||
{#each action.keyBinding as key, i}
|
||||
{#if i !== 0}
|
||||
<div class="ml-2 mr-2 lower"><Label label={view.string.Or} /></div>
|
||||
{/if}
|
||||
<div class="flex-row-center">
|
||||
{#each formatKey(key) as k, jj}
|
||||
{#if jj !== 0}
|
||||
<div class="ml-1 mr-1 lower"><Label label={view.string.Then} /></div>
|
||||
{/if}
|
||||
{#each k as kk, j}
|
||||
<div class="flex-center text-sm key-box">
|
||||
{capitalizeFirstLetter(kk.trim())}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionsHint">
|
||||
<div class="actionsHintLable">
|
||||
<span class="hintNav">
|
||||
<ChevronUp size={'small'} />
|
||||
</span>
|
||||
<span class="hintNav">
|
||||
<ChevronDown size={'small'} />
|
||||
</span>
|
||||
<span class="ml mr">
|
||||
<Label label={view.string.Type} />
|
||||
</span><span class="hintNav">/</span>
|
||||
<span class="ml"><Label label={view.string.ToViewCommands} /></span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="menu-space" />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.actionsHint {
|
||||
border-top: 1px solid var(--theme-popup-divider);
|
||||
padding: 0 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
.actionsHintLable {
|
||||
height: 2.75rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.hintNav {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
margin: 0.13rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ml {
|
||||
margin-left: 0.62rem;
|
||||
}
|
||||
.mr {
|
||||
margin-right: 0.62rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actionsDoc {
|
||||
margin-top: 1rem;
|
||||
margin-left: 1.25rem;
|
||||
margin-right: 1.25rem;
|
||||
}
|
||||
|
||||
.actionsInput {
|
||||
width: 100%;
|
||||
caret-color: var(--theme-caret-color);
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1.125rem;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--theme-dark-color);
|
||||
}
|
||||
}
|
||||
|
||||
.header.actionsHeader {
|
||||
padding-top: 1rem;
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
}
|
||||
|
||||
.selectPopup.actionsPopup {
|
||||
max-height: 30rem;
|
||||
|
||||
.actionsCategory {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.0625rem;
|
||||
color: var(--theme-dark-color);
|
||||
text-transform: uppercase;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.actionsSearchItem {
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
margin: 0.25rem;
|
||||
}
|
||||
|
||||
.actionsitem {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem;
|
||||
margin: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.key-box {
|
||||
padding: 0 0.5rem;
|
||||
min-width: 1.5rem;
|
||||
@@ -325,9 +538,11 @@
|
||||
border: 1px solid var(--theme-button-border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.key-box + .key-box {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.item-box {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
@@ -335,14 +550,4 @@
|
||||
background-color: var(--theme-divider-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.category-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
min-height: 2rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
font-size: 0.625rem;
|
||||
color: var(--theme-caption-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
export let size: 'small' | 'medium' | 'large'
|
||||
const fill: string = 'currentColor'
|
||||
</script>
|
||||
|
||||
<svg class="svg-{size}" {fill} viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 8.24999L2.25 4.49999L2.775 3.97499L6 7.19999L9.225 3.97499L9.75 4.49999L6 8.24999Z" fill-opacity="0.6" />
|
||||
</svg>
|
||||
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
// Copyright © 2023 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
export let size: 'small' | 'medium' | 'large'
|
||||
const fill: string = 'currentColor'
|
||||
</script>
|
||||
|
||||
<svg class="svg-{size}" {fill} viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 3.75L2.25 7.5L2.78033 8.03033L6 4.81066L9.21967 8.03033L9.75 7.5L6 3.75Z" fill-opacity="0.6" />
|
||||
</svg>
|
||||
Reference in New Issue
Block a user