Support Contacts (#2015)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-06-07 11:10:34 +07:00 committed by GitHub
parent 922c06d5f1
commit 3a5c431521
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 887 additions and 158 deletions

View File

@ -19,13 +19,14 @@ import type {
Contact,
Employee,
EmployeeAccount,
Member,
Organization,
Organizations,
Person,
Persons,
Status
} from '@anticrm/contact'
import type { Class, Domain, Ref, Timestamp } from '@anticrm/core'
import type { Class, Domain, FindOptions, Lookup, Ref, Timestamp } from '@anticrm/core'
import { DOMAIN_MODEL, IndexKind } from '@anticrm/core'
import { Builder, Collection, Index, Model, Prop, TypeRef, TypeString, TypeTimestamp, UX } from '@anticrm/model'
import attachment from '@anticrm/model-attachment'
@ -35,8 +36,8 @@ import presentation from '@anticrm/model-presentation'
import view, { actionTemplates, createAction } from '@anticrm/model-view'
import workbench from '@anticrm/model-workbench'
import type { Asset, IntlString } from '@anticrm/platform'
import contact from './plugin'
import setting from '@anticrm/setting'
import contact from './plugin'
export const DOMAIN_CONTACT = 'contact' as Domain
export const DOMAIN_CHANNEL = 'channel' as Domain
@ -94,9 +95,19 @@ export class TChannel extends TAttachedDoc implements Channel {
@UX(contact.string.Person, contact.icon.Person, undefined, 'name')
export class TPerson extends TContact implements Person {}
@Model(contact.class.Member, core.class.AttachedDoc, DOMAIN_CONTACT)
@UX(contact.string.Member, contact.icon.Person, undefined, 'name')
export class TMember extends TAttachedDoc implements Member {
@Prop(TypeRef(contact.class.Contact), contact.string.Contact)
contact!: Ref<Contact>
}
@Model(contact.class.Organization, contact.class.Contact)
@UX(contact.string.Organization, contact.icon.Company, undefined, 'name')
export class TOrganization extends TContact implements Organization {}
export class TOrganization extends TContact implements Organization {
@Prop(Collection(contact.class.Member), contact.string.Members)
members!: number
}
@Model(contact.class.Status, core.class.AttachedDoc, DOMAIN_CONTACT)
@UX(contact.string.Status)
@ -139,7 +150,8 @@ export function createModel (builder: Builder): void {
TEmployee,
TEmployeeAccount,
TChannel,
TStatus
TStatus,
TMember
)
builder.mixin(contact.class.Person, core.class.Class, view.mixin.ObjectFactory, {
@ -162,6 +174,33 @@ export function createModel (builder: Builder): void {
contact.app.Contacts
)
const contactLookup: Lookup<Contact> = {
_id: {
channels: contact.class.Channel
}
}
const memberOptions: FindOptions<Member> = {
lookup: {
contact: [contact.class.Contact, contactLookup]
}
}
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: contact.class.Member,
descriptor: view.viewlet.Table,
config: ['', '$lookup.contact.$lookup.channels', 'modifiedOn'],
options: memberOptions,
hiddenKeys: ['name']
},
contact.viewlet.TableMember
)
builder.mixin(contact.class.Member, core.class.Class, view.mixin.ObjectEditor, {
editor: contact.component.EditMember
})
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: contact.class.Contact,
descriptor: view.viewlet.Table,
@ -193,6 +232,13 @@ export function createModel (builder: Builder): void {
editor: contact.component.OrganizationEditor
})
builder.mixin(contact.class.Member, core.class.Class, view.mixin.CollectionEditor, {
editor: contact.component.Members
})
builder.mixin(contact.class.Member, core.class.Class, view.mixin.AttributePresenter, {
presenter: contact.component.MemberPresenter
})
builder.mixin(contact.class.Person, core.class.Class, view.mixin.AttributeEditor, {
editor: contact.component.PersonEditor
})
@ -305,6 +351,8 @@ export function createModel (builder: Builder): void {
builder.mixin(contact.class.Contact, core.class.Class, setting.mixin.Editable, {})
builder.mixin(contact.class.Member, core.class.Class, setting.mixin.Editable, {})
builder.createDoc(
presentation.class.ObjectSearchCategory,
core.space.Model,

View File

@ -13,20 +13,19 @@
// limitations under the License.
//
import { IntlString, mergeIds, Resource } from '@anticrm/platform'
import type { Ref } from '@anticrm/core'
import { contactId } from '@anticrm/contact'
import contact from '@anticrm/contact-resources/src/plugin'
import type { AnyComponent } from '@anticrm/ui'
import type { Ref } from '@anticrm/core'
import {} from '@anticrm/core'
import { ObjectSearchCategory, ObjectSearchFactory } from '@anticrm/model-presentation'
import { IntlString, mergeIds, Resource } from '@anticrm/platform'
import type { AnyComponent } from '@anticrm/ui'
export default mergeIds(contactId, contact, {
component: {
PersonPresenter: '' as AnyComponent,
ContactPresenter: '' as AnyComponent,
ChannelsPresenter: '' as AnyComponent,
CreatePerson: '' as AnyComponent,
EditPerson: '' as AnyComponent,
EditOrganization: '' as AnyComponent,
CreatePersons: '' as AnyComponent,
@ -36,7 +35,10 @@ export default mergeIds(contactId, contact, {
EmployeeAccountPresenter: '' as AnyComponent,
OrganizationEditor: '' as AnyComponent,
EmployeePresenter: '' as AnyComponent,
PersonEditor: '' as AnyComponent
PersonEditor: '' as AnyComponent,
Members: '' as AnyComponent,
MemberPresenter: '' as AnyComponent,
EditMember: '' as AnyComponent
},
string: {
Persons: '' as IntlString,

View File

@ -54,7 +54,7 @@ import preference, { TPreference } from '@anticrm/model-preference'
import view from './plugin'
export { viewOperation } from './migration'
export { ViewAction }
export { ViewAction, Viewlet }
export function createAction<T extends Doc = Doc, P = Record<string, any>> (
builder: Builder,

View File

@ -18,7 +18,7 @@
export let size: IconSize
const fill: string = 'var(--theme-caption-color)'
export let fill: string = 'var(--theme-caption-color)'
</script>
<svg class="svg-avatar {size}" {fill} viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">

View File

@ -16,7 +16,8 @@
import type { Asset, IntlString } from '@anticrm/platform'
import { onMount } from 'svelte'
import { registerFocus } from '../focus'
import type { AnySvelteComponent, ButtonKind, ButtonShape, ButtonSize } from '../types'
import { tooltip } from '../tooltips'
import type { AnySvelteComponent, ButtonKind, ButtonShape, ButtonSize, LabelAndProps } from '../types'
import Icon from './Icon.svelte'
import Label from './Label.svelte'
import Spinner from './Spinner.svelte'
@ -41,6 +42,8 @@
export let id: string | undefined = undefined
export let input: HTMLButtonElement | undefined = undefined
export let showTooltip: LabelAndProps | undefined = undefined
$: iconOnly = label === undefined && $$slots.content === undefined
onMount(() => {
@ -80,6 +83,7 @@
<!-- {focusIndex} -->
<button
use:tooltip={showTooltip}
bind:this={input}
class="button {kind} {size} jf-{justify}"
class:only-icon={iconOnly}

View File

@ -22,9 +22,11 @@
export let transparent: boolean = false
export let selected: boolean = false
export let primary: boolean = false
export let id: string | undefined = undefined
</script>
<div
{id}
class="flex-center icon-button icon-{size}"
class:selected
class:transparent

View File

@ -15,11 +15,10 @@
<script lang="ts">
import { getResource } from '@anticrm/platform'
import type { AnyComponent } from '../types'
// import Icon from './Icon.svelte'
import Loading from './Loading.svelte'
import ErrorBoundary from './internal/ErrorBoundary'
import ErrorPresenter from './ErrorPresenter.svelte'
import ErrorBoundary from './internal/ErrorBoundary'
import Loading from './Loading.svelte'
export let is: AnyComponent
export let props = {}

View File

@ -19,7 +19,6 @@
export let icon: Asset | AnySvelteComponent
export let size: IconSize
export let fill = 'currentColor'
export let filled: boolean = false
function isAsset (icon: Asset | AnySvelteComponent): boolean {
return typeof icon === 'string'
@ -38,5 +37,5 @@
<use href={url} />
</svg>
{:else}
<svelte:component this={icon} {size} {fill} {filled} />
<svelte:component this={icon} {size} {fill} />
{/if}

View File

@ -13,20 +13,22 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { translate } from '@anticrm/platform'
import { ticker } from '..'
import ui from '../plugin'
import Tooltip from './Tooltip.svelte'
export let value: number
<script lang="ts" context="module">
const SECOND = 1000
const MINUTE = SECOND * 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
const MONTH = DAY * 30
const YEAR = MONTH * 12
</script>
<script lang="ts">
import { translate } from '@anticrm/platform'
import { ticker } from '..'
import ui from '../plugin'
import { tooltip } from '../tooltips'
export let value: number
let time: string = ''
@ -48,7 +50,7 @@
$: formatTime($ticker)
$: tooltip = new Date(value).toLocaleString('default', {
$: tooltipValue = new Date(value).toLocaleString('default', {
minute: '2-digit',
hour: 'numeric',
day: '2-digit',
@ -57,6 +59,6 @@
})
</script>
<Tooltip label={ui.string.TimeTooltip} props={{ value: tooltip }}>
<span style="white-space: nowrap;">{time}</span>
</Tooltip>
<span use:tooltip={{ label: ui.string.TimeTooltip, props: { value: tooltipValue } }} style="white-space: nowrap;">
{time}
</span>

View File

@ -11,8 +11,41 @@ const emptyTooltip: LabelAndProps = {
anchor: undefined,
onUpdate: undefined
}
let storedValue: LabelAndProps = emptyTooltip
export const tooltipstore = writable<LabelAndProps>(emptyTooltip)
export function tooltip (node: HTMLElement, options?: LabelAndProps): any {
let toHandler: any
if (options === undefined) {
return {}
}
let opt = options
const show = (): void => {
const shown = !!(storedValue.label !== undefined || storedValue.component !== undefined)
if (!shown) {
clearTimeout(toHandler)
toHandler = setTimeout(() => {
showTooltip(opt.label, node, opt.direction, opt.component, opt.props, opt.anchor, opt.onUpdate)
}, 250)
}
}
const hide = (): void => {
clearTimeout(toHandler)
}
node.addEventListener('mouseleave', hide)
node.addEventListener('mousemove', show)
return {
update (options: LabelAndProps) {
opt = options
},
destroy () {
node.removeEventListener('mousemove', show)
node.removeEventListener('mouseleave', hide)
}
}
}
export function showTooltip (
label: IntlString | undefined,
element: HTMLElement,
@ -22,7 +55,7 @@ export function showTooltip (
anchor?: HTMLElement,
onUpdate?: (result: any) => void
): void {
tooltipstore.set({
storedValue = {
label: label,
element: element,
direction: direction,
@ -30,9 +63,11 @@ export function showTooltip (
props: props,
anchor: anchor,
onUpdate: onUpdate
})
}
tooltipstore.set(storedValue)
}
export function closeTooltip (): void {
storedValue = emptyTooltip
tooltipstore.set(emptyTooltip)
}

View File

@ -113,12 +113,12 @@ export interface DateOrShift {
}
export interface LabelAndProps {
label: IntlString | undefined
element: HTMLElement | undefined
label?: IntlString
element?: HTMLElement
direction?: TooltipAlignment
component?: AnySvelteComponent | AnyComponent
props?: any
anchor: HTMLElement | undefined
anchor?: HTMLElement
onUpdate?: (result: any) => void
}

View File

@ -15,9 +15,9 @@
-->
<script lang="ts">
import type { Doc } from '@anticrm/core'
import { Tooltip, IconAttachment } from '@anticrm/ui'
import AttachmentPopup from './AttachmentPopup.svelte'
import { IconAttachment, tooltip } from '@anticrm/ui'
import attachment from '../plugin'
import AttachmentPopup from './AttachmentPopup.svelte'
export let value: number | undefined
export let object: Doc
@ -26,16 +26,17 @@
</script>
{#if value && value > 0}
<Tooltip
label={attachment.string.Attachments}
component={AttachmentPopup}
props={{ objectId: object._id, attachments: value }}
<div
use:tooltip={{
label: attachment.string.Attachments,
component: AttachmentPopup,
props: { objectId: object._id, attachments: value }
}}
class="sm-tool-icon ml-1 mr-1"
>
<div class="sm-tool-icon ml-1 mr-1">
<span class="icon"><IconAttachment {size} /></span>
{#if showCounter}
&nbsp;{value}
{/if}
</div>
</Tooltip>
<span class="icon"><IconAttachment {size} /></span>
{#if showCounter}
&nbsp;{value}
{/if}
</div>
{/if}

View File

@ -14,10 +14,10 @@
// limitations under the License.
-->
<script lang="ts">
import type { Doc } from '@anticrm/core'
import { Tooltip, IconThread } from '@anticrm/ui'
import CommentPopup from './CommentPopup.svelte'
import chunter from '@anticrm/chunter'
import type { Doc } from '@anticrm/core'
import { IconThread, tooltip } from '@anticrm/ui'
import CommentPopup from './CommentPopup.svelte'
export let value: number | undefined
export let object: Doc
@ -26,12 +26,17 @@
</script>
{#if value && value > 0}
<Tooltip label={chunter.string.Comments} component={CommentPopup} props={{ objectId: object._id }}>
<div class="sm-tool-icon ml-1 mr-1">
<span class="icon"><IconThread {size} /></span>
{#if showCounter}
&nbsp;{value}
{/if}
</div>
</Tooltip>
<div
use:tooltip={{
label: chunter.string.Comments,
component: CommentPopup,
props: { objectId: object._id }
}}
class="sm-tool-icon ml-1 mr-1"
>
<span class="icon"><IconThread {size} /></span>
{#if showCounter}
&nbsp;{value}
{/if}
</div>
{/if}

View File

@ -59,6 +59,10 @@
"StatusDueDateTooltip": "Until {date}",
"CopyToClipboard": "Copy to clipboard",
"Copied": "Copied",
"ViewFullProfile": "View full profile"
"ViewFullProfile": "View full profile",
"Member": "Member",
"Members": "Members",
"NoMembers": "No members added",
"AddMember": "Add member"
}
}

View File

@ -59,6 +59,10 @@
"StatusDueDateTooltip": "До {date}",
"CopyToClipboard": "Скопировать в буфер обмена",
"Copied": "Скопировано",
"ViewFullProfile": "Посмотреть профиль"
"ViewFullProfile": "Посмотреть профиль",
"Member": "Сотрудник",
"Members": "Сотрудники",
"NoMembers": "Нет добавленных сотрудников",
"AddMember": "Добавить сотрудника"
}
}

View File

@ -223,43 +223,42 @@
class:short={displayItems.length > 4 && length === 'short'}
>
{#each displayItems as item, i}
<Tooltip
component={opened !== i ? ChannelEditor : undefined}
props={{
value: item.value,
placeholder: item.placeholder,
editable: editable !== undefined ? false : undefined,
openable: item.presenter ?? false
}}
onUpdate={(result) => {
if (result.detail === 'open') {
<Button
focusIndex={focusIndex === -1 ? focusIndex : focusIndex + 1 + i}
id={item.label}
bind:input={btns[i]}
icon={item.icon}
kind={highlighted.includes(item.provider) ? 'dangerous' : kind}
{size}
{shape}
highlight={item.integration || item.notification}
on:click={(ev) => {
if (editable) {
closeTooltip()
editChannel(eventToHTMLElement(ev), i, item)
} else {
dispatch('open', item)
} else if (result.detail === 'edit') {
closeTooltip()
editChannel(btns[i], i, item)
}
}}
>
<Button
focusIndex={focusIndex === -1 ? focusIndex : focusIndex + 1 + i}
id={item.label}
bind:input={btns[i]}
icon={item.icon}
kind={highlighted.includes(item.provider) ? 'dangerous' : kind}
{size}
{shape}
highlight={item.integration || item.notification}
on:click={(ev) => {
if (editable) {
showTooltip={{
component: opened !== i ? ChannelEditor : undefined,
props: {
value: item.value,
placeholder: item.placeholder,
editable: editable !== undefined ? false : undefined,
openable: item.presenter ?? false
},
onUpdate: (result) => {
if (result.detail === 'open') {
closeTooltip()
editChannel(eventToHTMLElement(ev), i, item)
} else {
dispatch('open', item)
} else if (result.detail === 'edit') {
closeTooltip()
editChannel(btns[i], i, item)
}
}}
/>
</Tooltip>
}
}}
/>
{/each}
{#if actions.length > 0 && editable}
{#if displayItems.length === 0}

View File

@ -15,9 +15,10 @@
-->
<script lang="ts">
import type { Channel } from '@anticrm/contact'
import { Doc } from '@anticrm/core'
import type { ButtonKind, ButtonSize } from '@anticrm/ui'
import ChannelsDropdown from './ChannelsDropdown.svelte'
import { showPopup } from '@anticrm/ui'
import ChannelsDropdown from './ChannelsDropdown.svelte'
export let value: Channel[] | Channel | null
@ -26,12 +27,13 @@
export let size: ButtonSize = 'small'
export let length: 'short' | 'full' = 'short'
export let shape: 'circle' | undefined = 'circle'
export let object: Doc
function _open (ev: any) {
if (ev.detail.presenter !== undefined && Array.isArray(value)) {
const channel = value[0]
if (channel !== undefined) {
showPopup(ev.detail.presenter, { _id: channel.attachedTo, _class: channel.attachedToClass }, 'float')
showPopup(ev.detail.presenter, { _id: object._id, _class: object._class }, 'float')
}
}
}

View File

@ -14,23 +14,25 @@
// limitations under the License.
-->
<script lang="ts">
import contact, { Contact } from '@anticrm/contact'
import contact, { Contact, Organization } from '@anticrm/contact'
import { getClient } from '@anticrm/presentation'
import OrganizationPresenter from './OrganizationPresenter.svelte'
import PersonPresenter from './PersonPresenter.svelte'
export let value: Contact
export let isInteractive = true
function isPerson (value: Contact): boolean {
const client = getClient()
const hierarchy = client.getHierarchy()
return hierarchy.isDerived(value._class, contact.class.Person)
}
const toOrg = (contact: Contact) => contact as Organization
</script>
{#if isPerson(value)}
<PersonPresenter {value} />
<PersonPresenter {isInteractive} {value} />
{:else}
<OrganizationPresenter {value} />
<OrganizationPresenter value={toOrg(value)} />
{/if}

View File

@ -0,0 +1,76 @@
<!--
// 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 { Organization, Person } from '@anticrm/contact'
import { Class, Ref, Space } from '@anticrm/core'
import { Card, getClient, UserBox } from '@anticrm/presentation'
import { createFocusManager, FocusHandler } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import contact from '../plugin'
export let organization: Ref<Organization>
export let _class: Ref<Class<Organization>>
export let space: Ref<Space>
const dispatch = createEventDispatcher()
let person: Ref<Person> | undefined
export function canClose (): boolean {
return person === undefined
}
const client = getClient()
async function createMember () {
if (person === undefined) {
return
}
const id = await client.addCollection(contact.class.Member, space, organization, _class, 'members', {
contact: person
})
dispatch('close', id)
}
const manager = createFocusManager()
</script>
<FocusHandler {manager} />
<Card
label={contact.string.AddMember}
okAction={createMember}
canSave={!!person}
on:close={() => {
dispatch('close')
}}
>
<div class="flex-row-center clear-mins">
<!-- <div class="mr-3">
<Button focusIndex={1} icon={Vacancy} size={'medium'} kind={'link-bordered'} disabled />
</div> -->
</div>
<svelte:fragment slot="pool">
<UserBox
focusIndex={3}
_class={contact.class.Person}
label={contact.string.Member}
placeholder={contact.string.Member}
bind:value={person}
kind={'no-border'}
size={'small'}
icon={contact.icon.Person}
create={{ component: contact.component.CreatePerson, label: contact.string.CreatePerson }}
/>
</svelte:fragment>
</Card>

View File

@ -0,0 +1,66 @@
<!--
// 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 contact, { Contact, Member, Organization } from '@anticrm/contact'
import { Ref } from '@anticrm/core'
import { createQuery } from '@anticrm/presentation'
import { createEventDispatcher, onMount } from 'svelte'
import ExpandRightDouble from './icons/ExpandRightDouble.svelte'
import OrganizationCard from './OrganizationCard.svelte'
import PersonCard from './PersonCard.svelte'
export let object: Member
let refContact: Contact
const personQuery = createQuery()
$: if (object !== undefined) {
personQuery.query(contact.class.Contact, { _id: object.contact }, (result) => {
refContact = result[0]
})
}
let organization: Organization
const orgQuery = createQuery()
$: if (object !== undefined) {
orgQuery.query(contact.class.Organization, { _id: object.attachedTo as Ref<Organization> }, (result) => {
organization = result[0]
})
}
const dispatch = createEventDispatcher()
onMount(() => {
dispatch('open', { ignoreKeys: ['comments', 'number'] })
})
</script>
{#if object !== undefined && refContact !== undefined && organization !== undefined}
<div class="flex-between">
<div class="card"><PersonCard object={refContact} on:click /></div>
<div class="arrows"><ExpandRightDouble /></div>
<div class="card"><OrganizationCard {organization} /></div>
</div>
{/if}
<style lang="scss">
.card {
align-self: stretch;
width: calc(50% - 3rem);
min-height: 16rem;
}
.arrows {
width: 4rem;
}
</style>

View File

@ -0,0 +1,35 @@
<!--
// 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 contact, { Member } from '@anticrm/contact'
import { Hierarchy } from '@anticrm/core'
import { getClient } from '@anticrm/presentation'
import { getPanelURI } from '@anticrm/ui'
import view from '@anticrm/view'
import { ContactPresenter } from '..'
export let value: Member
const contactRef = getClient().findOne(contact.class.Contact, { _id: value.contact })
</script>
<a href={`#${getPanelURI(view.component.EditDoc, value._id, Hierarchy.mixinOrClass(value), 'content')}`}>
{#await contactRef then ct}
{#if ct}
<ContactPresenter isInteractive={false} value={ct} />
{/if}
{/await}
</a>

View File

@ -0,0 +1,143 @@
<!--
// Copyright © 2022 Anticrm Platform Contributors.
//
// 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 { Member } from '@anticrm/contact'
import type { Class, Doc, Ref, Space } from '@anticrm/core'
import { createQuery, getClient, UsersPopup } from '@anticrm/presentation'
import { CircleButton, IconAdd, Label, showPopup } from '@anticrm/ui'
import view, { Viewlet, ViewletPreference } from '@anticrm/view'
import { Table, ViewletSettingButton } from '@anticrm/view-resources'
import contact from '../plugin'
export let objectId: Ref<Doc>
export let space: Ref<Space>
export let _class: Ref<Class<Doc>>
export let members: number
let memberItems: Member[] = []
const membersQuery = createQuery()
$: membersQuery.query(contact.class.Member, { attachedTo: objectId }, (result) => {
memberItems = result
})
const client = getClient()
let loading = true
const createApp = async (ev: MouseEvent): Promise<void> => {
showPopup(
UsersPopup,
{
_class: contact.class.Person,
options: undefined,
ignoreUsers: memberItems.map((it) => it.contact),
icon: contact.icon.Person,
allowDeselect: false,
placeholder: contact.string.Member,
create: { component: contact.component.CreatePerson, label: contact.string.CreatePerson }
},
ev.target as HTMLElement,
(result) => {
if (result != null) {
client.addCollection(contact.class.Member, space, objectId, _class, 'members', {
contact: result._id
})
}
}
)
}
let descr: Viewlet | undefined
const preferenceQuery = createQuery()
let preference: ViewletPreference | undefined
$: updateDescriptor(contact.viewlet.TableMember)
function updateDescriptor (id: Ref<Viewlet>) {
loading = true
client
.findOne<Viewlet>(view.class.Viewlet, {
_id: id
})
.then((res) => {
descr = res
if (res !== undefined) {
preferenceQuery.query(
view.class.ViewletPreference,
{
attachedTo: res._id
},
(res) => {
preference = res[0]
loading = false
},
{ limit: 1 }
)
}
})
}
</script>
<div class="applications-container">
<div class="flex flex-between">
<div class="title"><Label label={contact.string.Members} /></div>
<CircleButton id={contact.string.AddMember} icon={IconAdd} size={'small'} selected on:click={createApp} />
<div class="flex flex-grow flex-reverse">
<ViewletSettingButton viewlet={descr} />
</div>
</div>
{#if members > 0 && descr}
<Table
_class={contact.class.Member}
config={preference?.config ?? descr.config}
options={descr.options}
query={{ attachedTo: objectId }}
loadingProps={{ length: members }}
/>
{:else}
<div class="flex-col-center mt-5 createapp-container">
<div class="text-sm content-dark-color mt-2">
<Label label={contact.string.NoMembers} />
</div>
<div class="text-sm">
<div class="over-underline" on:click={createApp}><Label label={contact.string.AddMember} /></div>
</div>
</div>
{/if}
</div>
<style lang="scss">
.applications-container {
display: flex;
flex-direction: column;
.title {
margin-right: 0.75rem;
font-weight: 500;
font-size: 1.25rem;
color: var(--theme-caption-color);
}
}
.createapp-container {
padding: 1rem;
color: var(--theme-caption-color);
background: var(--theme-bg-accent-color);
border: 1px solid var(--theme-bg-accent-color);
border-radius: 0.75rem;
}
</style>

View File

@ -0,0 +1,95 @@
<!--
// 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 { Organization } from '@anticrm/contact'
import { Avatar } from '@anticrm/presentation'
import { closePanel, closePopup, closeTooltip, getCurrentLocation, Label, navigate } from '@anticrm/ui'
import contact from '../plugin'
export let organization: Organization
export let disabled: boolean = false
</script>
<div class="flex-col h-full card-container">
<div class="label uppercase"><Label label={contact.string.Organization} /></div>
<div class="flex-center logo">
<Avatar avatar={organization.avatar} size={'large'} icon={contact.icon.Company} />
</div>
{#if organization}
<div
class="name lines-limit-2"
class:over-underline={!disabled}
on:click={() => {
if (!disabled) {
closeTooltip()
closePopup()
closePanel()
const loc = getCurrentLocation()
loc.path[2] = organization._id
loc.path.length = 3
navigate(loc)
}
}}
>
{organization.name}
</div>
{#if organization}
<span class="label">{organization.name}</span>
{/if}
{/if}
</div>
<style lang="scss">
.card-container {
padding: 1rem 1.5rem 1.25rem;
background-color: var(--board-card-bg-color);
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
transition-property: box-shadow, background-color, border-color;
transition-timing-function: var(--timing-shadow);
transition-duration: 0.15s;
user-select: text;
&:hover {
background-color: var(--board-card-bg-hover);
border-color: var(--button-border-color);
box-shadow: var(--accent-shadow);
}
.logo {
width: 5rem;
height: 5rem;
color: var(--primary-button-color);
background-color: var(--primary-button-enabled);
border-radius: 50%;
}
.label {
margin-bottom: 1.75rem;
font-weight: 500;
font-size: 0.625rem;
color: var(--theme-content-dark-color);
}
.name {
margin: 1rem 0 0.25rem;
font-weight: 500;
font-size: 1rem;
color: var(--theme-caption-color);
}
.description {
font-size: 0.75rem;
color: var(--theme-content-dark-color);
}
}
</style>

View File

@ -0,0 +1,114 @@
<!--
// 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 attachment from '@anticrm/attachment'
import contact, { Channel, Contact, formatName } from '@anticrm/contact'
import { Avatar, createQuery } from '@anticrm/presentation'
import { Component, Label, showPanel } from '@anticrm/ui'
import view from '@anticrm/view'
import ChannelsEditor from './ChannelsEditor.svelte'
export let object: Contact
export let disabled: boolean = false
let channels: Channel[] = []
const channelsQuery = createQuery()
channelsQuery.query(
contact.class.Channel,
{
attachedTo: object._id
},
(res) => {
channels = res
}
)
</script>
<div class="flex-col h-full card-container">
<div class="label uppercase"><Label label={contact.string.Person} /></div>
<div class="flex-center logo">
<Avatar avatar={object.avatar} size={'large'} icon={contact.icon.Company} />
</div>
{#if object}
<div
class="name lines-limit-2"
class:over-underline={!disabled}
on:click={() => {
if (!disabled) showPanel(view.component.EditDoc, object._id, object._class, 'content')
}}
>
{formatName(object.name)}
</div>
<div class="description overflow-label">{object.city ?? ''}</div>
<div class="footer flex flex-reverse flex-grow">
<div class="flex-center flex-wrap">
<Component
is={attachment.component.AttachmentsPresenter}
props={{ value: object.attachments, object: object, size: 'medium', showCounter: true }}
/>
</div>
{#if channels[0]}
<div class="flex flex-grow">
<ChannelsEditor
attachedTo={channels[0].attachedTo}
attachedClass={channels[0].attachedToClass}
length={'short'}
editable={false}
/>
</div>
{/if}
</div>
{/if}
</div>
<style lang="scss">
.card-container {
padding: 1rem 1.5rem 1.25rem;
background-color: var(--board-card-bg-color);
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
transition-property: box-shadow, background-color, border-color;
transition-timing-function: var(--timing-shadow);
transition-duration: 0.15s;
user-select: text;
&:hover {
background-color: var(--board-card-bg-hover);
border-color: var(--button-border-color);
box-shadow: var(--accent-shadow);
}
.label {
margin-bottom: 1.75rem;
font-weight: 500;
font-size: 0.625rem;
color: var(--theme-content-dark-color);
}
.name {
margin: 1rem 0 0.25rem;
font-weight: 500;
font-size: 1rem;
color: var(--theme-caption-color);
}
.description {
font-size: 0.75rem;
color: var(--theme-content-dark-color);
}
.footer {
margin-top: 1.5rem;
// overflow: hidden;
}
}
</style>

View File

@ -17,7 +17,7 @@
import { Hierarchy } from '@anticrm/core'
import { IntlString } from '@anticrm/platform'
import { Avatar } from '@anticrm/presentation'
import { getPanelURI, Label } from '@anticrm/ui'
import { getPanelURI, Label, LabelAndProps, tooltip } from '@anticrm/ui'
import view from '@anticrm/view'
export let value: Person | undefined
@ -29,6 +29,7 @@
export let defaultName: IntlString | undefined = undefined
export let avatarSize: 'inline' | 'tiny' | 'x-small' | 'small' | 'medium' | 'large' | 'x-large' = 'x-small'
export let onEdit: ((event: MouseEvent) => void) | undefined = undefined
export let showTooltip: LabelAndProps | undefined = undefined
$: element = getElement(value, onEdit, shouldShowPlaceholder, isInteractive)
@ -56,6 +57,7 @@
<svelte:element
this={element}
use:tooltip={showTooltip}
class="contentPresenter"
class:inline-presenter={inline}
class:mContentPresenterNotInteractive={!isInteractive}

View File

@ -15,7 +15,6 @@
<script lang="ts">
import { formatName, Person } from '@anticrm/contact'
import { IntlString } from '@anticrm/platform'
import { Tooltip } from '@anticrm/ui'
import PersonContent from './PersonContent.svelte'
export let value: Person
@ -31,34 +30,21 @@
</script>
{#if value || shouldShowPlaceholder}
{#if tooltipLabels}
<Tooltip
label={value ? tooltipLabels.personLabel : tooltipLabels.placeholderLabel}
props={{ value: formatName(value?.name) }}
>
<PersonContent
{value}
{inline}
{onEdit}
{avatarSize}
{defaultName}
{isInteractive}
{shouldShowAvatar}
{shouldShowName}
{shouldShowPlaceholder}
/>
</Tooltip>
{:else}
<PersonContent
{value}
{inline}
{onEdit}
{avatarSize}
{defaultName}
{isInteractive}
{shouldShowAvatar}
{shouldShowName}
{shouldShowPlaceholder}
/>
{/if}
<PersonContent
showTooltip={tooltipLabels
? {
label: value ? tooltipLabels.personLabel : tooltipLabels.placeholderLabel,
props: { value: formatName(value?.name) }
}
: undefined}
{value}
{inline}
{onEdit}
{avatarSize}
{defaultName}
{isInteractive}
{shouldShowAvatar}
{shouldShowName}
{shouldShowPlaceholder}
/>
{/if}

View File

@ -0,0 +1,30 @@
<!--
// 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">
const fill: string = 'var(--theme-content-dark-color)'
</script>
<svg class="svg-expand" {fill} viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<polygon points="32.2,15.6 31.4,16.4 46.9,32 31.4,47.6 32.2,48.4 48.6,32 " />
<polygon points="32.6,32 16.2,15.6 15.4,16.4 30.9,32 15.4,47.6 16.2,48.4 " />
</svg>
<style lang="scss">
.svg-expand {
width: 4rem;
height: 4rem;
}
</style>

View File

@ -40,6 +40,9 @@ import EmployeeAccountPresenter from './components/EmployeeAccountPresenter.svel
import OrganizationEditor from './components/OrganizationEditor.svelte'
import PersonEditor from './components/PersonEditor.svelte'
import OrganizationSelector from './components/OrganizationSelector.svelte'
import Members from './components/Members.svelte'
import MemberPresenter from './components/MemberPresenter.svelte'
import EditMember from './components/EditMember.svelte'
export {
Channels,
@ -48,7 +51,8 @@ export {
ChannelsView,
OrganizationSelector,
ChannelsDropdown,
EmployeePresenter
EmployeePresenter,
MemberPresenter
}
async function queryContact (
@ -83,7 +87,10 @@ export default async (): Promise<Resources> => ({
SocialEditor,
Contacts,
EmployeeAccountPresenter,
EmployeePresenter
EmployeePresenter,
Members,
MemberPresenter,
EditMember
},
completion: {
EmployeeQuery: async (client: Client, query: string) => await queryContact(contact.class.Employee, client, query),

View File

@ -52,6 +52,10 @@ export default mergeIds(contactId, contact, {
StatusDueDateTooltip: '' as IntlString,
CopyToClipboard: '' as IntlString,
Copied: '' as IntlString,
ViewFullProfile: '' as IntlString
ViewFullProfile: '' as IntlString,
Member: '' as IntlString,
Members: '' as IntlString,
NoMembers: '' as IntlString,
AddMember: '' as IntlString
}
})

View File

@ -28,6 +28,7 @@
"dependencies": {
"@anticrm/platform": "~0.6.6",
"@anticrm/ui": "~0.6.0",
"@anticrm/core": "~0.6.16"
"@anticrm/core": "~0.6.16",
"@anticrm/view": "~0.6.0"
}
}

View File

@ -30,6 +30,7 @@ import {
import type { Asset, Plugin } from '@anticrm/platform'
import { IntlString, plugin } from '@anticrm/platform'
import type { AnyComponent } from '@anticrm/ui'
import { Viewlet } from '@anticrm/view'
/**
* @public
@ -80,7 +81,15 @@ export interface Person extends Contact {}
/**
* @public
*/
export interface Organization extends Contact {}
export interface Member extends AttachedDoc {
contact: Ref<Contact>
}
/**
* @public
*/
export interface Organization extends Contact {
members: number
}
/**
* @public
@ -152,6 +161,7 @@ const contactPlugin = plugin(contactId, {
Contact: '' as Ref<Class<Contact>>,
Person: '' as Ref<Class<Person>>,
Persons: '' as Ref<Class<Persons>>,
Member: '' as Ref<Class<Member>>,
Organization: '' as Ref<Class<Organization>>,
Organizations: '' as Ref<Class<Organizations>>,
Employee: '' as Ref<Class<Employee>>,
@ -160,7 +170,8 @@ const contactPlugin = plugin(contactId, {
},
component: {
SocialEditor: '' as AnyComponent,
CreateOrganization: '' as AnyComponent
CreateOrganization: '' as AnyComponent,
CreatePerson: '' as AnyComponent
},
channelProvider: {
Email: '' as Ref<ChannelProvider>,
@ -202,6 +213,9 @@ const contactPlugin = plugin(contactId, {
PersonAlreadyExists: '' as IntlString,
Person: '' as IntlString,
CreateOrganization: '' as IntlString
},
viewlet: {
TableMember: '' as Ref<Viewlet>
}
})

View File

@ -15,18 +15,23 @@
-->
<script lang="ts">
import type { Candidate } from '@anticrm/recruit'
import { Icon, Tooltip } from '@anticrm/ui'
import ApplicationsPopup from './ApplicationsPopup.svelte'
import { Icon, tooltip } from '@anticrm/ui'
import recruit from '../plugin'
import ApplicationsPopup from './ApplicationsPopup.svelte'
export let value: number
export let object: Candidate
</script>
{#if value && value > 0}
<Tooltip label={recruit.string.Applications} component={ApplicationsPopup} props={{ value: object }}>
<div class="sm-tool-icon">
<span class="icon"><Icon icon={recruit.icon.Application} size={'small'} /></span>&nbsp;{value}
</div>
</Tooltip>
<div
use:tooltip={{
label: recruit.string.Applications,
component: ApplicationsPopup,
props: { value: object }
}}
class="sm-tool-icon"
>
<span class="icon"><Icon icon={recruit.icon.Application} size={'small'} /></span>&nbsp;{value}
</div>
{/if}

View File

@ -15,7 +15,7 @@
<script lang="ts">
import { Class, Doc, Ref } from '@anticrm/core'
import { getClient } from '@anticrm/presentation'
import { Icon, Tooltip } from '@anticrm/ui'
import { Icon, tooltip } from '@anticrm/ui'
import { getCollectionCounter } from '@anticrm/view-resources'
import tagsId from '../plugin'
import TagsPresentationPopup from './TagsPresentationPopup.svelte'
@ -31,9 +31,14 @@
</script>
{#if tags > 0}
<Tooltip label={attr.label} component={TagsPresentationPopup} props={{ object: value, _class, key: { key, attr } }}>
<div class="sm-tool-icon">
<span class="icon"><Icon icon={tagsId.icon.Tags} size={'small'} /></span>&nbsp;{tags}
</div>
</Tooltip>
<div
use:tooltip={{
label: attr.label,
component: TagsPresentationPopup,
props: { object: value, _class, key: { key, attr } }
}}
class="sm-tool-icon"
>
<span class="icon"><Icon icon={tagsId.icon.Tags} size={'small'} /></span>&nbsp;{tags}
</div>
{/if}

View File

@ -153,6 +153,13 @@
r?.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}
const joinProps = (collectionAttr: boolean, object: Doc, props: any) => {
if (collectionAttr) {
return { object, ...props }
}
return props
}
</script>
{#await buildModel({ client, _class, keys: config, lookup })}
@ -251,8 +258,7 @@
<svelte:component
this={attribute.presenter}
value={getObjectValue(attribute.key, object) ?? ''}
{object}
{...attribute.props}
{...joinProps(attribute.collectionAttr, object, attribute.props)}
/>
<!-- <div
id="context-menu"
@ -268,8 +274,7 @@
<svelte:component
this={attribute.presenter}
value={getObjectValue(attribute.key, object) ?? ''}
{object}
{...attribute.props}
{...joinProps(attribute.collectionAttr, object, attribute.props)}
/>
</td>
{/if}

View File

@ -75,7 +75,8 @@ export async function getObjectPresenter (
label: preserveKey.label ?? clazz.label,
presenter,
props: preserveKey.props,
sortingKey
sortingKey,
collectionAttr: isCollectionAttr
}
}
@ -102,9 +103,8 @@ async function getAttributePresenter (
const hierarchy = client.getHierarchy()
const attribute = hierarchy.getAttribute(_class, key)
let attrClass = getAttributePresenterClass(attribute)
const mixin = hierarchy.isDerived(attribute.type._class, core.class.Collection)
? view.mixin.CollectionPresenter
: view.mixin.AttributePresenter
const isCollectionAttr = hierarchy.isDerived(attribute.type._class, core.class.Collection)
const mixin = isCollectionAttr ? view.mixin.CollectionPresenter : view.mixin.AttributePresenter
const clazz = hierarchy.getClass(attrClass)
let presenterMixin = hierarchy.as(clazz, mixin)
let parent = clazz.extends
@ -129,7 +129,8 @@ async function getAttributePresenter (
presenter,
props: {},
icon: presenterMixin.icon,
attribute
attribute,
collectionAttr: isCollectionAttr
}
}
@ -149,7 +150,8 @@ export async function getPresenter<T extends Doc> (
_class,
label: label as IntlString,
presenter: await getResource(presenter),
props: key.props
props: key.props,
collectionAttr: isCollectionAttr
}
}
if (key.key.length === 0) {
@ -233,12 +235,13 @@ export async function buildModel (options: BuildModelOptions): Promise<Attribute
presenter: ErrorPresenter,
label: stringKey as IntlString,
_class: core.class.TypeString,
props: { error: err }
props: { error: err },
collectionAttr: false
}
return errorPresenter
}
})
return (await Promise.all(model)).filter((a) => a !== undefined) as AttributeModel[]
return (await (await Promise.all(model)).filter((a) => a !== undefined)) as AttributeModel[]
}
export async function deleteObject (client: TxOperations, object: Doc): Promise<void> {

View File

@ -325,6 +325,7 @@ export interface AttributeModel {
icon?: Asset
attribute?: AnyAttribute
collectionAttr: boolean
}
/**

View File

@ -0,0 +1,29 @@
import { test } from '@playwright/test'
import { generateId, PlatformSetting, PlatformURI } from './utils'
test.use({
storageState: PlatformSetting
})
test.describe('recruit tests', () => {
test.beforeEach(async ({ page }) => {
// Create user and workspace
await page.goto(`${PlatformURI}/workbench%3Acomponent%3AWorkbenchApp`)
})
test('org-add-member', async ({ page }) => {
await page.click('[id="app-contact\\:string\\:Contacts"]')
await page.click('button:has-text("Contact")')
await page.click('button:has-text("Organization")')
await page.click('[placeholder="Apple"]')
const orgId = 'Organiation-' + generateId()
await page.fill('[placeholder="Apple"]', orgId)
await page.click('button:has-text("Create")')
await page.click(`text=${orgId}`)
await page.click('[id="contact:string:AddMember"]')
await page.click('button:has-text("Rosamund Chen")')
await page.click('text=Rosamund Chen less than a minute ago >> span')
await page.click(`:nth-match(:text("${orgId}"), 2)`)
})
})