Add ChannelsDropdown (#1459)

Signed-off-by: Alexander Platov <sas_lord@mail.ru>
This commit is contained in:
Alexander Platov 2022-04-20 10:56:45 +03:00 committed by GitHub
parent 30095a6929
commit 6df202f166
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 299 additions and 46 deletions

View File

@ -49,6 +49,7 @@ input {
background-color: transparent; background-color: transparent;
outline: none; outline: none;
color: var(--caption-color); color: var(--caption-color);
&::placeholder { color: var(--dark-color); }
&.wrong-input { background-color: var(--system-error-color) !important; } &.wrong-input { background-color: var(--system-error-color) !important; }
} }
audio, canvas, embed, iframe, img, object, svg, video { audio, canvas, embed, iframe, img, object, svg, video {

View File

@ -42,8 +42,29 @@
color: #d6d6d6; color: #d6d6d6;
border: none; border: none;
caret-color: var(--caret-color); caret-color: var(--caret-color);
}
&::placeholder { color: var(--content-color); } .clear-btn {
display: flex;
justify-content: center;
align-items: center;
width: .75rem;
height: .75rem;
border-radius: 50%;
.icon {
width: .625rem;
height: .625rem;
}
&.show {
color: var(--content-color);
background-color: var(--button-border-color);
cursor: pointer;
&:hover {
color: var(--accent-color);
background-color: var(--button-border-hover);
}
}
} }
} }
@ -80,6 +101,7 @@
.icon { .icon {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
color: var(--content-color);
} }
.color { .color {
width: .875rem; width: .875rem;
@ -105,7 +127,11 @@
margin-right: .75rem; margin-right: .75rem;
} }
.check-right { margin: 0 0 0 2rem; } .check-right { margin: 0 0 0 2rem; }
&:hover { background-color: var(--popup-bg-hover); } &:hover {
background-color: var(--popup-bg-hover);
.icon { color: var(--accent-color); }
}
} }
.sticky-wrapper { .sticky-wrapper {
display: flex; display: flex;

View File

@ -32,6 +32,7 @@
export let width: string | undefined = undefined export let width: string | undefined = undefined
export let resetIconSize: boolean = false export let resetIconSize: boolean = false
export let focus: boolean = false export let focus: boolean = false
export let click: boolean = false
export let title: string | undefined = undefined export let title: string | undefined = undefined
export let input: HTMLButtonElement | undefined = undefined export let input: HTMLButtonElement | undefined = undefined
@ -43,6 +44,10 @@
input.focus() input.focus()
focus = false focus = false
} }
if (click && input) {
input.click()
click = false
}
}) })
</script> </script>
@ -128,6 +133,8 @@
&:disabled { &:disabled {
color: rgb(var(--caption-color) / 40%); color: rgb(var(--caption-color) / 40%);
cursor: not-allowed; cursor: not-allowed;
.btn-icon { opacity: .5; }
} }
&.jf-left { justify-content: flex-start; } &.jf-left { justify-content: flex-start; }

View File

@ -39,14 +39,14 @@
{/if} {/if}
{#each actions as action} {#each actions as action}
<div <div
class="ap-menuItem flex-row-center" class="ap-menuItem flex-row-center withIcon"
on:click={(evt) => { on:click={(evt) => {
dispatch('close') dispatch('close')
action.action(evt, ctx) action.action(evt, ctx)
}} }}
> >
{#if action.icon} {#if action.icon}
<Icon icon={action.icon} size={'small'} /> <div class="icon"><Icon icon={action.icon} size={'small'} /></div>
{/if} {/if}
<div class="ml-3 pr-1"><Label label={action.label} /></div> <div class="ml-3 pr-1"><Label label={action.label} /></div>
</div> </div>
@ -55,3 +55,10 @@
</div> </div>
<div class="ap-space" /> <div class="ap-space" />
</div> </div>
<style lang="scss">
.withIcon {
.icon { color: var(--content-color); }
&:hover .icon { color: var(--accent-color); }
}
</style>

View File

@ -0,0 +1,60 @@
<!--
// Copyright © 2022 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">
import { createEventDispatcher, onMount } from 'svelte'
import type { IntlString } from '@anticrm/platform'
import { translate } from '@anticrm/platform'
import contact from '@anticrm/contact'
import { Button, Icon, IconClose, IconBlueCheck, Label } from '@anticrm/ui'
export let value: string = ''
export let placeholder: IntlString
const dispatch = createEventDispatcher()
let input: HTMLInputElement
let phTraslate: string
translate(placeholder, {}).then(tr => phTraslate = tr)
onMount(() => { if (input) input.focus() })
</script>
<div class="selectPopup">
<div class="header no-border">
<div class="flex-between flex-grow pr-2">
<div class="flex-grow">
<input
bind:this={input}
type="text"
bind:value
placeholder={phTraslate}
style="width: 100%;"
on:keypress={(ev) => {
if (ev.key === 'Enter') dispatch('close', value)
}}
on:change
/>
</div>
<div class="buttons-group small-gap">
<div class="clear-btn" class:show={value !== ''} on:click={() => {
value = ''
input.focus()
}}>
{#if value !== ''}<div class="icon"><Icon icon={IconClose} size={'inline'} /></div>{/if}
</div>
<Button kind={'transparent'} size={'small'} icon={IconBlueCheck} on:click={() => dispatch('close', value)} />
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,184 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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">
import type { Channel, ChannelProvider } from '@anticrm/contact'
import contact from '@anticrm/contact'
import type { AttachedData, Doc, Ref, Timestamp } from '@anticrm/core'
import type { Asset, IntlString } from '@anticrm/platform'
import { AnyComponent, showPopup, Tooltip, Button, Menu } from '@anticrm/ui'
import type { Action, ButtonKind, ButtonSize } from '@anticrm/ui'
import presentation from '@anticrm/presentation'
import { getChannelProviders } from '../utils'
import ChannelsPopup from './ChannelsPopup.svelte'
import ChannelEditor from './ChannelEditor.svelte'
import { NotificationClientImpl } from '@anticrm/notification-resources'
export let value: AttachedData<Channel>[] | Channel | null
export let editable = true
export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small'
export let length: 'short' | 'full' = 'full'
export let reverse: boolean = false
export let integrations: Set<Ref<Doc>> = new Set<Ref<Doc>>()
const notificationClient = NotificationClientImpl.getClient()
const lastViews = notificationClient.getLastViews()
interface Item {
label: IntlString
icon: Asset
value: string
presenter?: AnyComponent
placeholder: IntlString
provider: Ref<ChannelProvider>
integration: boolean
notification: boolean
}
function getProvider (
item: AttachedData<Channel>,
map: Map<Ref<ChannelProvider>, ChannelProvider>,
lastViews: Map<Ref<Doc>, Timestamp>
): any | undefined {
const provider = map.get(item.provider)
if (provider) {
const notification = (item as Channel)._id !== undefined ? isNew((item as Channel), lastViews) : false
return {
label: provider.label,
icon: provider.icon as Asset,
value: item.value,
presenter: provider.presenter,
placeholder: provider.placeholder,
provider: provider._id,
notification,
integration: provider.integrationType !== undefined ? integrations.has(provider.integrationType) : false
}
} else {
console.log('provider not found: ', item.provider)
}
}
function isNew (item: Channel, lastViews: Map<Ref<Doc>, Timestamp>): boolean {
const lastView = (item as Channel)._id !== undefined ? lastViews.get((item as Channel)._id) : undefined
return lastView ? lastView < item.modifiedOn : (item.items ?? 0) > 0
}
async function update (value: AttachedData<Channel>[] | Channel | null, lastViews: Map<Ref<Doc>, Timestamp>) {
if (value === null) {
displayItems = []
return
}
const result = []
const map = await getChannelProviders()
if (Array.isArray(value)) {
for (const item of value) {
const provider = getProvider(item, map, lastViews)
if (provider !== undefined) {
result.push(provider)
}
}
} else {
const provider = getProvider(value, map, lastViews)
if (provider !== undefined) {
result.push(provider)
}
}
displayItems = result
updateMenu()
}
$: if (value) update(value, $lastViews)
let providers: Map<Ref<ChannelProvider>, ChannelProvider>
let displayItems: Item[] = []
let actions: Action[] = []
let addBtn: HTMLButtonElement
function filterUndefined (channels: AttachedData<Channel>[]): AttachedData<Channel>[] {
return channels.filter((channel) => channel.value !== undefined && channel.value.length > 0)
}
getChannelProviders().then(pr => providers = pr)
const updateMenu = (): void => {
actions = []
providers.forEach(pr => {
if (displayItems.filter(it => it.provider === pr._id).length == 0) {
actions.push({
icon: pr.icon ?? contact.icon.SocialEdit,
label: pr.label,
action: async () => {
const provider = getProvider({ provider: pr._id, value: '' }, providers, $lastViews)
if (provider !== undefined) {
if (displayItems.filter(it => it.provider === pr._id).length === 0) {
displayItems = [...displayItems, provider]
}
}
}
})
}
})
}
$: if (providers) updateMenu()
const editChannel = (channel: Item, n: number, ev: MouseEvent): void => {
showPopup(
ChannelEditor,
{ value: channel.value, placeholder: channel.placeholder },
ev.target as HTMLElement,
result => {
if (result !== undefined) {
if (result == null || result === '') {
displayItems = displayItems.filter((it, i) => i !== n)
} else {
displayItems[n].value = result
value = filterUndefined(displayItems)
}
updateMenu()
if (actions.length > 0 && addBtn) addBtn.click()
}
value = filterUndefined(displayItems)
}
)
}
const showMenu = (ev: MouseEvent): void => {
showPopup(Menu, { actions }, ev.target as HTMLElement)
}
</script>
{#each displayItems as item, i}
{#if item.value === ''}
<Button
icon={item.icon} {kind} {size} click={item.value === ''}
on:click={(ev) => { if (editable) editChannel(item, i, ev) }}
/>
{:else}
<Tooltip component={ChannelsPopup} props={{ value: item }} label={undefined}>
<Button
icon={item.icon} {kind} {size} click={item.value === ''}
on:click={(ev) => { if (editable) editChannel(item, i, ev) }}
/>
</Tooltip>
{/if}
{/each}
{#if actions.length > 0}
<Button
bind:input={addBtn}
icon={contact.icon.SocialEdit}
label={presentation.string.AddSocialLinks}
{kind} {size}
on:click={showMenu}
/>
{/if}

View File

@ -22,6 +22,7 @@
import { Button, EditBox, eventToHTMLElement, IconInfo, Label, showPopup } from '@anticrm/ui' import { Button, EditBox, eventToHTMLElement, IconInfo, Label, showPopup } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import contact from '../plugin' import contact from '../plugin'
import { ChannelsDropdown } from '..'
import ChannelsView from './ChannelsView.svelte' import ChannelsView from './ChannelsView.svelte'
import PersonPresenter from './PersonPresenter.svelte' import PersonPresenter from './PersonPresenter.svelte'
@ -102,29 +103,18 @@
{/if} {/if}
</svelte:fragment> </svelte:fragment>
<div class="flex-row-center"> <div class="flex-row-center">
<div class="mr-4"> <div class="flex-grow flex-col">
<EditableAvatar avatar={object.avatar} size={'large'} on:done={onAvatarDone} on:remove={removeAvatar} />
</div>
<div class="flex-col">
<EditBox placeholder={contact.string.PersonFirstNamePlaceholder} bind:value={firstName} kind={'large-style'} maxWidth={'32rem'} focus /> <EditBox placeholder={contact.string.PersonFirstNamePlaceholder} bind:value={firstName} kind={'large-style'} maxWidth={'32rem'} focus />
<EditBox placeholder={contact.string.PersonLastNamePlaceholder} bind:value={lastName} kind={'large-style'} maxWidth={'32rem'} /> <EditBox placeholder={contact.string.PersonLastNamePlaceholder} bind:value={lastName} kind={'large-style'} maxWidth={'32rem'} />
<div class="mt-1"> <div class="mt-1">
<EditBox placeholder={contact.string.PersonLocationPlaceholder} bind:value={object.city} kind={'small-style'} maxWidth={'32rem'} /> <EditBox placeholder={contact.string.PersonLocationPlaceholder} bind:value={object.city} kind={'small-style'} maxWidth={'32rem'} />
</div> </div>
</div> </div>
<div class="ml-4">
<EditableAvatar avatar={object.avatar} size={'large'} on:done={onAvatarDone} on:remove={removeAvatar} />
</div>
</div> </div>
{#if channels.length > 0} <svelte:fragment slot="pool">
<div class="ml-22"><ChannelsView value={channels} size={'small'} on:click /></div> <ChannelsDropdown bind:value={channels} />
{/if}
<svelte:fragment slot="footer">
<Button
icon={contact.icon.SocialEdit}
kind={'transparent'}
on:click={(ev) =>
showPopup(contact.component.SocialEditor, { values: channels }, eventToHTMLElement(ev), (result) => {
if (result !== undefined) channels = result
})
}
/>
</svelte:fragment> </svelte:fragment>
</Card> </Card>

View File

@ -22,6 +22,7 @@ import Channels from './components/Channels.svelte'
import ChannelsEditor from './components/ChannelsEditor.svelte' import ChannelsEditor from './components/ChannelsEditor.svelte'
import ChannelsPresenter from './components/ChannelsPresenter.svelte' import ChannelsPresenter from './components/ChannelsPresenter.svelte'
import ChannelsView from './components/ChannelsView.svelte' import ChannelsView from './components/ChannelsView.svelte'
import ChannelsDropdown from './components/ChannelsDropdown.svelte'
import ContactPresenter from './components/ContactPresenter.svelte' import ContactPresenter from './components/ContactPresenter.svelte'
import Contacts from './components/Contacts.svelte' import Contacts from './components/Contacts.svelte'
import CreateOrganization from './components/CreateOrganization.svelte' import CreateOrganization from './components/CreateOrganization.svelte'
@ -38,7 +39,7 @@ import EmployeeAccountPresenter from './components/EmployeeAccountPresenter.svel
import OrganizationEditor from './components/OrganizationEditor.svelte' import OrganizationEditor from './components/OrganizationEditor.svelte'
import OrganizationSelector from './components/OrganizationSelector.svelte' import OrganizationSelector from './components/OrganizationSelector.svelte'
export { Channels, ChannelsEditor, ContactPresenter, ChannelsView, OrganizationSelector } export { Channels, ChannelsEditor, ContactPresenter, ChannelsView, OrganizationSelector, ChannelsDropdown }
async function queryContact (_class: Ref<Class<Contact>>, client: Client, search: string): Promise<ObjectSearchResult[]> { async function queryContact (_class: Ref<Class<Contact>>, client: Client, search: string): Promise<ObjectSearchResult[]> {
return (await client.findAll(_class, { name: { $like: `%${search}%` } }, { limit: 200 })).map(e => ({ return (await client.findAll(_class, { name: { $like: `%${search}%` } }, { limit: 200 })).map(e => ({

View File

@ -151,29 +151,6 @@
</div> </div>
<style lang="scss"> <style lang="scss">
.clear-btn {
display: flex;
justify-content: center;
align-items: center;
width: .75rem;
height: .75rem;
border-radius: 50%;
.icon {
width: .625rem;
height: .625rem;
}
&.show {
color: var(--content-color);
background-color: var(--button-border-color);
cursor: pointer;
&:hover {
color: var(--accent-color);
background-color: var(--button-border-hover);
}
}
}
.counter { .counter {
padding-right: .125rem; padding-right: .125rem;
min-width: 1.5rem; min-width: 1.5rem;