Sortable list (#2618)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2023-02-10 22:45:14 +06:00 committed by GitHub
parent df7667289d
commit f7e495b609
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 420 additions and 415 deletions

View File

@ -234,10 +234,10 @@ export function createModel (builder: Builder): void {
attachTo: lead.class.Lead, attachTo: lead.class.Lead,
descriptor: view.viewlet.List, descriptor: view.viewlet.List,
config: [ config: [
{ key: '', props: { fixed: 'left' } }, { key: '', props: { listProps: { fixed: 'left' } } },
{ key: 'title', props: { fixed: 'left' } }, { key: 'title', props: { listProps: { fixed: 'left' } } },
{ key: 'state', props: { fixed: 'left' } }, { key: 'state', props: { listProps: { fixed: 'left' } } },
{ key: 'doneState', props: { fixed: 'left' } }, { key: 'doneState', props: { listProps: { fixed: 'left' } } },
{ key: '', presenter: view.component.GrowPresenter }, { key: '', presenter: view.component.GrowPresenter },
'attachments', 'attachments',
'comments', 'comments',

View File

@ -540,7 +540,7 @@ export function createModel (builder: Builder): void {
presenter: tracker.component.PriorityEditor, presenter: tracker.component.PriorityEditor,
props: { type: 'priority', kind: 'list', size: 'small' } props: { type: 'priority', kind: 'list', size: 'small' }
}, },
{ key: '', presenter: tracker.component.IssuePresenter, props: { type: 'issue', fixed: 'left' } }, { key: '', presenter: tracker.component.IssuePresenter, props: { type: 'issue', listProps: { fixed: 'left' } } },
{ {
key: '', key: '',
presenter: tracker.component.StatusEditor, presenter: tracker.component.StatusEditor,
@ -558,8 +558,10 @@ export function createModel (builder: Builder): void {
size: 'small', size: 'small',
shape: 'round', shape: 'round',
shouldShowPlaceholder: false, shouldShowPlaceholder: false,
excludeByKey: 'project', listProps: {
optional: true excludeByKey: 'project',
optional: true
}
} }
}, },
{ {
@ -570,24 +572,26 @@ export function createModel (builder: Builder): void {
size: 'small', size: 'small',
shape: 'round', shape: 'round',
shouldShowPlaceholder: false, shouldShowPlaceholder: false,
excludeByKey: 'sprint', listProps: {
optional: true excludeByKey: 'sprint',
optional: true
}
} }
}, },
{ {
key: '', key: '',
presenter: tracker.component.EstimationEditor, presenter: tracker.component.EstimationEditor,
props: { kind: 'list', size: 'small', optional: true } props: { kind: 'list', size: 'small', listProps: { optional: true } }
}, },
{ {
key: 'modifiedOn', key: 'modifiedOn',
presenter: tracker.component.ModificationDatePresenter, presenter: tracker.component.ModificationDatePresenter,
props: { fixed: 'right', optional: true } props: { listProps: { fixed: 'right', optional: true } }
}, },
{ {
key: '$lookup.assignee', key: '$lookup.assignee',
presenter: tracker.component.AssigneePresenter, presenter: tracker.component.AssigneePresenter,
props: { issueClass: tracker.class.Issue, defaultClass: contact.class.Employee, shouldShowLabel: false } props: { defaultClass: contact.class.Employee, shouldShowLabel: false }
} }
] ]
}) })
@ -595,11 +599,11 @@ export function createModel (builder: Builder): void {
const subIssuesOptions: ViewOptionsModel = { const subIssuesOptions: ViewOptionsModel = {
groupBy: ['status', 'assignee', 'priority', 'sprint'], groupBy: ['status', 'assignee', 'priority', 'sprint'],
orderBy: [ orderBy: [
['rank', SortingOrder.Ascending],
['status', SortingOrder.Ascending], ['status', SortingOrder.Ascending],
['priority', SortingOrder.Ascending], ['priority', SortingOrder.Ascending],
['modifiedOn', SortingOrder.Descending], ['modifiedOn', SortingOrder.Descending],
['dueDate', SortingOrder.Descending], ['dueDate', SortingOrder.Descending]
['rank', SortingOrder.Ascending]
], ],
groupDepth: 1, groupDepth: 1,
other: [] other: []
@ -619,7 +623,11 @@ export function createModel (builder: Builder): void {
presenter: tracker.component.PriorityEditor, presenter: tracker.component.PriorityEditor,
props: { type: 'priority', kind: 'list', size: 'small' } props: { type: 'priority', kind: 'list', size: 'small' }
}, },
{ key: '', presenter: tracker.component.IssuePresenter, props: { type: 'issue', fixed: 'left' } }, {
key: '',
presenter: tracker.component.IssuePresenter,
props: { type: 'issue', listProps: { fixed: 'left' } }
},
{ {
key: '', key: '',
presenter: tracker.component.StatusEditor, presenter: tracker.component.StatusEditor,
@ -637,24 +645,26 @@ export function createModel (builder: Builder): void {
size: 'small', size: 'small',
shape: 'round', shape: 'round',
shouldShowPlaceholder: false, shouldShowPlaceholder: false,
excludeByKey: 'sprint', listProps: {
optional: true excludeByKey: 'sprint',
optional: true
}
} }
}, },
{ {
key: '', key: '',
presenter: tracker.component.EstimationEditor, presenter: tracker.component.EstimationEditor,
props: { kind: 'list', size: 'small', optional: true } props: { kind: 'list', size: 'small', listProps: { optional: true } }
}, },
{ {
key: 'modifiedOn', key: 'modifiedOn',
presenter: tracker.component.ModificationDatePresenter, presenter: tracker.component.ModificationDatePresenter,
props: { fixed: 'right', optional: true } props: { listProps: { fixed: 'right', optional: true } }
}, },
{ {
key: '$lookup.assignee', key: '$lookup.assignee',
presenter: tracker.component.AssigneePresenter, presenter: tracker.component.AssigneePresenter,
props: { issueClass: tracker.class.Issue, defaultClass: contact.class.Employee, shouldShowLabel: false } props: { defaultClass: contact.class.Employee, shouldShowLabel: false }
} }
] ]
}, },
@ -690,11 +700,15 @@ export function createModel (builder: Builder): void {
props: { kind: 'list', size: 'small', shape: 'round', shouldShowPlaceholder: false } props: { kind: 'list', size: 'small', shape: 'round', shouldShowPlaceholder: false }
}, },
{ key: '', presenter: tracker.component.TemplateEstimationEditor, props: { kind: 'list', size: 'small' } }, { key: '', presenter: tracker.component.TemplateEstimationEditor, props: { kind: 'list', size: 'small' } },
{ key: 'modifiedOn', presenter: tracker.component.ModificationDatePresenter, props: { fixed: 'right' } }, {
key: 'modifiedOn',
presenter: tracker.component.ModificationDatePresenter,
props: { listProps: { fixed: 'right' } }
},
{ {
key: '$lookup.assignee', key: '$lookup.assignee',
presenter: tracker.component.AssigneePresenter, presenter: tracker.component.AssigneePresenter,
props: { issueClass: tracker.class.IssueTemplate, defaultClass: contact.class.Employee, shouldShowLabel: false } props: { defaultClass: contact.class.Employee, shouldShowLabel: false }
} }
] ]
}) })

View File

@ -128,11 +128,8 @@
} }
} }
function cardDragOver (evt: CardDragEvent, object: ExtItem): void { function cardDragOver (evt: CardDragEvent, object: ExtItem): void {
if (dragCard !== undefined) { if (dragCard !== undefined && !dontUpdateRank) {
;(dragCard as any)[fieldName] = (dragCard as any)[fieldName] dragCard.rank = doCalcRank(object, evt)
if (!dontUpdateRank) {
dragCard.rank = doCalcRank(object, evt)
}
} }
} }
function cardDrop (evt: CardDragEvent, object: ExtItem): void { function cardDrop (evt: CardDragEvent, object: ExtItem): void {

View File

@ -35,11 +35,13 @@
{/if} {/if}
{:then Ctor} {:then Ctor}
<ErrorBoundary> <ErrorBoundary>
<Ctor {...props} on:change on:close on:open on:click on:delete on:action> {#if $$slots.default !== undefined}
{#if $$slots.default} <Ctor {...props} on:change on:close on:open on:click on:delete on:action>
<slot /> <slot />
{/if} </Ctor>
</Ctor> {:else}
<Ctor {...props} on:change on:close on:open on:click on:delete on:action />
{/if}
</ErrorBoundary> </ErrorBoundary>
{:catch err} {:catch err}
<pre style="max-height: 140px; overflow: auto;"> <pre style="max-height: 140px; overflow: auto;">

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { DocumentQuery, Ref, Space, WithLookup } from '@hcengineering/core' import { DocumentQuery, Ref, Space, WithLookup } from '@hcengineering/core'
import { Issue, IssueStatus, Team } from '@hcengineering/tracker' import { Issue } from '@hcengineering/tracker'
import { Component } from '@hcengineering/ui' import { Component } from '@hcengineering/ui'
import { Viewlet, ViewOptions } from '@hcengineering/view' import { Viewlet, ViewOptions } from '@hcengineering/view'
import tracker from '../../plugin' import tracker from '../../plugin'
@ -10,9 +10,6 @@
export let query: DocumentQuery<Issue> = {} export let query: DocumentQuery<Issue> = {}
export let space: Ref<Space> | undefined export let space: Ref<Space> | undefined
// Extra properties
export let teams: Map<Ref<Team>, Team> | undefined
export let issueStatuses: Map<Ref<Team>, WithLookup<IssueStatus>[]>
export let viewOptions: ViewOptions export let viewOptions: ViewOptions
const createItemDialog = CreateIssue const createItemDialog = CreateIssue
@ -32,8 +29,7 @@
viewOptions, viewOptions,
viewOptionsConfig: viewlet.viewOptions?.other, viewOptionsConfig: viewlet.viewOptions?.other,
space, space,
query, query
props: { teams, issueStatuses }
}} }}
/> />
{/if} {/if}

View File

@ -1,232 +0,0 @@
<!--
// 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 { createEventDispatcher } from 'svelte'
import { getObjectValue, WithLookup } from '@hcengineering/core'
import { Issue, IssueStatus, Team } from '@hcengineering/tracker'
import notification from '@hcengineering/notification'
import { tooltip, CheckBox, Component, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import tracker from '../../plugin'
import { IssuesGroupByKeys } from '../../utils'
import { FixedColumn } from '@hcengineering/view-resources'
import Circles from '../icons/Circles.svelte'
export let use: HTMLElement
export let docObject: Issue
export let model: AttributeModel[]
export let groupByKey: IssuesGroupByKeys | undefined
export let checked: boolean
export let selected: boolean
export let statuses: WithLookup<IssueStatus>[]
export let currentTeam: Team | undefined
const dispatch = createEventDispatcher()
$: compactMode = $deviceInfo.twoRows
</script>
<div
bind:this={use}
class="listGrid antiList__row row gap-2 flex-grow"
class:checking={checked}
class:mListGridFixed={selected}
class:mListGridSelected={selected}
on:contextmenu
on:focus
on:mouseover
>
<div class="flex-center relative" use:tooltip={{ label: tracker.string.SelectIssue, direction: 'bottom' }}>
<div class="antiList-cells__notifyCell">
<div class="antiList-cells__checkCell">
<CheckBox
{checked}
on:value={(event) => {
dispatch('check', { docs: [docObject], value: event.detail })
}}
/>
</div>
<Component
is={notification.component.NotificationPresenter}
showLoading={false}
props={{ value: docObject, kind: 'table' }}
/>
</div>
</div>
{#each model as attributeModel}
{#if attributeModel.props?.type === 'grow'}
<svelte:component this={attributeModel.presenter} />
{:else if (!groupByKey || attributeModel.props?.excludeByKey !== groupByKey) && !(attributeModel.props?.optional && compactMode)}
{#if attributeModel.props?.fixed}
<FixedColumn key={`issue_${attributeModel.key}`} justify={attributeModel.props.fixed}>
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
groupBy={groupByKey}
{...attributeModel.props}
{statuses}
{currentTeam}
/>
</FixedColumn>
{:else}
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
issueId={docObject._id}
groupBy={groupByKey}
{...attributeModel.props}
{statuses}
{currentTeam}
/>
{/if}
{/if}
{/each}
{#if compactMode}
<div class="panel-trigger" tabindex="-1">
<Circles />
<div class="space" />
<Circles />
</div>
<div class="hidden-panel" tabindex="-1">
<div class="header">
<Circles />
<div class="space" />
<Circles />
</div>
<div class="scroll-box gap-2">
{#each model as attributeModel}
{@const value = getObjectValue(attributeModel.key, docObject)}
{#if attributeModel.props?.optional && attributeModel.props?.excludeByKey !== groupByKey && value !== undefined}
<svelte:component
this={attributeModel.presenter}
value={value ?? ''}
issueId={docObject._id}
groupBy={groupByKey}
{...attributeModel.props}
{statuses}
{currentTeam}
/>
{/if}
{/each}
</div>
</div>
{/if}
</div>
<style lang="scss">
.row:not(:last-child) {
border-bottom: 1px solid var(--accent-bg-color);
}
.listGrid {
position: relative;
display: flex;
align-items: center;
padding: 0 0.75rem 0 0.875rem;
width: 100%;
height: 2.75rem;
min-height: 2.75rem;
color: var(--theme-caption-color);
&.checking {
background-color: var(--highlight-select);
border-bottom-color: var(--highlight-select);
&:hover {
background-color: var(--highlight-select-hover);
border-bottom-color: var(--highlight-select-hover);
}
}
&.mListGridSelected {
background-color: var(--highlight-hover);
}
.hidden-panel,
.panel-trigger {
position: absolute;
display: flex;
align-items: center;
top: 0;
bottom: 0;
height: 100%;
}
.hidden-panel {
overflow: hidden;
right: 0;
width: 80%;
background-color: var(--accent-bg-color);
opacity: 0;
pointer-events: none;
z-index: 2;
transition-property: opacity, width;
transition-duration: 0.15s;
transition-timing-function: var(--timing-main);
.header {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0 0.25rem;
width: 0.375rem;
min-width: 0.375rem;
height: 100%;
opacity: 0.25;
}
.scroll-box {
overflow: auto visible;
display: flex;
align-items: center;
margin: 0.125rem 0.25rem 0;
padding: 0.25rem 0.25rem;
min-width: 0;
&::-webkit-scrollbar:horizontal {
height: 3px;
}
}
}
.panel-trigger {
flex-direction: column;
justify-content: center;
padding: 0 0.125rem;
right: 2.5rem;
width: 0.75rem;
border: 1px solid transparent;
border-radius: 0.25rem;
opacity: 0.1;
z-index: 1;
transition: opacity 0.15s var(--timing-main);
&:focus {
border-color: var(--primary-edit-border-color);
opacity: 0.25;
}
& > * {
pointer-events: none;
}
}
.hidden-panel:focus-within,
.hidden-panel:focus,
.panel-trigger:focus + .hidden-panel {
width: 100%;
opacity: 1;
pointer-events: all;
}
.space {
min-height: 0.1075rem;
}
}
</style>

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { DocumentQuery, Ref, SortingOrder, Space, toIdMap, WithLookup } from '@hcengineering/core' import { DocumentQuery, Ref, Space, WithLookup } from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform' import { IntlString, translate } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { Issue, IssueStatus, Team } from '@hcengineering/tracker' import { Issue } from '@hcengineering/tracker'
import { Button, IconDetails, IconDetailsFilled } from '@hcengineering/ui' import { Button, IconDetails, IconDetailsFilled } from '@hcengineering/ui'
import view, { Viewlet } from '@hcengineering/view' import view, { Viewlet } from '@hcengineering/view'
import { FilterBar, getActiveViewletId, getViewOptions, setActiveViewletId } from '@hcengineering/view-resources' import { FilterBar, getActiveViewletId, getViewOptions, setActiveViewletId } from '@hcengineering/view-resources'
@ -65,33 +65,6 @@
$: if (docWidth <= 900 && !docSize) docSize = true $: if (docWidth <= 900 && !docSize) docSize = true
$: if (docWidth > 900 && docSize) docSize = false $: if (docWidth > 900 && docSize) docSize = false
const teamQuery = createQuery()
let _teams: Map<Ref<Team>, Team> | undefined = undefined
$: teamQuery.query(tracker.class.Team, {}, (result) => {
_teams = toIdMap(result)
})
let issueStatuses: Map<Ref<Team>, WithLookup<IssueStatus>[]>
const statusesQuery = createQuery()
statusesQuery.query(
tracker.class.IssueStatus,
{},
(statuses) => {
const st = new Map<Ref<Team>, WithLookup<IssueStatus>[]>()
for (const s of statuses) {
const id = s.attachedTo as Ref<Team>
st.set(id, [...(st.get(id) ?? []), s])
}
issueStatuses = st
},
{
lookup: { category: tracker.class.IssueStatusCategory },
sort: { rank: SortingOrder.Ascending }
}
)
$: viewOptions = getViewOptions(viewlet) $: viewOptions = getViewOptions(viewlet)
</script> </script>
@ -120,8 +93,8 @@
<slot name="afterHeader" /> <slot name="afterHeader" />
<FilterBar _class={tracker.class.Issue} query={searchQuery} {viewOptions} on:change={(e) => (resultQuery = e.detail)} /> <FilterBar _class={tracker.class.Issue} query={searchQuery} {viewOptions} on:change={(e) => (resultQuery = e.detail)} />
<div class="flex w-full h-full clear-mins"> <div class="flex w-full h-full clear-mins">
{#if viewlet && _teams && issueStatuses} {#if viewlet}
<IssuesContent {viewlet} query={resultQuery} {space} teams={_teams} {issueStatuses} {viewOptions} /> <IssuesContent {viewlet} query={resultQuery} {space} {viewOptions} />
{/if} {/if}
{#if $$slots.aside !== undefined && asideShown} {#if $$slots.aside !== undefined && asideShown}
<div class="popupPanel-body__aside flex" class:float={asideFloat} class:shown={asideShown}> <div class="popupPanel-body__aside flex" class:float={asideFloat} class:shown={asideShown}>

View File

@ -32,7 +32,7 @@
import { subIssueListProvider } from '../../../utils' import { subIssueListProvider } from '../../../utils'
export let value: WithLookup<Issue> export let value: WithLookup<Issue>
export let currentTeam: Team | undefined export let currentTeam: Team | undefined = undefined
export let kind: ButtonKind = 'link-bordered' export let kind: ButtonKind = 'link-bordered'
export let size: ButtonSize = 'inline' export let size: ButtonSize = 'inline'
@ -41,9 +41,23 @@
let btn: HTMLElement let btn: HTMLElement
$: team = currentTeam
let subIssues: Issue[] = [] let subIssues: Issue[] = []
let countComplate: number = 0 let countComplate: number = 0
const teamQuery = createQuery()
$: if (currentTeam === undefined) {
teamQuery.query(
tracker.class.Team,
{
_id: value.space
},
(res) => ([team] = res)
)
} else {
teamQuery.unsubscribe()
}
const query = createQuery() const query = createQuery()
const statusesQuery = createQuery() const statusesQuery = createQuery()
@ -103,7 +117,7 @@
SelectPopup, SelectPopup,
{ {
value: subIssues.map((iss) => { value: subIssues.map((iss) => {
const text = currentTeam ? `${getIssueId(currentTeam, iss)} ${iss.title}` : iss.title const text = team ? `${getIssueId(team, iss)} ${iss.title}` : iss.title
return { id: iss._id, text, isSelected: iss._id === value._id, ...getIssueStatusIcon(iss, statuses) } return { id: iss._id, text, isSelected: iss._id === value._id, ...getIssueStatusIcon(iss, statuses) }
}), }),

View File

@ -31,7 +31,7 @@
export let size: ButtonSize = 'large' export let size: ButtonSize = 'large'
export let justify: 'left' | 'center' = 'left' export let justify: 'left' | 'center' = 'left'
export let width: string | undefined = undefined export let width: string | undefined = undefined
export let currentTeam: Team | undefined export let currentTeam: Team | undefined = undefined
const client = getClient() const client = getClient()
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -175,7 +175,9 @@
size: 'small', size: 'small',
shape: 'round', shape: 'round',
shouldShowPlaceholder: false, shouldShowPlaceholder: false,
excludeByKey: 'sprint', listProps: {
excludeByKey: 'sprint'
},
isEditable: false isEditable: false
} }
}, },

View File

@ -13,6 +13,11 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: grops = viewOptions.groupBy =
viewOptions.groupBy[viewOptions.groupBy.length - 1] === noCategory
? viewOptions.groupBy
: [...viewOptions.groupBy, noCategory]
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
const lookup = buildConfigLookup(hierarchy, viewlet.attachTo, viewlet.config, viewlet.options?.lookup) const lookup = buildConfigLookup(hierarchy, viewlet.attachTo, viewlet.config, viewlet.options?.lookup)
@ -57,7 +62,7 @@
<div class="antiCard"> <div class="antiCard">
<div class="antiCard-group grid"> <div class="antiCard-group grid">
{#each viewOptions.groupBy as group, i} {#each grops as group, i}
<span class="label"><Label label={i === 0 ? view.string.Grouping : view.string.Then} /></span> <span class="label"><Label label={i === 0 ? view.string.Grouping : view.string.Then} /></span>
<div class="value grouping"> <div class="value grouping">
<DropdownLabelsIntl <DropdownLabelsIntl

View File

@ -129,9 +129,13 @@
return props.length return props.length
} }
let dragItem: Doc | undefined = undefined
let listDiv: HTMLDivElement
</script> </script>
<div class="list-container"> <div class="list-container" bind:this={listDiv}>
<ListCategories <ListCategories
newObjectProps={space ? { space } : {}} newObjectProps={space ? { space } : {}}
{elementByIndex} {elementByIndex}
@ -157,6 +161,8 @@
{flatHeaders} {flatHeaders}
{disableHeader} {disableHeader}
{props} {props}
{listDiv}
bind:dragItem
/> />
</div> </div>

View File

@ -18,7 +18,7 @@
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { AnyComponent } from '@hcengineering/ui' import { AnyComponent } from '@hcengineering/ui'
import { AttributeModel, BuildModelKey, CategoryOption, ViewOptionModel, ViewOptions } from '@hcengineering/view' import { AttributeModel, BuildModelKey, CategoryOption, ViewOptionModel, ViewOptions } from '@hcengineering/view'
import { onDestroy } from 'svelte' import { createEventDispatcher, onDestroy } from 'svelte'
import { buildModel, getAdditionalHeader, getCategories, getPresenter, groupBy } from '../../utils' import { buildModel, getAdditionalHeader, getCategories, getPresenter, groupBy } from '../../utils'
import { CategoryQuery, noCategory } from '../../viewOptions' import { CategoryQuery, noCategory } from '../../viewOptions'
import ListCategory from './ListCategory.svelte' import ListCategory from './ListCategory.svelte'
@ -44,6 +44,8 @@
export let newObjectProps: Record<string, any> export let newObjectProps: Record<string, any>
export let docByIndex: Map<number, Doc> export let docByIndex: Map<number, Doc>
export let viewOptionsConfig: ViewOptionModel[] | undefined export let viewOptionsConfig: ViewOptionModel[] | undefined
export let dragItem: Doc | undefined
export let listDiv: HTMLDivElement
$: groupByKey = viewOptions.groupBy[level] ?? noCategory $: groupByKey = viewOptions.groupBy[level] ?? noCategory
$: groupedDocs = groupBy(docs, groupByKey) $: groupedDocs = groupBy(docs, groupByKey)
@ -110,12 +112,14 @@
let res = initIndex let res = initIndex
for (let index = 0; index < i; index++) { for (let index = 0; index < i; index++) {
const cat = categories[index] const cat = categories[index]
res += groupedDocs[cat]?.length res += groupedDocs[cat]?.length ?? 0
} }
return res return res
} }
$: extraHeaders = getAdditionalHeader(client, _class) $: extraHeaders = getAdditionalHeader(client, _class)
const dispatch = createEventDispatcher()
</script> </script>
{#each categories as category, i} {#each categories as category, i}
@ -133,6 +137,7 @@
{level} {level}
{viewOptions} {viewOptions}
{groupByKey} {groupByKey}
{lookup}
{config} {config}
{docByIndex} {docByIndex}
{itemModels} {itemModels}
@ -147,9 +152,17 @@
on:check on:check
on:uncheckAll on:uncheckAll
on:row-focus on:row-focus
on:dragstart={(e) => {
dispatch('dragstart', {
target: e.detail.target,
index: e.detail.index + getInitIndex(categories, i)
})
}}
{flatHeaders} {flatHeaders}
{disableHeader} {disableHeader}
{props} {props}
{listDiv}
bind:dragItem
/> />
{/key} {/key}
{/each} {/each}

View File

@ -13,8 +13,10 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Class, Doc, Lookup, Ref, Space } from '@hcengineering/core' import { Class, Doc, DocumentUpdate, Lookup, Ref, Space } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform' import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { calcRank, DocWithRank } from '@hcengineering/task'
import { import {
AnyComponent, AnyComponent,
CheckBox, CheckBox,
@ -58,28 +60,31 @@
export let newObjectProps: Record<string, any> export let newObjectProps: Record<string, any>
export let docByIndex: Map<number, Doc> export let docByIndex: Map<number, Doc>
export let viewOptionsConfig: ViewOptionModel[] | undefined export let viewOptionsConfig: ViewOptionModel[] | undefined
export let dragItem: Doc | undefined
export let listDiv: HTMLDivElement
$: lastLevel = level + 1 >= viewOptions.groupBy.length $: lastLevel = level + 1 >= viewOptions.groupBy.length
const autoFoldLimit = 20 const autoFoldLimit = 20
const defaultLimit = 20 const defaultLimit = 20
const singleCategoryLimit = 200 const singleCategoryLimit = 200
$: initialLimit = !lastLevel ? items.length : singleCat ? singleCategoryLimit : defaultLimit $: initialLimit = !lastLevel ? undefined : singleCat ? singleCategoryLimit : defaultLimit
$: limit = initialLimit $: limit = initialLimit
let collapsed = true let collapsed = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
function limitGroup (items: Doc[], limit: number): Doc[] { function limitGroup (items: Doc[], limit: number | undefined): Doc[] {
return items.slice(0, limit) const res = limit !== undefined ? items.slice(0, limit) : items
return res
} }
function initCollapsed (singleCat: boolean, lastLevel: boolean, category: any): void { function initCollapsed (singleCat: boolean, lastLevel: boolean): void {
collapsed = !disableHeader && !singleCat && items.length > (lastLevel ? autoFoldLimit : singleCategoryLimit) collapsed = !disableHeader && !singleCat && items.length > (lastLevel ? autoFoldLimit : singleCategoryLimit)
} }
$: initCollapsed(singleCat, lastLevel, category) $: initCollapsed(singleCat, lastLevel)
const handleRowFocused = (object: Doc) => { const handleRowFocused = (object: Doc) => {
dispatch('row-focus', object) dispatch('row-focus', object)
@ -108,96 +113,272 @@
function isSelected (doc: Doc, focusStore: FocusSelection): boolean { function isSelected (doc: Doc, focusStore: FocusSelection): boolean {
return focusStore.focus?._id === doc._id return focusStore.focus?._id === doc._id
} }
$: byRank = viewOptions.orderBy[0] === 'rank'
const client = getClient()
let dragItemIndex: number | undefined
function dragswap (ev: MouseEvent, i: number): boolean {
if (dragItemIndex === undefined || !byRank) return false
const s = dragItemIndex
if (i < s) {
return ev.offsetY < (ev.target as HTMLElement).offsetHeight / 2
} else if (i > s) {
return ev.offsetY > (ev.target as HTMLElement).offsetHeight / 2
}
return false
}
function dragOverCat (ev: MouseEvent) {
ev.preventDefault()
ev.stopPropagation()
}
let div: HTMLDivElement
function isBorder (ev: MouseEvent, direction: 'top' | 'bottom'): boolean {
const target = ev.target as HTMLDivElement
return Math.abs(ev.clientY - target.getBoundingClientRect()[direction]) < 5
}
function dragEnterCat (ev: MouseEvent) {
ev.preventDefault()
if (dragItemIndex === undefined && dragItem !== undefined) {
const index = items.findIndex((p) => p._id === dragItem?._id)
if (index !== -1) {
dragItemIndex = index
return
}
if (isBorder(ev, 'top')) {
items.unshift(dragItem)
dragItemIndex = 0
items = items
dispatch('row-focus', dragItem)
} else if (isBorder(ev, 'bottom')) {
items.push(dragItem)
dragItemIndex = items.length - 1
items = items
dispatch('row-focus', dragItem)
}
}
}
function dragLeaveCat (ev: MouseEvent) {
ev.stopPropagation()
if (dragItemIndex !== undefined) {
items.splice(dragItemIndex, 1)
items = items
dragItemIndex = undefined
}
}
function dragItemLeave (ev: MouseEvent, i: number) {
if (dragItemIndex !== undefined) {
const isLastItem = i === limited.length - 1
const isFirstItemWithoutHeader = i === 0 && disableHeader
if (isFirstItemWithoutHeader && isBorder(ev, 'top')) {
return
}
if (isLastItem && isBorder(ev, 'bottom')) {
return
}
ev.stopPropagation()
ev.preventDefault()
}
}
function dragover (ev: MouseEvent, i: number) {
if (dragItemIndex === undefined || !lastLevel) return
ev.preventDefault()
ev.stopPropagation()
const s = dragItemIndex
if (dragswap(ev, i) && items[i] !== undefined && items[s] !== undefined) {
;[items[i], items[s]] = [items[s], items[i]]
items = items
dragItemIndex = i
dispatch('row-focus', dragItem)
}
}
function dropItemHandle (ev: MouseEvent) {
ev.stopPropagation()
ev.preventDefault()
const update: DocumentUpdate<Doc> = {}
if (dragItemIndex !== undefined) {
const prev = limited[dragItemIndex - 1] as DocWithRank
const next = limited[dragItemIndex + 1] as DocWithRank
try {
const newRank = calcRank(prev, next)
if ((dragItem as DocWithRank)?.rank !== newRank) {
;(update as any).rank = newRank
}
} catch {}
}
drop(update)
}
async function drop (update: DocumentUpdate<Doc> = {}) {
if (dragItem !== undefined) {
for (const key in newObjectProps) {
const value = newObjectProps[key]
if ((dragItem as any)[key] !== value) {
;(update as any)[key] = value
}
}
if (Object.keys(update).length > 0) {
await client.update(dragItem, update)
}
}
dragItem = undefined
dragItemIndex = undefined
}
const dragEndListener: any = (ev: DragEvent, initIndex: number) => {
ev.preventDefault()
const rect = listDiv.getBoundingClientRect()
const inRect = ev.clientY > rect.top && ev.clientY < rect.top + rect.height
if (!inRect) {
if (items.findIndex((p) => p._id === dragItem?._id) === -1 && dragItem !== undefined) {
items = [...items.slice(0, initIndex), dragItem, ...items.slice(initIndex)]
}
if (level === 0) {
dragItem = undefined
}
}
}
function dragStartHandler (e: CustomEvent<any>) {
const { target, index } = e.detail
dragItemIndex = index
;(target as EventTarget).addEventListener('dragend', (e) => dragEndListener(e, index))
}
function dragStart (ev: DragEvent, docObject: Doc, i: number) {
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move'
ev.dataTransfer.dropEffect = 'move'
}
ev.target?.addEventListener('dragend', (e) => dragEndListener(e, i))
dragItem = docObject
dragItemIndex = i
dispatch('dragstart', {
target: ev.target,
index: i
})
}
</script> </script>
{#if !disableHeader} <div
<ListHeader bind:this={div}
{groupByKey} on:drop|preventDefault={drop}
{category} on:dragover={dragOverCat}
{space} on:dragenter={dragEnterCat}
{level} on:dragleave={dragLeaveCat}
limited={limited.length} >
{items} {#if !disableHeader}
{headerComponent} <ListHeader
{createItemDialog} {groupByKey}
{createItemLabel} {category}
{extraHeaders} {space}
{newObjectProps} {level}
flat={flatHeaders} limited={limited.length}
{props} {items}
on:more={() => { {headerComponent}
limit += 20 {createItemDialog}
}} {createItemLabel}
on:collapse={() => { {extraHeaders}
collapsed = !collapsed {newObjectProps}
}} flat={flatHeaders}
/> {props}
{/if} on:more={() => {
<ExpandCollapse isExpanded={!collapsed} duration={400}> if (limit !== undefined) limit += 20
{#if !lastLevel} }}
<div class="p-2"> on:collapse={() => {
<ListCategories collapsed = !collapsed
{elementByIndex} }}
{indexById} />
docs={items} {/if}
{_class} <ExpandCollapse isExpanded={!collapsed || dragItemIndex !== undefined} duration={400}>
{space} {#if !lastLevel}
{lookup} <div class="p-2">
{loadingPropsLength} <ListCategories
{baseMenuClass}
{config}
{selectedObjectIds}
{createItemDialog}
{createItemLabel}
{viewOptions}
{newObjectProps}
{flatHeaders}
{props}
level={level + 1}
{initIndex}
{docByIndex}
{viewOptionsConfig}
on:check
on:uncheckAll
on:row-focus
/>
</div>
{:else if itemModels}
{#if limited}
{#each limited as docObject, i (docObject._id)}
<ListItem
{docObject}
{elementByIndex} {elementByIndex}
{docByIndex}
{indexById} {indexById}
model={itemModels} docs={items}
index={initIndex + i} {_class}
{groupByKey} {space}
selected={isSelected(docObject, $focusStore)} {lookup}
checked={selectedObjectIdsSet.has(docObject._id)} {loadingPropsLength}
on:check={(ev) => dispatch('check', { docs: ev.detail.docs, value: ev.detail.value })} {baseMenuClass}
on:contextmenu={(event) => handleMenuOpened(event, docObject, initIndex + i)} {config}
on:focus={() => {}} {selectedObjectIds}
on:mouseover={() => handleRowFocused(docObject)} {createItemDialog}
{createItemLabel}
{viewOptions}
{newObjectProps}
{flatHeaders}
{props} {props}
level={level + 1}
{initIndex}
{docByIndex}
{viewOptionsConfig}
{listDiv}
bind:dragItem
on:check
on:uncheckAll
on:row-focus
on:dragstart={dragStartHandler}
/> />
{/each} </div>
{/if} {:else if itemModels}
{:else if loadingPropsLength !== undefined} {#if limited}
{#each Array(Math.max(loadingPropsLength, limit)) as _, rowIndex} {#each limited as docObject, i (docObject._id)}
<div class="listGrid row"> <ListItem
<div class="flex-center clear-mins h-full"> {docObject}
<div class="gridElement"> {elementByIndex}
<CheckBox checked={false} /> {docByIndex}
<div class="ml-4"> {indexById}
<Spinner size="small" /> model={itemModels}
index={initIndex + i}
{groupByKey}
selected={isSelected(docObject, $focusStore)}
checked={selectedObjectIdsSet.has(docObject._id)}
on:dragstart={(e) => dragStart(e, docObject, i)}
on:dragenter={(e) => {
if (dragItemIndex !== undefined) {
e.stopPropagation()
e.preventDefault()
}
}}
on:dragleave={(e) => dragItemLeave(e, i)}
on:dragover={(e) => dragover(e, i)}
on:drop={dropItemHandle}
on:check={(ev) => dispatch('check', { docs: ev.detail.docs, value: ev.detail.value })}
on:contextmenu={(event) => handleMenuOpened(event, docObject, initIndex + i)}
on:focus={() => {}}
on:mouseover={() => handleRowFocused(docObject)}
{props}
/>
{/each}
{/if}
{:else if loadingPropsLength !== undefined}
{#each Array(Math.max(loadingPropsLength, limit ?? 0)) as _, rowIndex}
<div class="listGrid row">
<div class="flex-center clear-mins h-full">
<div class="gridElement">
<CheckBox checked={false} />
<div class="ml-4">
<Spinner size="small" />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> {/each}
{/each} {/if}
{/if} </ExpandCollapse>
</ExpandCollapse> </div>
<style lang="scss"> <style lang="scss">
.row:not(:last-child) { .row:not(:last-child) {

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { AnyAttribute, Doc, getObjectValue, Ref } from '@hcengineering/core' import core, { AnyAttribute, Doc, getObjectValue, Ref } from '@hcengineering/core'
import notification from '@hcengineering/notification' import notification from '@hcengineering/notification'
import { getClient, updateAttribute } from '@hcengineering/presentation' import { getClient, updateAttribute } from '@hcengineering/presentation'
import { CheckBox, Component, deviceOptionsStore as deviceInfo, tooltip } from '@hcengineering/ui' import { CheckBox, Component, deviceOptionsStore as deviceInfo, tooltip } from '@hcengineering/ui'
@ -65,6 +65,14 @@
if (attribute.isLookup) return if (attribute.isLookup) return
return (value: any) => onChange(value, docObject, attribute.key, attr) return (value: any) => onChange(value, docObject, attribute.key, attr)
} }
function joinProps (attribute: AttributeModel, object: Doc, props: Record<string, any>) {
const { listProps, ...clearAttributeProps } = attribute.props as any
if (attribute.attribute?.type._class === core.class.EnumOf) {
return { ...clearAttributeProps, type: attribute.attribute.type, ...props }
}
return { object, ...clearAttributeProps, ...props }
}
</script> </script>
<div <div
@ -72,10 +80,19 @@
class="listGrid antiList__row row gap-2 flex-grow" class="listGrid antiList__row row gap-2 flex-grow"
class:checking={checked} class:checking={checked}
class:mListGridSelected={selected} class:mListGridSelected={selected}
draggable={true}
on:contextmenu on:contextmenu
on:focus on:focus
on:mouseover on:mouseover
on:dragover
on:dragenter
on:dragleave
on:drop
on:dragstart
> >
<div class="draggable-container">
<div class="draggable-mark"><Circles /></div>
</div>
<div class="flex-center relative" use:tooltip={{ label: view.string.Select, direction: 'bottom' }}> <div class="flex-center relative" use:tooltip={{ label: view.string.Select, direction: 'bottom' }}>
<div class="antiList-cells__notifyCell"> <div class="antiList-cells__notifyCell">
<div class="antiList-cells__checkCell"> <div class="antiList-cells__checkCell">
@ -94,30 +111,27 @@
</div> </div>
</div> </div>
{#each model as attributeModel} {#each model as attributeModel}
{@const listProps = attributeModel.props?.listProps}
{#if attributeModel.props?.type === 'grow'} {#if attributeModel.props?.type === 'grow'}
<svelte:component this={attributeModel.presenter} /> <svelte:component this={attributeModel.presenter} />
{:else if (!groupByKey || attributeModel.props?.excludeByKey !== groupByKey) && !(attributeModel.props?.optional && compactMode)} {:else if (!groupByKey || listProps?.excludeByKey !== groupByKey) && !(listProps?.optional && compactMode)}
{#if attributeModel.props?.fixed} {#if listProps?.fixed}
<FixedColumn key={`list_item_${attributeModel.key}`} justify={attributeModel.props.fixed}> <FixedColumn key={`list_item_${attributeModel.key}`} justify={listProps.fixed}>
<svelte:component <svelte:component
this={attributeModel.presenter} this={attributeModel.presenter}
{...props}
value={getObjectValue(attributeModel.key, docObject) ?? ''} value={getObjectValue(attributeModel.key, docObject) ?? ''}
object={docObject}
onChange={getOnChange(docObject, attributeModel)}
kind={'list'} kind={'list'}
{...attributeModel.props} onChange={getOnChange(docObject, attributeModel)}
{...joinProps(attributeModel, docObject, props)}
/> />
</FixedColumn> </FixedColumn>
{:else} {:else}
<svelte:component <svelte:component
this={attributeModel.presenter} this={attributeModel.presenter}
{...props}
value={getObjectValue(attributeModel.key, docObject) ?? ''} value={getObjectValue(attributeModel.key, docObject) ?? ''}
object={docObject}
onChange={getOnChange(docObject, attributeModel)} onChange={getOnChange(docObject, attributeModel)}
kind={'list'} kind={'list'}
{...attributeModel.props} {...joinProps(attributeModel, docObject, props)}
/> />
{/if} {/if}
{/if} {/if}
@ -136,16 +150,15 @@
</div> </div>
<div class="scroll-box gap-2"> <div class="scroll-box gap-2">
{#each model as attributeModel} {#each model as attributeModel}
{@const listProps = attributeModel.props?.listProps}
{@const value = getObjectValue(attributeModel.key, docObject)} {@const value = getObjectValue(attributeModel.key, docObject)}
{#if attributeModel.props?.optional && attributeModel.props?.excludeByKey !== groupByKey && value !== undefined} {#if listProps?.optional && listProps?.excludeByKey !== groupByKey && value !== undefined}
<svelte:component <svelte:component
this={attributeModel.presenter} this={attributeModel.presenter}
{...props} value={getObjectValue(attributeModel.key, docObject) ?? ''}
value={value ?? ''}
objectId={docObject._id}
onChange={getOnChange(docObject, attributeModel)} onChange={getOnChange(docObject, attributeModel)}
groupBy={groupByKey} kind={'list'}
{...attributeModel.props} {...joinProps(attributeModel, docObject, props)}
/> />
{/if} {/if}
{/each} {/each}
@ -183,6 +196,29 @@
background-color: var(--highlight-hover); background-color: var(--highlight-hover);
} }
.draggable-container {
position: absolute;
left: 0;
display: flex;
align-items: center;
height: 100%;
width: 1.5rem;
cursor: grabbing;
.draggable-mark {
opacity: 0;
width: 0.375rem;
height: 1rem;
margin-left: 0.75rem;
transition: opacity 0.1s;
}
}
&:hover {
.draggable-mark {
opacity: 0.4;
}
}
.hidden-panel, .hidden-panel,
.panel-trigger { .panel-trigger {
position: absolute; position: absolute;

View File

@ -421,8 +421,6 @@ export interface AttributeModel {
// Extra properties for component // Extra properties for component
props?: Record<string, any> props?: Record<string, any>
sortingKey: string | string[] sortingKey: string | string[]
optional?: boolean
excludeByKey?: string
// Extra icon if applicable // Extra icon if applicable
icon?: Asset icon?: Asset