Add sortable list (#2403)

Signed-off-by: Sergei Ogorelkov <sergei.ogorelkov@xored.com>
This commit is contained in:
Sergei Ogorelkov 2022-11-30 22:03:05 +06:00 committed by GitHub
parent a8a8f80d62
commit 33a4445a06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 407 additions and 12 deletions

View File

@ -161,7 +161,8 @@ export class TObjectValidator extends TClass implements ObjectValidator {
@Mixin(view.mixin.ObjectFactory, core.class.Class) @Mixin(view.mixin.ObjectFactory, core.class.Class)
export class TObjectFactory extends TClass implements ObjectFactory { export class TObjectFactory extends TClass implements ObjectFactory {
component!: AnyComponent component?: AnyComponent
create?: Resource<() => Promise<void>>
} }
@Mixin(view.mixin.ObjectTitle, core.class.Class) @Mixin(view.mixin.ObjectTitle, core.class.Class)

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Asset } from '@hcengineering/platform' import { Asset, getResource } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { Menu, Action, showPopup, closePopup } from '@hcengineering/ui' import { Menu, Action, showPopup, closePopup } from '@hcengineering/ui'
import view from '@hcengineering/view' import view from '@hcengineering/view'
@ -13,18 +13,28 @@
client client
.getHierarchy() .getHierarchy()
.getDescendants(contact.class.Contact) .getDescendants(contact.class.Contact)
.forEach((v) => { .forEach(async (v) => {
const cl = hierarchy.getClass(v) const cl = hierarchy.getClass(v)
if (hierarchy.hasMixin(cl, view.mixin.ObjectFactory)) { if (hierarchy.hasMixin(cl, view.mixin.ObjectFactory)) {
const f = hierarchy.as(cl, view.mixin.ObjectFactory) const { component, create } = hierarchy.as(cl, view.mixin.ObjectFactory)
actions.push({ let action: (() => Promise<void>) | undefined
icon: cl.icon as Asset,
label: cl.label, if (component) {
action: async () => { action = async () => {
closePopup() 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> </script>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -54,6 +54,9 @@ import UpDownNavigator from './components/UpDownNavigator.svelte'
import ViewletSettingButton from './components/ViewletSettingButton.svelte' import ViewletSettingButton from './components/ViewletSettingButton.svelte'
import ValueSelector from './components/ValueSelector.svelte' import ValueSelector from './components/ValueSelector.svelte'
import HTMLEditor from './components/HTMLEditor.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 { import {
afterResult, afterResult,
beforeResult, beforeResult,
@ -110,7 +113,10 @@ export {
BooleanPresenter, BooleanPresenter,
NumberEditor, NumberEditor,
NumberPresenter, NumberPresenter,
TimestampPresenter TimestampPresenter,
SortableList,
SortableListItem,
SortableListItemPresenter
} }
export default async (): Promise<Resources> => ({ export default async (): Promise<Resources> => ({

View File

@ -376,7 +376,8 @@ export interface BuildModelOptions {
* *
*/ */
export interface ObjectFactory extends Class<Obj> { export interface ObjectFactory extends Class<Obj> {
component: AnyComponent component?: AnyComponent
create?: Resource<() => Promise<void>>
} }
/** /**