mirror of
https://github.com/hcengineering/platform.git
synced 2024-11-26 04:23:58 +03:00
Add sortable list (#2403)
Signed-off-by: Sergei Ogorelkov <sergei.ogorelkov@xored.com>
This commit is contained in:
parent
a8a8f80d62
commit
33a4445a06
@ -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)
|
||||||
|
@ -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>
|
||||||
|
219
plugins/view-resources/src/components/list/SortableList.svelte
Normal file
219
plugins/view-resources/src/components/list/SortableList.svelte
Normal 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>
|
@ -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>
|
@ -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>
|
@ -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> => ({
|
||||||
|
@ -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>>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user