diff --git a/models/view/src/index.ts b/models/view/src/index.ts index 70d8d8784e..fa6067528d 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -161,7 +161,8 @@ export class TObjectValidator extends TClass implements ObjectValidator { @Mixin(view.mixin.ObjectFactory, core.class.Class) export class TObjectFactory extends TClass implements ObjectFactory { - component!: AnyComponent + component?: AnyComponent + create?: Resource<() => Promise<void>> } @Mixin(view.mixin.ObjectTitle, core.class.Class) diff --git a/plugins/contact-resources/src/components/CreateContact.svelte b/plugins/contact-resources/src/components/CreateContact.svelte index 64779868e4..cf70e61abf 100644 --- a/plugins/contact-resources/src/components/CreateContact.svelte +++ b/plugins/contact-resources/src/components/CreateContact.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { Asset } from '@hcengineering/platform' + import { Asset, getResource } from '@hcengineering/platform' import { getClient } from '@hcengineering/presentation' import { Menu, Action, showPopup, closePopup } from '@hcengineering/ui' import view from '@hcengineering/view' @@ -13,18 +13,28 @@ client .getHierarchy() .getDescendants(contact.class.Contact) - .forEach((v) => { + .forEach(async (v) => { const cl = hierarchy.getClass(v) if (hierarchy.hasMixin(cl, view.mixin.ObjectFactory)) { - const f = hierarchy.as(cl, view.mixin.ObjectFactory) - actions.push({ - icon: cl.icon as Asset, - label: cl.label, - action: async () => { + const { component, create } = hierarchy.as(cl, view.mixin.ObjectFactory) + let action: (() => Promise<void>) | undefined + + if (component) { + action = async () => { closePopup() - showPopup(f.component, {}, 'top') + showPopup(component, {}, 'top') } - }) + } else if (create) { + action = await getResource(create) + } + + if (action) { + actions.push({ + icon: cl.icon as Asset, + label: cl.label, + action + }) + } } }) </script> diff --git a/plugins/view-resources/src/components/list/SortableList.svelte b/plugins/view-resources/src/components/list/SortableList.svelte new file mode 100644 index 0000000000..913309e1ce --- /dev/null +++ b/plugins/view-resources/src/components/list/SortableList.svelte @@ -0,0 +1,219 @@ +<!-- +// 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 { Class, Doc, DocumentQuery, FindOptions, FindResult, Ref } from '@hcengineering/core' + import { getResource, IntlString } from '@hcengineering/platform' + import presentation, { createQuery, getClient } from '@hcengineering/presentation' + import { calcRank, DocWithRank } from '@hcengineering/task' + import { Button, Component, IconAdd, Label, Loading } from '@hcengineering/ui' + import view, { AttributeModel, ObjectFactory } from '@hcengineering/view' + import { flip } from 'svelte/animate' + import { getObjectPresenter } from '../../utils' + import SortableListItem from './SortableListItem.svelte' + + /* + How to use: + + We must add presenter for the "_class" via "AttributePresenter" mixin + to be able display the rows list. + + To create a new items, we should add "ObjectFactory" mixin also. + + We can create a custom list items or editor based on "SortableListItem" + and "SortableListItemPresenter" + + Important: the "ObjectFactory" component must emit the "close" event +*/ + + export let _class: Ref<Class<Doc>> + export let label: IntlString | undefined = undefined + export let query: DocumentQuery<Doc> = {} + export let queryOptions: FindOptions<Doc> | undefined = undefined + export let presenterProps: Record<string, any> = {} + export let flipDuration = 200 + + const client = getClient() + const hierarchy = client.getHierarchy() + const itemsQuery = createQuery() + + let isModelLoading = false + let areItemsloading = true + let areItemsSorting = false + + let model: AttributeModel | undefined + let objectFactory: ObjectFactory | undefined + let items: FindResult<Doc> | undefined + + let draggingIndex: number | null = null + let hoveringIndex: number | null = null + + let isCreating = false + + async function updateModel (modelClassRef: Ref<Class<Doc>>) { + try { + isModelLoading = true + model = await getObjectPresenter(client, modelClassRef, { key: '', props: presenterProps }) + } finally { + isModelLoading = false + } + } + + function updateObjectFactory (objectFactoryClassRef: Ref<Class<Doc>>) { + const objectFactoryClass = hierarchy.getClass(objectFactoryClassRef) + + if (hierarchy.hasMixin(objectFactoryClass, view.mixin.ObjectFactory)) { + objectFactory = hierarchy.as(objectFactoryClass, view.mixin.ObjectFactory) + } + } + + function updateItems (newItems: FindResult<Doc>): void { + items = newItems + areItemsloading = false + } + + function handleDragStart (ev: DragEvent, itemIndex: number) { + if (ev.dataTransfer) { + ev.dataTransfer.effectAllowed = 'move' + ev.dataTransfer.dropEffect = 'move' + } + + draggingIndex = itemIndex + } + + function handleDragOver (ev: DragEvent, itemIndex: number) { + ev.preventDefault() + + hoveringIndex = itemIndex + } + + async function handleDrop (itemIndex: number) { + if (isSortable && items && draggingIndex !== null && draggingIndex !== itemIndex) { + const item = items[draggingIndex] as DocWithRank + const [prev, next] = [ + items[draggingIndex < itemIndex ? itemIndex : itemIndex - 1] as DocWithRank, + items[draggingIndex < itemIndex ? itemIndex + 1 : itemIndex] as DocWithRank + ] + + try { + areItemsSorting = true + await client.update(item, { rank: calcRank(prev, next) }) + } finally { + areItemsSorting = false + } + } + + resetDrag() + } + + async function create () { + if (objectFactory?.create) { + const createFn = await getResource(objectFactory.create) + await createFn() + return + } + + isCreating = true + } + + function resetDrag () { + draggingIndex = null + hoveringIndex = null + } + + $: updateModel(_class) + $: updateObjectFactory(_class) + $: itemsQuery.query(_class, query, updateItems, { ...queryOptions, limit: Math.max(queryOptions?.limit ?? 0, 200) }) + + $: isLoading = isModelLoading || areItemsloading + $: isSortable = hierarchy.getAllAttributes(_class).has('rank') +</script> + +<div class="flex-col"> + {#if label} + <div class="flex mb-4"> + {#if label} + <div class="title-wrapper"> + <span class="wrapped-title text-base content-accent-color"> + <Label {label} /> + </span> + </div> + {/if} + {#if objectFactory} + <div class="ml-auto"> + <Button + showTooltip={{ label: presentation.string.Add }} + disabled={isLoading} + width="min-content" + icon={IconAdd} + size="small" + kind="transparent" + on:click={create} + /> + </div> + {/if} + </div> + {/if} + + {#if isLoading} + <Loading /> + {:else if model && items} + <div class="flex-col flex-gap-1"> + {#each items as item, index (item._id)} + {@const isDraggable = isSortable && items.length > 1 && !areItemsSorting} + <div + class="row" + class:is-dragged-over-up={draggingIndex !== null && index === hoveringIndex && index < draggingIndex} + class:is-dragged-over-down={draggingIndex !== null && index === hoveringIndex && index > draggingIndex} + draggable={isDraggable} + animate:flip={{ duration: flipDuration }} + on:dragstart={(ev) => handleDragStart(ev, index)} + on:dragover={(ev) => handleDragOver(ev, index)} + on:drop={() => handleDrop(index)} + on:dragend={resetDrag} + > + <SortableListItem {isDraggable}> + <svelte:component this={model.presenter} {...model.props ?? {}} value={item} /> + </SortableListItem> + </div> + {/each} + + {#if objectFactory?.component && isCreating} + <!-- Important: the "close" event must be specified --> + <Component is={objectFactory.component} showLoading on:close={() => (isCreating = false)} /> + {/if} + </div> + {/if} +</div> + +<style lang="scss"> + .row { + position: relative; + overflow: hidden; + + &.is-dragged-over-up::before { + position: absolute; + content: ''; + inset: 0; + border-top: 1px solid var(--theme-bg-check); + } + + &.is-dragged-over-down::before { + position: absolute; + content: ''; + inset: 0; + border-bottom: 1px solid var(--theme-bg-check); + } + } +</style> diff --git a/plugins/view-resources/src/components/list/SortableListItem.svelte b/plugins/view-resources/src/components/list/SortableListItem.svelte new file mode 100644 index 0000000000..5cc4c4d1c1 --- /dev/null +++ b/plugins/view-resources/src/components/list/SortableListItem.svelte @@ -0,0 +1,52 @@ +<!-- +// 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 Circles from '../icons/Circles.svelte' + + export let isDraggable = false +</script> + +<div class="root flex background-button-bg-color border-radius-1"> + <div class="flex-center ml-2"> + <div class="flex-no-shrink circles-mark" class:isDraggable><Circles /></div> + </div> + + <slot /> +</div> + +<style lang="scss"> + .root { + &:hover { + .circles-mark.isDraggable { + cursor: grab; + opacity: 0.4; + } + } + } + + .circles-mark { + position: relative; + opacity: 0; + width: 0.375rem; + height: 1rem; + transition: opacity 0.1s; + + &.isDraggable::before { + position: absolute; + content: ''; + inset: -0.5rem; + } + } +</style> diff --git a/plugins/view-resources/src/components/list/SortableListItemPresenter.svelte b/plugins/view-resources/src/components/list/SortableListItemPresenter.svelte new file mode 100644 index 0000000000..3f9e7c9de4 --- /dev/null +++ b/plugins/view-resources/src/components/list/SortableListItemPresenter.svelte @@ -0,0 +1,106 @@ +<!-- +// 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 presentation from '@hcengineering/presentation' + import { Icon, IconEdit, IconClose, tooltip, Button } from '@hcengineering/ui' + import { createEventDispatcher } from 'svelte' + + export let isEditable = false + export let isDeletable = false + export let isEditing = false + export let isSaving = false + export let canSave = false + + const dispatch = createEventDispatcher() + + $: areButtonsVisible = isEditable || isDeletable || isEditing +</script> + +<div + class="root flex flex-between items-center w-full p-2" + on:dblclick|preventDefault={isEditable && !isEditing ? () => dispatch('edit') : undefined} +> + <div class="content w-full"> + <slot /> + </div> + + {#if areButtonsVisible} + <div class="ml-auto pl-2 buttons-group small-gap flex-no-shrink"> + {#if isEditing} + <Button label={presentation.string.Cancel} kind="secondary" on:click={() => dispatch('cancel')} /> + <Button + label={presentation.string.Save} + kind="primary" + loading={isSaving} + disabled={!canSave} + on:click={() => dispatch('save')} + /> + {:else} + {#if isEditable} + <button + class="btn" + use:tooltip={{ label: presentation.string.Edit }} + on:click|preventDefault={() => dispatch('edit')} + > + <Icon icon={IconEdit} size="small" /> + </button> + {/if} + {#if isDeletable} + <button + class="btn" + use:tooltip={{ label: presentation.string.Remove }} + on:click|preventDefault={() => dispatch('delete')} + > + <Icon icon={IconClose} size="small" /> + </button> + {/if} + {/if} + </div> + {/if} +</div> + +<style lang="scss"> + .root { + overflow: hidden; + + &:hover { + .btn { + opacity: 1; + } + } + } + + .content { + overflow: hidden; + } + + .btn { + position: relative; + opacity: 0; + cursor: pointer; + color: var(--content-color); + transition: opacity 0.15s; + + &:hover { + color: var(--caption-color); + } + + &::before { + position: absolute; + content: ''; + inset: -0.5rem; + } + } +</style> diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index 74b01d7ba4..8693f2562a 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -54,6 +54,9 @@ import UpDownNavigator from './components/UpDownNavigator.svelte' import ViewletSettingButton from './components/ViewletSettingButton.svelte' import ValueSelector from './components/ValueSelector.svelte' import HTMLEditor from './components/HTMLEditor.svelte' +import SortableList from './components/list/SortableList.svelte' +import SortableListItem from './components/list/SortableListItem.svelte' +import SortableListItemPresenter from './components/list/SortableListItemPresenter.svelte' import { afterResult, beforeResult, @@ -110,7 +113,10 @@ export { BooleanPresenter, NumberEditor, NumberPresenter, - TimestampPresenter + TimestampPresenter, + SortableList, + SortableListItem, + SortableListItemPresenter } export default async (): Promise<Resources> => ({ diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index a9fb44dca3..3823690e6e 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -376,7 +376,8 @@ export interface BuildModelOptions { * */ export interface ObjectFactory extends Class<Obj> { - component: AnyComponent + component?: AnyComponent + create?: Resource<() => Promise<void>> } /**