feat(planner): drag-n-drop (#5031)

Signed-off-by: Eduard Aksamitov <e@euaaaio.ru>
This commit is contained in:
Eduard Aksamitov 2024-03-22 11:37:24 +03:00 committed by GitHub
parent 5fd60d1096
commit 9d52ddbbea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 534 additions and 142 deletions

View File

@ -50,6 +50,7 @@
"@hcengineering/notification": "^0.6.16", "@hcengineering/notification": "^0.6.16",
"@hcengineering/model-tracker": "^0.6.0", "@hcengineering/model-tracker": "^0.6.0",
"@hcengineering/time": "^0.6.0", "@hcengineering/time": "^0.6.0",
"@hcengineering/time-resources": "^0.6.0" "@hcengineering/time-resources": "^0.6.0",
"@hcengineering/rank": "^0.6.0"
} }
} }

View File

@ -26,10 +26,23 @@ import {
type Space, type Space,
type Timestamp, type Timestamp,
type Type, type Type,
DateRangeMode DateRangeMode,
IndexKind
} from '@hcengineering/core' } from '@hcengineering/core'
import lead from '@hcengineering/lead' import lead from '@hcengineering/lead'
import { Collection, Mixin, Model, Prop, TypeRef, TypeString, UX, type Builder, TypeDate } from '@hcengineering/model' import {
Collection,
Mixin,
Model,
Prop,
TypeRef,
TypeString,
UX,
type Builder,
TypeDate,
Hidden,
Index
} from '@hcengineering/model'
import { TEvent } from '@hcengineering/model-calendar' import { TEvent } from '@hcengineering/model-calendar'
import core, { TAttachedDoc, TClass, TDoc, TType } from '@hcengineering/model-core' import core, { TAttachedDoc, TClass, TDoc, TType } from '@hcengineering/model-core'
import tracker from '@hcengineering/model-tracker' import tracker from '@hcengineering/model-tracker'
@ -51,7 +64,8 @@ import {
type WorkSlot type WorkSlot
} from '@hcengineering/time' } from '@hcengineering/time'
import { type Resource } from '@hcengineering/platform' import type { Resource } from '@hcengineering/platform'
import type { Rank } from '@hcengineering/task'
import time from './plugin' import time from './plugin'
import task from '@hcengineering/task' import task from '@hcengineering/task'
@ -107,6 +121,10 @@ export class TToDO extends TAttachedDoc implements ToDo {
@Prop(Collection(tags.class.TagReference, tags.string.TagLabel), tags.string.Tags) @Prop(Collection(tags.class.TagReference, tags.string.TagLabel), tags.string.Tags)
labels?: number | undefined labels?: number | undefined
@Index(IndexKind.Indexed)
@Hidden()
rank!: Rank
} }
@Model(time.class.ProjectToDo, time.class.ToDo) @Model(time.class.ProjectToDo, time.class.ToDo)

View File

@ -14,13 +14,14 @@
// //
import { type PersonAccount } from '@hcengineering/contact' import { type PersonAccount } from '@hcengineering/contact'
import { type Account, type Doc, type Ref, TxOperations } from '@hcengineering/core' import { type Account, type Doc, type Ref, SortingOrder, TxOperations } from '@hcengineering/core'
import { import {
type MigrateOperation, type MigrateOperation,
type MigrationClient, type MigrationClient,
type MigrationUpgradeClient, type MigrationUpgradeClient,
createOrUpdate createOrUpdate
} from '@hcengineering/model' } from '@hcengineering/model'
import { makeRank } from '@hcengineering/rank'
import core from '@hcengineering/model-core' import core from '@hcengineering/model-core'
import task from '@hcengineering/task' import task from '@hcengineering/task'
import tags from '@hcengineering/tags' import tags from '@hcengineering/tags'
@ -37,12 +38,14 @@ export async function migrateWorkSlots (client: TxOperations): Promise<void> {
const now = Date.now() const now = Date.now()
const todos = new Map<Ref<Doc>, Ref<ToDo>>() const todos = new Map<Ref<Doc>, Ref<ToDo>>()
const count = new Map<Ref<ToDo>, number>() const count = new Map<Ref<ToDo>, number>()
let rank = makeRank(undefined, undefined)
for (const oldWorkSlot of oldWorkSlots) { for (const oldWorkSlot of oldWorkSlots) {
const todo = todos.get(oldWorkSlot.attachedTo) const todo = todos.get(oldWorkSlot.attachedTo)
if (todo === undefined) { if (todo === undefined) {
const acc = oldWorkSlot.space.replace('_calendar', '') as Ref<Account> const acc = oldWorkSlot.space.replace('_calendar', '') as Ref<Account>
const account = (await client.findOne(core.class.Account, { _id: acc })) as PersonAccount const account = (await client.findOne(core.class.Account, { _id: acc })) as PersonAccount
if (account.person !== undefined) { if (account.person !== undefined) {
rank = makeRank(undefined, rank)
const todo = await client.addCollection( const todo = await client.addCollection(
time.class.ProjectToDo, time.class.ProjectToDo,
time.space.ToDos, time.space.ToDos,
@ -57,7 +60,8 @@ export async function migrateWorkSlots (client: TxOperations): Promise<void> {
workslots: 0, workslots: 0,
priority: ToDoPriority.NoPriority, priority: ToDoPriority.NoPriority,
user: account.person, user: account.person,
visibility: 'public' visibility: 'public',
rank
} }
) )
await client.update(oldWorkSlot, { await client.update(oldWorkSlot, {
@ -105,6 +109,44 @@ async function migrateTodosSpace (client: TxOperations): Promise<void> {
} }
} }
async function migrateTodosRanks (client: TxOperations): Promise<void> {
const doneTodos = await client.findAll(
time.class.ToDo,
{
rank: { $exists: false },
doneOn: null
},
{
sort: { modifiedOn: SortingOrder.Ascending }
}
)
let doneTodoRank = makeRank(undefined, undefined)
for (const todo of doneTodos) {
await client.update(todo, {
rank: doneTodoRank
})
doneTodoRank = makeRank(undefined, doneTodoRank)
}
const undoneTodos = await client.findAll(
time.class.ToDo,
{
rank: { $exists: false },
doneOn: { $ne: null }
},
{
sort: { doneOn: SortingOrder.Ascending }
}
)
let undoneTodoRank = makeRank(undefined, undefined)
for (const todo of undoneTodos) {
await client.update(todo, {
rank: undoneTodoRank
})
undoneTodoRank = makeRank(undefined, undoneTodoRank)
}
}
async function createDefaultSpace (tx: TxOperations): Promise<void> { async function createDefaultSpace (tx: TxOperations): Promise<void> {
const current = await tx.findOne(core.class.Space, { const current = await tx.findOne(core.class.Space, {
_id: time.space.ToDos _id: time.space.ToDos
@ -161,5 +203,6 @@ export const timeOperation: MigrateOperation = {
) )
await migrateWorkSlots(tx) await migrateWorkSlots(tx)
await migrateTodosSpace(tx) await migrateTodosSpace(tx)
await migrateTodosRanks(tx)
} }
} }

View File

@ -457,7 +457,7 @@
} }
} }
&.large { &.large {
padding: var(--spacing-2) var(--spacing-1_5) var(--spacing-1) var(--spacing-2); padding: var(--spacing-2) var(--spacing-1_5) var(--spacing-2) var(--spacing-2);
min-height: var(--global-extra-large-Size); min-height: var(--global-extra-large-Size);
.hulyAccordionItem-header__label-wrapper { .hulyAccordionItem-header__label-wrapper {
@ -526,6 +526,23 @@
} }
} }
.hulyToDoLine-draggable {
position: relative;
&.is-dragging-over-up::before {
position: absolute;
content: '';
inset: 0;
border-top: 1px solid var(--global-focus-BorderColor);
}
&.is-dragging-over-down::before {
position: absolute;
content: '';
inset: 0;
border-bottom: 1px solid var(--global-focus-BorderColor);
}
}
/* ToDo Line */ /* ToDo Line */
.hulyToDoLine-container { .hulyToDoLine-container {
display: flex; display: flex;
@ -567,6 +584,7 @@
color: inherit; color: inherit;
border: none; border: none;
outline: none; outline: none;
cursor: grab;
&.isNew::after { &.isNew::after {
position: absolute; position: absolute;

View File

@ -60,6 +60,7 @@
"@hcengineering/workbench": "^0.6.9", "@hcengineering/workbench": "^0.6.9",
"@hcengineering/document": "^0.6.0", "@hcengineering/document": "^0.6.0",
"@hcengineering/time": "^0.6.0", "@hcengineering/time": "^0.6.0",
"@hcengineering/rank": "^0.6.0",
"@tiptap/core": "^2.1.12", "@tiptap/core": "^2.1.12",
"slugify": "^1.6.6" "slugify": "^1.6.6"
} }

View File

@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Person } from '@hcengineering/contact' import { Person } from '@hcengineering/contact'
import { AssigneePopup, EmployeePresenter } from '@hcengineering/contact-resources' import { AssigneePopup, EmployeePresenter } from '@hcengineering/contact-resources'
import { Doc, Ref } from '@hcengineering/core' import { Doc, Ref, SortingOrder } from '@hcengineering/core'
import { MessageBox, createQuery, getClient } from '@hcengineering/presentation' import { MessageBox, createQuery, getClient } from '@hcengineering/presentation'
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@hcengineering/text-editor' import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@hcengineering/text-editor'
import { CheckBox, getEventPositionElement, showPopup } from '@hcengineering/ui' import { CheckBox, getEventPositionElement, showPopup } from '@hcengineering/ui'
import time, { ToDo, ToDoPriority } from '@hcengineering/time' import time, { ToDo, ToDoPriority } from '@hcengineering/time'
import { makeRank } from '@hcengineering/rank'
import document from '../../plugin' import document from '../../plugin'
@ -75,6 +76,20 @@
await ops.remove(todo) await ops.remove(todo)
} }
const doneOn = node.attrs.checked === true ? Date.now() : null
const latestTodoItem = await ops.findOne(
time.class.ToDo,
{
user,
doneOn: doneOn === null ? null : { $ne: null }
},
{
sort: { rank: SortingOrder.Ascending }
}
)
const rank = makeRank(undefined, latestTodoItem?.rank)
const id = await ops.addCollection(time.class.ProjectToDo, time.space.ToDos, object._id, object._class, 'todos', { const id = await ops.addCollection(time.class.ProjectToDo, time.space.ToDos, object._id, object._class, 'todos', {
attachedSpace: object.space, attachedSpace: object.space,
title, title,
@ -83,7 +98,8 @@
workslots: 0, workslots: 0,
priority: ToDoPriority.NoPriority, priority: ToDoPriority.NoPriority,
visibility: 'public', visibility: 'public',
doneOn: node.attrs.checked === true ? Date.now() : null doneOn,
rank
}) })
await ops.commit() await ops.commit()

View File

@ -1,30 +1,45 @@
<script lang="ts"> <script lang="ts">
import { ActionIcon, IconAdd, showPopup, ModernEditbox } from '@hcengineering/ui'
import { SortingOrder, getCurrentAccount } from '@hcengineering/core'
import { PersonAccount } from '@hcengineering/contact' import { PersonAccount } from '@hcengineering/contact'
import { getCurrentAccount } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { ActionIcon, EditBox, IconAdd, showPopup, ModernEditbox } from '@hcengineering/ui'
import { ToDoPriority } from '@hcengineering/time' import { ToDoPriority } from '@hcengineering/time'
import time from '../plugin' import { getClient } from '@hcengineering/presentation'
import CreateToDoPopup from './CreateToDoPopup.svelte' import CreateToDoPopup from './CreateToDoPopup.svelte'
import time from '../plugin'
import { makeRank } from '@hcengineering/task'
export let fullSize: boolean = false export let fullSize: boolean = false
let value: string = '' let value: string = ''
async function save () { const client = getClient()
const acc = getCurrentAccount() as PersonAccount
async function save (): Promise<void> {
let [name, description] = value.split('//') let [name, description] = value.split('//')
name = name.trim() name = name.trim()
if (name.length === 0) return if (name.length === 0) return
description = description?.trim() ?? '' description = description?.trim() ?? ''
const client = getClient() const ops = client.apply('todo')
const acc = getCurrentAccount() as PersonAccount const latestTodo = await ops.findOne(
await client.addCollection(time.class.ToDo, time.space.ToDos, time.ids.NotAttached, time.class.ToDo, 'todos', { time.class.ToDo,
{
user: acc.person,
doneOn: null
},
{
sort: { rank: SortingOrder.Ascending }
}
)
await ops.addCollection(time.class.ToDo, time.space.ToDos, time.ids.NotAttached, time.class.ToDo, 'todos', {
title: name, title: name,
description, description,
user: acc.person, user: acc.person,
workslots: 0, workslots: 0,
priority: ToDoPriority.NoPriority, priority: ToDoPriority.NoPriority,
visibility: 'private' visibility: 'private',
rank: makeRank(undefined, latestTodo?.rank)
}) })
await ops.commit()
clear() clear()
} }

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import core, { AttachedData, Doc, Ref, generateId, getCurrentAccount } from '@hcengineering/core' import core, { AttachedData, Doc, Ref, SortingOrder, generateId, getCurrentAccount } from '@hcengineering/core'
import { Button, Component, EditBox, IconClose, Label, Scroller } from '@hcengineering/ui' import { Button, Component, EditBox, IconClose, Label, Scroller } from '@hcengineering/ui'
import { SpaceSelector, createQuery, getClient } from '@hcengineering/presentation' import { SpaceSelector, createQuery, getClient } from '@hcengineering/presentation'
import { ToDo, ToDoPriority, WorkSlot } from '@hcengineering/time' import { ToDo, ToDoPriority, WorkSlot } from '@hcengineering/time'
@ -24,7 +24,7 @@
import { StyledTextBox } from '@hcengineering/text-editor' import { StyledTextBox } from '@hcengineering/text-editor'
import { PersonAccount } from '@hcengineering/contact' import { PersonAccount } from '@hcengineering/contact'
import calendar from '@hcengineering/calendar-resources/src/plugin' import calendar from '@hcengineering/calendar-resources/src/plugin'
import task from '@hcengineering/task' import task, { makeRank } from '@hcengineering/task'
import PriorityEditor from './PriorityEditor.svelte' import PriorityEditor from './PriorityEditor.svelte'
import DueDateEditor from './DueDateEditor.svelte' import DueDateEditor from './DueDateEditor.svelte'
import Workslots from './Workslots.svelte' import Workslots from './Workslots.svelte'
@ -40,7 +40,8 @@
priority: ToDoPriority.NoPriority, priority: ToDoPriority.NoPriority,
attachedSpace: object?.space, attachedSpace: object?.space,
visibility: 'private', visibility: 'private',
user: acc.person user: acc.person,
rank: ''
} }
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -54,7 +55,18 @@
async function saveToDo (): Promise<void> { async function saveToDo (): Promise<void> {
loading = true loading = true
const id = await client.addCollection( const ops = client.apply('todo')
const latestTodo = await ops.findOne(
time.class.ToDo,
{
user: acc.person,
doneOn: null
},
{
sort: { rank: SortingOrder.Ascending }
}
)
const id = await ops.addCollection(
time.class.ToDo, time.class.ToDo,
time.space.ToDos, time.space.ToDos,
object?._id ?? time.ids.NotAttached, object?._id ?? time.ids.NotAttached,
@ -68,12 +80,13 @@
visibility: todo.visibility, visibility: todo.visibility,
user: acc.person, user: acc.person,
dueDate: todo.dueDate, dueDate: todo.dueDate,
attachedSpace: todo.attachedSpace attachedSpace: todo.attachedSpace,
rank: makeRank(undefined, latestTodo?.rank)
} }
) )
const space = `${acc._id}_calendar` as Ref<Calendar> const space = `${acc._id}_calendar` as Ref<Calendar>
for (const slot of slots) { for (const slot of slots) {
await client.addCollection(time.class.WorkSlot, space, id, time.class.ToDo, 'workslots', { await ops.addCollection(time.class.WorkSlot, space, id, time.class.ToDo, 'workslots', {
eventId: generateEventId(), eventId: generateEventId(),
date: slot.date, date: slot.date,
dueDate: slot.dueDate, dueDate: slot.dueDate,
@ -87,8 +100,9 @@
}) })
} }
for (const tag of tags) { for (const tag of tags) {
await client.addCollection(tagsPlugin.class.TagReference, time.space.ToDos, id, time.class.ToDo, 'labels', tag) await ops.addCollection(tagsPlugin.class.TagReference, time.space.ToDos, id, time.class.ToDo, 'labels', tag)
} }
await ops.commit()
dispatch('close', true) dispatch('close', true)
} }

View File

@ -1,3 +1,18 @@
<!--
// Copyright © 2024 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"> <script lang="ts">
import { createEventDispatcher, afterUpdate } from 'svelte' import { createEventDispatcher, afterUpdate } from 'svelte'
import calendar, { Calendar, generateEventId } from '@hcengineering/calendar' import calendar, { Calendar, generateEventId } from '@hcengineering/calendar'
@ -6,13 +21,13 @@
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { TagElement } from '@hcengineering/tags' import { TagElement } from '@hcengineering/tags'
import { Separator, defineSeparators } from '@hcengineering/ui' import { Separator, defineSeparators } from '@hcengineering/ui'
import { ToDo } from '@hcengineering/time'
import { ToDosMode } from '..' import { ToDosMode } from '..'
import time from '../plugin'
import { timeSeparators } from '../utils'
import PlanningCalendar from './PlanningCalendar.svelte' import PlanningCalendar from './PlanningCalendar.svelte'
import ToDos from './ToDos.svelte'
import ToDosNavigator from './ToDosNavigator.svelte' import ToDosNavigator from './ToDosNavigator.svelte'
import ToDos from './ToDos.svelte'
import { timeSeparators } from '../utils'
import { dragging } from '../dragging'
import time from '../plugin'
export let visibleNav: boolean = true export let visibleNav: boolean = true
export let navFloat: boolean = false export let navFloat: boolean = false
@ -26,12 +41,12 @@
let currentDate: Date = new Date() let currentDate: Date = new Date()
let dragItem: ToDo | undefined = undefined $: dragItem = $dragging.item
const client = getClient() const client = getClient()
async function drop (e: CustomEvent<any>) { async function drop (e: CustomEvent<any>) {
if (dragItem === undefined) return if (dragItem === null) return
const doc = dragItem const doc = dragItem
const date = e.detail.date.getTime() const date = e.detail.date.getTime()
const currentUser = getCurrentAccount() as PersonAccount const currentUser = getCurrentAccount() as PersonAccount
@ -79,14 +94,7 @@
/> />
{/if} {/if}
<div class="flex-col clear-mins"> <div class="flex-col clear-mins">
<ToDos <ToDos {mode} {tag} bind:isVisiblePlannerNav bind:currentDate />
{mode}
{tag}
bind:isVisiblePlannerNav
bind:currentDate
on:dragstart={(e) => (dragItem = e.detail)}
on:dragend={() => (dragItem = undefined)}
/>
</div> </div>
<Separator name={'time'} float={navFloat} index={1} color={'transparent'} separatorSize={0} short /> <Separator name={'time'} float={navFloat} index={1} color={'transparent'} separatorSize={0} short />
{/if} {/if}

View File

@ -25,7 +25,7 @@
import time from '../plugin' import time from '../plugin'
import IconSun from './icons/Sun.svelte' import IconSun from './icons/Sun.svelte'
export let dragItem: ToDo | undefined = undefined export let dragItem: ToDo | null = null
export let currentDate: Date = new Date() export let currentDate: Date = new Date()
export let displayedDaysCount = 1 export let displayedDaysCount = 1
export let createComponent: AnyComponent | undefined = calendar.component.CreateEvent export let createComponent: AnyComponent | undefined = calendar.component.CreateEvent
@ -151,8 +151,8 @@
} }
} }
function clear (dragItem: ToDo | undefined) { function clear (dragItem: ToDo | null) {
if (dragItem === undefined) { if (dragItem === null) {
raw = raw.filter((p) => p._id !== dragItemId) raw = raw.filter((p) => p._id !== dragItemId)
all = getAllEvents(raw, from, to) all = getAllEvents(raw, from, to)
objects = hidePrivateEvents(all, $calendarStore) objects = hidePrivateEvents(all, $calendarStore)
@ -216,7 +216,7 @@
events={objects} events={objects}
{displayedDaysCount} {displayedDaysCount}
startFromWeekStart={false} startFromWeekStart={false}
clearCells={dragItem !== undefined} clearCells={dragItem !== null}
{dragItemId} {dragItemId}
on:dragEnter={dragEnter} on:dragEnter={dragEnter}
on:dragleave={dragLeave} on:dragleave={dragLeave}

View File

@ -0,0 +1,113 @@
<!--
// Copyright © 2024 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 type { IntlString } from '@hcengineering/platform'
import type { WithLookup } from '@hcengineering/core'
import type { ToDo } from '@hcengineering/time'
import { createEventDispatcher } from 'svelte'
import { dragging } from '../dragging'
import time from '../plugin'
export let todo: WithLookup<ToDo>
export let index: number
export let groupName: IntlString | null
export let projectId: string | false | null
const dispatch = createEventDispatcher()
let isDragging: boolean = false
let draggingOverClass: string = ''
$: draggingItemIndex = $dragging.itemIndex
$: draggingGroupName = $dragging.groupName
$: draggingProjectId = $dragging.projectId
$: draggingOverIndex = $dragging.overItemIndex
$: draggingOverGroupName = $dragging.overGroupName
$: draggingOverProjectId = $dragging.overProjectId
$: isDraggable = groupName !== time.string.Done
$: {
if (
isDraggable &&
draggingItemIndex !== null &&
draggingOverGroupName === groupName &&
draggingGroupName === groupName &&
draggingOverProjectId === projectId &&
draggingProjectId === projectId &&
index === draggingOverIndex
) {
draggingOverClass = index < draggingItemIndex ? 'is-dragging-over-up' : 'is-dragging-over-down'
} else {
draggingOverClass = ''
}
}
function handleDragStart (event: DragEvent): void {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'all'
}
isDragging = true
dragging.update((state) => ({
...state,
item: todo,
itemIndex: index,
groupName,
projectId
}))
}
function handleDragEnd (): void {
isDragging = false
dragging.set({
item: null,
itemIndex: null,
groupName: null,
projectId: null,
overItemIndex: null,
overGroupName: null,
overProjectId: null
})
}
function handleDragOver (): void {
if (!isDraggable) return
dragging.update((state) => ({
...state,
overItemIndex: index,
overGroupName: groupName,
overProjectId: projectId
}))
}
function handleDrop (event: DragEvent): void {
if (!isDraggable) return
dispatch('drop', { event, index })
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="hulyToDoLine-draggable {draggingOverClass} step-tb125"
class:dragging={isDragging}
draggable={true}
on:dragstart={handleDragStart}
on:dragend={handleDragEnd}
on:dragover|preventDefault={handleDragOver}
on:drop={handleDrop}
>
<slot />
</div>

View File

@ -1,3 +1,17 @@
<!--
// Copyright © 2024 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"> <script lang="ts">
import { SortingOrder, WithLookup } from '@hcengineering/core' import { SortingOrder, WithLookup } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
@ -5,7 +19,6 @@
import { Component, IconMoreV2, Spinner, showPanel, Icon } from '@hcengineering/ui' import { Component, IconMoreV2, Spinner, showPanel, Icon } from '@hcengineering/ui'
import { showMenu } from '@hcengineering/view-resources' import { showMenu } from '@hcengineering/view-resources'
import time, { ToDo, ToDoPriority, WorkSlot } from '@hcengineering/time' import time, { ToDo, ToDoPriority, WorkSlot } from '@hcengineering/time'
import { createEventDispatcher } from 'svelte'
import plugin from '../plugin' import plugin from '../plugin'
import ToDoDuration from './ToDoDuration.svelte' import ToDoDuration from './ToDoDuration.svelte'
import WorkItemPresenter from './WorkItemPresenter.svelte' import WorkItemPresenter from './WorkItemPresenter.svelte'
@ -15,13 +28,10 @@
export let todo: WithLookup<ToDo> export let todo: WithLookup<ToDo>
export let size: 'small' | 'large' = 'small' export let size: 'small' | 'large' = 'small'
export let planned: boolean = true export let planned: boolean = true
export let draggable: boolean = true
export let isNew: boolean = false export let isNew: boolean = false
const dispatch = createEventDispatcher()
const client = getClient() const client = getClient()
let updating: Promise<any> | undefined = undefined let updating: Promise<any> | undefined = undefined
let isDrag: boolean = false
async function markDone (): Promise<void> { async function markDone (): Promise<void> {
await updating await updating
@ -51,17 +61,6 @@
}) })
} }
function dragStart (todo: ToDo, event: DragEvent & { currentTarget: EventTarget & HTMLButtonElement }): void {
event.currentTarget.classList.add('dragged')
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'all'
dispatch('dragstart', todo)
}
function dragEnd (event: DragEvent & { currentTarget: EventTarget & HTMLButtonElement }): void {
event.currentTarget.classList.remove('dragged')
dispatch('dragend')
}
function open (e: MouseEvent): void { function open (e: MouseEvent): void {
showPanel(time.component.EditToDo, todo._id, todo._class, 'content') showPanel(time.component.EditToDo, todo._id, todo._class, 'content')
} }
@ -73,20 +72,10 @@
class="hulyToDoLine-container {size}" class="hulyToDoLine-container {size}"
class:hovered class:hovered
class:isDone class:isDone
class:isDrag
on:click|stopPropagation={open} on:click|stopPropagation={open}
on:contextmenu={(e) => { on:contextmenu={(e) => {
showMenu(e, { object: todo }) showMenu(e, { object: todo })
}} }}
{draggable}
on:dragstart={(e) => {
isDrag = true
dragStart(todo, e)
}}
on:dragend={(e) => {
isDrag = false
dragEnd(e)
}}
> >
<div class="flex-row-top flex-grow flex-gap-2"> <div class="flex-row-top flex-grow flex-gap-2">
<div class="flex-row-center flex-no-shrink"> <div class="flex-row-center flex-no-shrink">

View File

@ -1,21 +1,33 @@
<!--
// Copyright © 2024 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"> <script lang="ts">
import { WithLookup, IdMap, Ref, Space } from '@hcengineering/core' import type { WithLookup, IdMap, Ref, Space } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform' import type { ToDo, WorkSlot } from '@hcengineering/time'
import { ToDo, WorkSlot } from '@hcengineering/time' import type { IntlString } from '@hcengineering/platform'
import time from '../plugin' import type { Project } from '@hcengineering/tracker'
import { createEventDispatcher } from 'svelte' import type { ToDosMode } from '..'
import { AccordionItem } from '@hcengineering/ui'
import ToDoDraggable from './ToDoDraggable.svelte'
import ToDoDuration from './ToDoDuration.svelte' import ToDoDuration from './ToDoDuration.svelte'
import ToDoElement from './ToDoElement.svelte' import ToDoElement from './ToDoElement.svelte'
import { import time from '../plugin'
AccordionItem, import { dragging } from '../dragging'
IconWithEmoji, import ToDoProjectGroup from './ToDoProjectGroup.svelte'
getPlatformColorDef, import { getClient } from '@hcengineering/presentation'
getPlatformColorForTextDef, import { makeRank } from '@hcengineering/task'
themeStore
} from '@hcengineering/ui'
import { ToDosMode } from '..'
import tracker, { Project } from '@hcengineering/tracker'
import view from '@hcengineering/view'
export let mode: ToDosMode export let mode: ToDosMode
export let title: IntlString export let title: IntlString
@ -25,8 +37,6 @@
export let largeSize: boolean = false export let largeSize: boolean = false
export let projects: IdMap<Project> export let projects: IdMap<Project>
const dispatch = createEventDispatcher()
function getAllWorkslots (todos: WithLookup<ToDo>[]): WorkSlot[] { function getAllWorkslots (todos: WithLookup<ToDo>[]): WorkSlot[] {
const workslots: WorkSlot[] = [] const workslots: WorkSlot[] = []
for (const todo of todos) { for (const todo of todos) {
@ -56,9 +66,28 @@
withoutProject = wp withoutProject = wp
return _groups return _groups
} }
const hasProject = (proj: Ref<Space> | undefined): boolean => { const hasProject = (proj: Ref<Space> | undefined): boolean => {
return (proj && projects.has(proj as Ref<Project>)) ?? false return (proj && projects.has(proj as Ref<Project>)) ?? false
} }
const client = getClient()
$: draggingItem = $dragging.item
$: draggingItemIndex = $dragging.itemIndex
async function handleDrop (event: CustomEvent<{ event: DragEvent, index: number }>): Promise<void> {
if (draggingItem === null || draggingItemIndex === null) return
const droppingIndex = event.detail.index
const [previousItem, nextItem] = [
todos[draggingItemIndex < droppingIndex ? droppingIndex : droppingIndex - 1],
todos[draggingItemIndex < droppingIndex ? droppingIndex + 1 : droppingIndex]
]
const newRank = makeRank(previousItem?.rank, nextItem?.rank)
await client.update(draggingItem, { rank: newRank })
}
</script> </script>
{#if showTitle} {#if showTitle}
@ -68,7 +97,7 @@
bottomSpace={false} bottomSpace={false}
counter={todos.length} counter={todos.length}
duration={showDuration} duration={showDuration}
isOpen isOpen={title !== time.string.Done}
fixHeader fixHeader
background={'var(--theme-navpanel-color)'} background={'var(--theme-navpanel-color)'}
> >
@ -77,48 +106,31 @@
</svelte:fragment> </svelte:fragment>
{#if groups} {#if groups}
{#each groups as group} {#each groups as group}
<AccordionItem <ToDoProjectGroup
icon={group.icon === view.ids.IconWithEmoji ? IconWithEmoji : group.icon ?? tracker.icon.Home} todos={todos.filter((td) => td.attachedSpace === group._id)}
iconProps={group.icon === view.ids.IconWithEmoji project={group}
? { icon: group.color } groupName={title}
: { {largeSize}
fill: {mode}
group.color !== undefined />
? getPlatformColorDef(group.color, $themeStore.dark).icon
: getPlatformColorForTextDef(group.name, $themeStore.dark).icon
}}
title={group.name}
size={'medium'}
isOpen
nested
>
{#each todos.filter((td) => td.attachedSpace === group._id) as todo}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="step-tb125" draggable={true} on:dragend on:dragstart={() => dispatch('dragstart', todo)}>
<ToDoElement {todo} size={largeSize ? 'large' : 'small'} planned={mode !== 'unplanned'} />
</div>
{/each}
</AccordionItem>
{/each} {/each}
{/if} {/if}
{#if withoutProject} {#if withoutProject}
<AccordionItem label={time.string.WithoutProject} size={'medium'} isOpen nested> <ToDoProjectGroup
{#each todos.filter((td) => !hasProject(td.attachedSpace)) as todo} todos={todos.filter((td) => !hasProject(td.attachedSpace))}
<!-- svelte-ignore a11y-no-static-element-interactions --> project={false}
<div class="step-tb125" draggable={true} on:dragend on:dragstart={() => dispatch('dragstart', todo)}> groupName={title}
<ToDoElement {todo} size={largeSize ? 'large' : 'small'} planned={mode !== 'unplanned'} /> {largeSize}
</div> {mode}
{/each} />
</AccordionItem>
{/if} {/if}
</AccordionItem> </AccordionItem>
{:else} {:else}
<div class="flex-col p-4 w-full"> <div class="flex-col p-4 w-full">
{#each todos as todo} {#each todos as todo, index}
<!-- svelte-ignore a11y-no-static-element-interactions --> <ToDoDraggable {todo} {index} groupName={title} projectId={false} on:drop={handleDrop}>
<div class="step-tb125" draggable={true} on:dragend on:dragstart={() => dispatch('dragstart', todo)}>
<ToDoElement {todo} size={largeSize ? 'large' : 'small'} planned={mode !== 'unplanned'} /> <ToDoElement {todo} size={largeSize ? 'large' : 'small'} planned={mode !== 'unplanned'} />
</div> </ToDoDraggable>
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@ -0,0 +1,90 @@
<!--
// Copyright © 2024 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 type { IntlString } from '@hcengineering/platform'
import type { Project } from '@hcengineering/tracker'
import type { ToDo } from '@hcengineering/time'
import type { ToDosMode } from '..'
import {
AccordionItem,
IconWithEmoji,
getPlatformColorDef,
getPlatformColorForTextDef,
themeStore
} from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import { makeRank } from '@hcengineering/task'
import tracker from '@hcengineering/tracker'
import view from '@hcengineering/view'
import ToDoDraggable from './ToDoDraggable.svelte'
import ToDoElement from './ToDoElement.svelte'
import time from '../plugin'
import { dragging } from '../dragging'
export let todos: ToDo[]
export let project: Project | false | undefined = undefined
export let mode: ToDosMode
export let groupName: IntlString
export let largeSize: boolean = false
const client = getClient()
let projectId: string | false
$: icon = project
? project.icon === view.ids.IconWithEmoji
? IconWithEmoji
: project.icon ?? tracker.icon.Home
: undefined
$: iconProps = project
? project.icon === view.ids.IconWithEmoji
? { icon: project.color }
: {
fill:
project.color !== undefined
? getPlatformColorDef(project.color, $themeStore.dark).icon
: getPlatformColorForTextDef(project.name, $themeStore.dark).icon
}
: undefined
$: title = project ? project.name : undefined
$: label = title ? undefined : time.string.WithoutProject
$: projectId = project ? project._id : false
$: draggingItem = $dragging.item
$: draggingItemIndex = $dragging.itemIndex
async function handleDrop (event: CustomEvent<{ event: DragEvent, index: number }>): Promise<void> {
if (draggingItem === null || draggingItemIndex === null) return
const droppingIndex = event.detail.index
const [previousItem, nextItem] = [
todos[draggingItemIndex < droppingIndex ? droppingIndex : droppingIndex - 1],
todos[draggingItemIndex < droppingIndex ? droppingIndex + 1 : droppingIndex]
]
const newRank = makeRank(previousItem?.rank, nextItem?.rank)
await client.update(draggingItem, { rank: newRank })
}
</script>
<AccordionItem {icon} {iconProps} {title} {label} size={'medium'} isOpen nested>
{#each todos as todo, index}
<ToDoDraggable {todo} {index} {groupName} {projectId} on:drop={handleDrop}>
<ToDoElement {todo} size={largeSize ? 'large' : 'small'} planned={mode !== 'unplanned'} />
</ToDoDraggable>
{/each}
</AccordionItem>

View File

@ -1,21 +1,39 @@
<!--
// Copyright © 2024 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"> <script lang="ts">
import { PersonAccount } from '@hcengineering/contact' import type { DocumentQuery, Ref, WithLookup, IdMap } from '@hcengineering/core'
import { DocumentQuery, Ref, SortingOrder, WithLookup, getCurrentAccount, IdMap, toIdMap } from '@hcengineering/core' import type { ToDo, WorkSlot } from '@hcengineering/time'
import { IntlString } from '@hcengineering/platform' import type { PersonAccount } from '@hcengineering/contact'
import { createQuery } from '@hcengineering/presentation' import type { IntlString } from '@hcengineering/platform'
import type { TagElement } from '@hcengineering/tags'
import type { Project } from '@hcengineering/tracker'
import type { ToDosMode } from '..'
import { Scroller, areDatesEqual, todosSP, defaultSP, Header, ButtonIcon, Label } from '@hcengineering/ui' import { Scroller, areDatesEqual, todosSP, defaultSP, Header, ButtonIcon, Label } from '@hcengineering/ui'
import { ToDo, WorkSlot } from '@hcengineering/time' import { getCurrentAccount, toIdMap, SortingOrder } from '@hcengineering/core'
import { ToDosMode } from '..' import { createQuery } from '@hcengineering/presentation'
import tracker from '@hcengineering/tracker'
import tags from '@hcengineering/tags'
import view from '@hcengineering/view-resources/src/plugin'
import { getNearest } from '../utils' import { getNearest } from '../utils'
import MenuClose from './icons/MenuClose.svelte'
import MenuOpen from './icons/MenuOpen.svelte'
import CreateToDo from './CreateToDo.svelte' import CreateToDo from './CreateToDo.svelte'
import ToDoGroup from './ToDoGroup.svelte' import ToDoGroup from './ToDoGroup.svelte'
import MenuClose from './icons/MenuClose.svelte'
import MenuOpen from './icons/MenuOpen.svelte'
import IconDiff from './icons/Diff.svelte' import IconDiff from './icons/Diff.svelte'
import time from '../plugin' import time from '../plugin'
import tags, { TagElement } from '@hcengineering/tags'
import tracker, { Project } from '@hcengineering/tracker'
import view from '@hcengineering/view-resources/src/plugin'
export let mode: ToDosMode export let mode: ToDosMode
export let tag: Ref<TagElement> | undefined export let tag: Ref<TagElement> | undefined
@ -29,7 +47,6 @@
const doneQuery = createQuery() const doneQuery = createQuery()
const inboxQuery = createQuery() const inboxQuery = createQuery()
const activeQuery = createQuery() const activeQuery = createQuery()
const tagsQuery = createQuery() const tagsQuery = createQuery()
const projectsQuery = createQuery() const projectsQuery = createQuery()
@ -129,7 +146,7 @@
}, },
{ {
limit: 200, limit: 200,
sort: { modifiedOn: SortingOrder.Ascending }, sort: { rank: SortingOrder.Ascending },
lookup: { _id: { workslots: time.class.WorkSlot } } lookup: { _id: { workslots: time.class.WorkSlot } }
} }
) )
@ -147,7 +164,7 @@
}, },
{ {
limit: 200, limit: 200,
sort: { modifiedOn: SortingOrder.Ascending } sort: { rank: SortingOrder.Ascending }
} }
) )
} else { } else {
@ -245,8 +262,6 @@
}) })
} }
} }
todos.sort((a, b) => (a.nearest?.date ?? 0) - (b.nearest?.date ?? 0))
scheduled.sort((a, b) => (a.nearest?.date ?? 0) - (b.nearest?.date ?? 0))
groups.set( groups.set(
time.string.ToDos, time.string.ToDos,
todos.map((p) => p.todo) todos.map((p) => p.todo)
@ -309,8 +324,6 @@
{mode} {mode}
{projects} {projects}
{largeSize} {largeSize}
on:dragstart
on:dragend
/> />
{/each} {/each}
</Scroller> </Scroller>
@ -322,7 +335,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
// height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }

View File

@ -0,0 +1,23 @@
import type { IntlString } from '@hcengineering/platform'
import type { ToDo } from '@hcengineering/time'
import { writable } from 'svelte/store'
interface ToDoDragging {
item: ToDo | null
itemIndex: number | null
groupName: IntlString | null
projectId: string | false | null
overItemIndex: number | null
overGroupName: IntlString | null
overProjectId: string | false | null
}
export const dragging = writable<ToDoDragging>({
item: null,
itemIndex: null,
groupName: null,
projectId: null,
overItemIndex: null,
overGroupName: null,
overProjectId: null
})

View File

@ -37,6 +37,7 @@
"@hcengineering/calendar": "^0.6.17", "@hcengineering/calendar": "^0.6.17",
"@hcengineering/task": "^0.6.13", "@hcengineering/task": "^0.6.13",
"@hcengineering/platform": "^0.6.9", "@hcengineering/platform": "^0.6.9",
"@hcengineering/ui": "^0.6.11" "@hcengineering/ui": "^0.6.11",
"@hcengineering/rank": "^0.6.0"
} }
} }

View File

@ -19,6 +19,7 @@ import { IntlString, plugin } from '@hcengineering/platform'
import { Event, Visibility } from '@hcengineering/calendar' import { Event, Visibility } from '@hcengineering/calendar'
import { AnyComponent } from '@hcengineering/ui' import { AnyComponent } from '@hcengineering/ui'
import { Person } from '@hcengineering/contact' import { Person } from '@hcengineering/contact'
import type { Rank } from '@hcengineering/rank'
/** /**
* @public * @public
@ -49,6 +50,7 @@ export interface ToDo extends AttachedDoc {
user: Ref<Person> user: Ref<Person>
attachedSpace?: Ref<Space> attachedSpace?: Ref<Space>
labels?: number labels?: number
rank: Rank
} }
/** /**

View File

@ -21,6 +21,7 @@ import core, {
Doc, Doc,
DocumentUpdate, DocumentUpdate,
Ref, Ref,
SortingOrder,
Status, Status,
Tx, Tx,
TxCUD, TxCUD,
@ -38,7 +39,7 @@ import {
getNotificationContent, getNotificationContent,
isShouldNotifyTx isShouldNotifyTx
} from '@hcengineering/server-notification-resources' } from '@hcengineering/server-notification-resources'
import task from '@hcengineering/task' import task, { makeRank } from '@hcengineering/task'
import tracker, { Issue, IssueStatus, Project, TimeSpendReport } from '@hcengineering/tracker' import tracker, { Issue, IssueStatus, Project, TimeSpendReport } from '@hcengineering/tracker'
import serverTime, { OnToDo, ToDoFactory } from '@hcengineering/server-time' import serverTime, { OnToDo, ToDoFactory } from '@hcengineering/server-time'
import time, { ProjectToDo, ToDo, ToDoPriority, TodoAutomationHelper, WorkSlot } from '@hcengineering/time' import time, { ProjectToDo, ToDo, ToDoPriority, TodoAutomationHelper, WorkSlot } from '@hcengineering/time'
@ -444,6 +445,20 @@ async function getIssueToDoData (
): Promise<AttachedData<ProjectToDo> | undefined> { ): Promise<AttachedData<ProjectToDo> | undefined> {
const acc = await getPersonAccount(user, control) const acc = await getPersonAccount(user, control)
if (acc === undefined) return if (acc === undefined) return
const firstTodoItem = (
await control.findAll(
time.class.ToDo,
{
user: acc.person,
doneOn: null
},
{
limit: 1,
sort: { rank: SortingOrder.Ascending }
}
)
)[0]
const rank = makeRank(undefined, firstTodoItem?.rank)
const data: AttachedData<ProjectToDo> = { const data: AttachedData<ProjectToDo> = {
attachedSpace: issue.space, attachedSpace: issue.space,
workslots: 0, workslots: 0,
@ -451,7 +466,8 @@ async function getIssueToDoData (
priority: ToDoPriority.NoPriority, priority: ToDoPriority.NoPriority,
visibility: 'public', visibility: 'public',
title: issue.title, title: issue.title,
user: acc.person user: acc.person,
rank
} }
return data return data
} }