mirror of
https://github.com/hcengineering/platform.git
synced 2024-11-22 21:50:34 +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)
|
||||
export class TObjectFactory extends TClass implements ObjectFactory {
|
||||
component!: AnyComponent
|
||||
component?: AnyComponent
|
||||
create?: Resource<() => Promise<void>>
|
||||
}
|
||||
|
||||
@Mixin(view.mixin.ObjectTitle, core.class.Class)
|
||||
|
@ -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,19 +13,29 @@
|
||||
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)
|
||||
const { component, create } = hierarchy.as(cl, view.mixin.ObjectFactory)
|
||||
let action: (() => Promise<void>) | undefined
|
||||
|
||||
if (component) {
|
||||
action = async () => {
|
||||
closePopup()
|
||||
showPopup(component, {}, 'top')
|
||||
}
|
||||
} else if (create) {
|
||||
action = await getResource(create)
|
||||
}
|
||||
|
||||
if (action) {
|
||||
actions.push({
|
||||
icon: cl.icon as Asset,
|
||||
label: cl.label,
|
||||
action: async () => {
|
||||
closePopup()
|
||||
showPopup(f.component, {}, 'top')
|
||||
}
|
||||
action
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</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 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> => ({
|
||||
|
@ -376,7 +376,8 @@ export interface BuildModelOptions {
|
||||
*
|
||||
*/
|
||||
export interface ObjectFactory extends Class<Obj> {
|
||||
component: AnyComponent
|
||||
component?: AnyComponent
|
||||
create?: Resource<() => Promise<void>>
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user