Multi group list (#2540)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2023-01-25 19:45:50 +06:00 committed by GitHub
parent 17f7563d85
commit aebb215799
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 437 additions and 213 deletions

View File

@ -60,7 +60,8 @@ import type {
Viewlet, Viewlet,
ViewletDescriptor, ViewletDescriptor,
ViewletPreference, ViewletPreference,
ViewOptionsModel ViewOptionsModel,
ViewOptions
} from '@hcengineering/view' } from '@hcengineering/view'
import view from './plugin' import view from './plugin'
@ -99,6 +100,8 @@ export class TFilteredView extends TPreference implements FilteredView {
name!: string name!: string
location!: Location location!: Location
filters!: string filters!: string
viewOptions?: ViewOptions
viewletId?: Ref<Viewlet> | null
} }
@Model(view.class.FilterMode, core.class.Doc, DOMAIN_MODEL) @Model(view.class.FilterMode, core.class.Doc, DOMAIN_MODEL)

View File

@ -15,8 +15,9 @@
import { Ref } from '@hcengineering/core' import { Ref } from '@hcengineering/core'
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model' import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model'
import { Viewlet, ViewletPreference } from '@hcengineering/view' import { FilteredView, Viewlet, ViewletPreference } from '@hcengineering/view'
import { DOMAIN_PREFERENCE } from '@hcengineering/preference' import { DOMAIN_PREFERENCE } from '@hcengineering/preference'
import view from './plugin'
async function migrateViewletPreference (client: MigrationClient): Promise<void> { async function migrateViewletPreference (client: MigrationClient): Promise<void> {
const targets: Record<string, string[]> = { const targets: Record<string, string[]> = {
@ -56,9 +57,31 @@ async function migrateViewletPreference (client: MigrationClient): Promise<void>
} }
} }
async function migrateSavedFilters (client: MigrationClient): Promise<void> {
const preferences = await client.find<FilteredView>(DOMAIN_PREFERENCE, {
_class: view.class.FilteredView,
viewOptions: { $exists: true }
})
for (const pref of preferences) {
if (pref.viewOptions === undefined) continue
if (Array.isArray(pref.viewOptions.groupBy)) continue
pref.viewOptions.groupBy = [pref.viewOptions.groupBy]
await client.update<FilteredView>(
DOMAIN_PREFERENCE,
{
_id: pref._id
},
{
viewOptions: pref.viewOptions
}
)
}
}
export const viewOperation: MigrateOperation = { export const viewOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> { async migrate (client: MigrationClient): Promise<void> {
await migrateViewletPreference(client) await migrateViewletPreference(client)
await migrateSavedFilters(client)
}, },
async upgrade (client: MigrationUpgradeClient): Promise<void> {} async upgrade (client: MigrationUpgradeClient): Promise<void> {}
} }

View File

@ -31,5 +31,9 @@
"@hcengineering/ui": "^0.6.3", "@hcengineering/ui": "^0.6.3",
"@hcengineering/contact": "^0.6.9", "@hcengineering/contact": "^0.6.9",
"@hcengineering/setting": "^0.6.2" "@hcengineering/setting": "^0.6.2"
},
"repository": "https://github.com/hcengineering/anticrm",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
} }
} }

View File

@ -62,7 +62,7 @@
export let viewOptions: ViewOptions export let viewOptions: ViewOptions
$: currentSpace = space || tracker.team.DefaultTeam $: currentSpace = space || tracker.team.DefaultTeam
$: groupBy = (viewOptions.groupBy ?? noCategory) as IssuesGrouping $: groupBy = (viewOptions.groupBy[0] ?? noCategory) as IssuesGrouping
$: orderBy = viewOptions.orderBy $: orderBy = viewOptions.orderBy
$: sort = { [orderBy[0]]: orderBy[1] } $: sort = { [orderBy[0]]: orderBy[1] }
$: dontUpdateRank = orderBy[0] !== IssuesOrdering.Manual $: dontUpdateRank = orderBy[0] !== IssuesOrdering.Manual

View File

@ -47,21 +47,30 @@
const query = createQuery() const query = createQuery()
const statusesQuery = createQuery() const statusesQuery = createQuery()
let statuses: WithLookup<IssueStatus>[] = [] $: update(value)
$: if (value.$lookup?.subIssues !== undefined) { function update (value: WithLookup<Issue>): void {
if (value.$lookup?.subIssues !== undefined) {
query.unsubscribe() query.unsubscribe()
subIssues = value.$lookup.subIssues as Issue[] subIssues = value.$lookup.subIssues as Issue[]
subIssues.sort((a, b) => (a.rank ?? '').localeCompare(b.rank ?? '')) subIssues.sort((a, b) => (a.rank ?? '').localeCompare(b.rank ?? ''))
} else { } else if (value.subIssues > 0) {
query.query(tracker.class.Issue, { attachedTo: value._id }, (res) => (subIssues = res), { query.query(tracker.class.Issue, { attachedTo: value._id }, (res) => (subIssues = res), {
sort: { rank: SortingOrder.Ascending } sort: { rank: SortingOrder.Ascending }
}) })
} else {
query.unsubscribe()
} }
if (value.subIssues > 0 || value.$lookup?.subIssues !== undefined) {
statusesQuery.query(tracker.class.IssueStatus, {}, (res) => (statuses = res), { statusesQuery.query(tracker.class.IssueStatus, {}, (res) => (statuses = res), {
lookup: { category: tracker.class.IssueStatusCategory } lookup: { category: tracker.class.IssueStatusCategory }
}) })
} else {
statusesQuery.unsubscribe()
}
}
let statuses: WithLookup<IssueStatus>[] = []
$: if (statuses && subIssues) { $: if (statuses && subIssues) {
const doneStatuses = statuses.filter((s) => s.category === tracker.issueStatusCategory.Completed).map((p) => p._id) const doneStatuses = statuses.filter((s) => s.category === tracker.issueStatusCategory.Completed).map((p) => p._id)

View File

@ -14,8 +14,8 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Doc, DocumentQuery, Ref, SortingOrder, WithLookup } from '@hcengineering/core' import { Doc, DocumentQuery, Ref, SortingOrder, WithLookup } from '@hcengineering/core'
import presentation, { createQuery, getClient } from '@hcengineering/presentation' import presentation, { createQuery } from '@hcengineering/presentation'
import { calcRank, Issue, IssueStatus, Team } from '@hcengineering/tracker' import { Issue, IssueStatus, Team } from '@hcengineering/tracker'
import { Label, Spinner } from '@hcengineering/ui' import { Label, Spinner } from '@hcengineering/ui'
import { Viewlet, ViewOptions } from '@hcengineering/view' import { Viewlet, ViewOptions } from '@hcengineering/view'
import tracker from '../../../plugin' import tracker from '../../../plugin'
@ -29,23 +29,11 @@
$: query = { 'relations._id': object._id, 'relations._class': object._class } $: query = { 'relations._id': object._id, 'relations._class': object._class }
const subIssuesQuery = createQuery() const subIssuesQuery = createQuery()
const client = getClient()
let subIssues: Issue[] = [] let subIssues: Issue[] = []
let teams: Map<Ref<Team>, Team> | undefined let teams: Map<Ref<Team>, Team> | undefined
async function handleIssueSwap (ev: CustomEvent<{ fromIndex: number; toIndex: number }>) {
const { fromIndex, toIndex } = ev.detail
const [prev, next] = [
subIssues[fromIndex < toIndex ? toIndex : toIndex - 1],
subIssues[fromIndex < toIndex ? toIndex + 1 : toIndex]
]
const issue = subIssues[fromIndex]
await client.update(issue, { rank: calcRank(prev, next) })
}
$: subIssuesQuery.query(tracker.class.Issue, query, async (result) => (subIssues = result), { $: subIssuesQuery.query(tracker.class.Issue, query, async (result) => (subIssues = result), {
sort: { rank: SortingOrder.Ascending } sort: { rank: SortingOrder.Ascending }
}) })
@ -80,7 +68,7 @@
<div class="mt-1"> <div class="mt-1">
{#if subIssues !== undefined && viewlet !== undefined} {#if subIssues !== undefined && viewlet !== undefined}
{#if issueStatuses.size > 0 && teams} {#if issueStatuses.size > 0 && teams}
<SubIssueList bind:viewOptions {viewlet} issues={subIssues} {teams} {issueStatuses} on:move={handleIssueSwap} /> <SubIssueList bind:viewOptions {viewlet} issues={subIssues} {teams} {issueStatuses} />
{:else} {:else}
<div class="p-1"> <div class="p-1">
<Label label={presentation.string.NoMatchesFound} /> <Label label={presentation.string.NoMatchesFound} />

View File

@ -35,7 +35,7 @@
const statuses = createQuery() const statuses = createQuery()
let issueStatuses: IdMap<IssueStatus> = new Map() let issueStatuses: IdMap<IssueStatus> = new Map()
$: if (noParents !== undefined) { $: if (noParents !== undefined) {
statuses.query(tracker.class.IssueStatus, { _id: { $in: Array.from(noParents.map((it) => it.status)) } }, (res) => { statuses.query(tracker.class.IssueStatus, {}, (res) => {
issueStatuses = toIdMap(res) issueStatuses = toIdMap(res)
}) })
} else { } else {

View File

@ -494,8 +494,7 @@ export function getDefaultViewOptionsTemplatesConfig (): ViewOptionModel[] {
key: 'shouldShowEmptyGroups', key: 'shouldShowEmptyGroups',
label: tracker.string.ShowEmptyGroups, label: tracker.string.ShowEmptyGroups,
defaultValue: false, defaultValue: false,
type: 'toggle', type: 'toggle'
hidden: ({ groupBy }) => !['status', 'priority'].includes(groupBy)
} }
const result: ViewOptionModel[] = [groupByCategory, orderByCategory] const result: ViewOptionModel[] = [groupByCategory, orderByCategory]
result.push(showEmptyGroups) result.push(showEmptyGroups)

View File

@ -60,6 +60,7 @@
"FilteredViews": "Filtered views", "FilteredViews": "Filtered views",
"NewFilteredView": "New filtered view", "NewFilteredView": "New filtered view",
"FilteredViewName": "Filtered view name" "FilteredViewName": "Filtered view name",
"Than": "Than"
} }
} }

View File

@ -57,6 +57,7 @@
"Manual": "Пользовательский", "Manual": "Пользовательский",
"FilteredViews": "Фильтрованные отображения", "FilteredViews": "Фильтрованные отображения",
"NewFilteredView": "Новое фильтрованное отображение", "NewFilteredView": "Новое фильтрованное отображение",
"FilteredViewName": "Имя фильтрованного отображения" "FilteredViewName": "Имя фильтрованного отображения",
"Than": "Затем"
} }
} }

View File

@ -33,26 +33,47 @@
label: key === 'rank' ? view.string.Manual : getKeyLabel(client, viewlet.attachTo, key, lookup) label: key === 'rank' ? view.string.Manual : getKeyLabel(client, viewlet.attachTo, key, lookup)
} }
}) })
$: groups =
viewOptions.groupBy[viewOptions.groupBy.length - 1] === noCategory
? viewOptions.groupBy
: [...viewOptions.groupBy, noCategory]
function selectGrouping (value: string, i: number) {
viewOptions.groupBy[i] = value
if (value !== noCategory) {
if (i + 1 === viewOptions.groupBy.length) {
viewOptions.groupBy[i + 1] = noCategory
viewOptions.groupBy = viewOptions.groupBy
}
} else {
viewOptions.groupBy.length = i + 1
viewOptions.groupBy = viewOptions.groupBy
}
dispatch('update', {
key: 'groupBy',
value: viewOptions.groupBy.length > 1 ? viewOptions.groupBy.filter((p) => p !== noCategory) : viewOptions.groupBy
})
}
</script> </script>
<div class="antiCard"> <div class="antiCard">
<div class="antiCard-group grid"> <div class="antiCard-group grid">
<span class="label"><Label label={view.string.Grouping} /></span> {#each groups as group, i}
<div class="value"> <span class="label"><Label label={i === 0 ? view.string.Grouping : view.string.Than} /></span>
<div class="value grouping">
<DropdownLabelsIntl <DropdownLabelsIntl
label={view.string.Grouping} label={view.string.Grouping}
items={groupBy} items={groupBy.filter((p) => !viewOptions.groupBy.includes(p.id) || [group, noCategory].includes(p.id))}
selected={viewOptions.groupBy} selected={group}
width="10rem" width="10rem"
justify="left" justify="left"
on:selected={(e) => { on:selected={(e) => selectGrouping(e.detail, i)}
viewOptions.groupBy = e.detail
dispatch('update', { key: 'groupBy', value: e.detail })
}}
/> />
</div> </div>
{/each}
<span class="label"><Label label={view.string.Ordering} /></span> <span class="label"><Label label={view.string.Ordering} /></span>
<div class="value"> <div class="value ordering">
<DropdownLabelsIntl <DropdownLabelsIntl
label={view.string.Ordering} label={view.string.Ordering}
items={orderBy} items={orderBy}

View File

@ -13,21 +13,14 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import core, { Class, Doc, DocumentQuery, FindOptions, Ref, Space } from '@hcengineering/core' import { Class, Doc, DocumentQuery, FindOptions, Ref, Space } from '@hcengineering/core'
import { getResource, IntlString } from '@hcengineering/platform' import { getResource, IntlString } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
import { AnyComponent } from '@hcengineering/ui' import { AnyComponent } from '@hcengineering/ui'
import view, { import { BuildModelKey, ViewOptionModel, ViewOptions, ViewQueryOption } from '@hcengineering/view'
AttributeModel,
BuildModelKey,
ViewOptionModel,
ViewOptions,
ViewQueryOption
} from '@hcengineering/view'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { buildConfigLookup, buildModel, getCategories, getPresenter, groupBy, LoadingProps } from '../../utils' import { buildConfigLookup, LoadingProps } from '../../utils'
import { noCategory } from '../../viewOptions' import ListCategories from './ListCategories.svelte'
import ListCategory from './ListCategory.svelte'
export let _class: Ref<Class<Doc>> export let _class: Ref<Class<Doc>>
export let space: Ref<Space> | undefined = undefined export let space: Ref<Space> | undefined = undefined
@ -36,7 +29,6 @@
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
export let config: (string | BuildModelKey)[] export let config: (string | BuildModelKey)[]
export let selectedObjectIds: Doc[] = [] export let selectedObjectIds: Doc[] = []
export let selectedRowIndex: number | undefined = undefined
export let loadingProps: LoadingProps | undefined = undefined export let loadingProps: LoadingProps | undefined = undefined
export let createItemDialog: AnyComponent | undefined = undefined export let createItemDialog: AnyComponent | undefined = undefined
export let createItemLabel: IntlString | undefined = undefined export let createItemLabel: IntlString | undefined = undefined
@ -47,18 +39,16 @@
export let documents: Doc[] | undefined = undefined export let documents: Doc[] | undefined = undefined
const objectRefs: HTMLElement[] = [] const elementByIndex: Map<number, HTMLDivElement> = new Map()
const docByIndex: Map<number, Doc> = new Map()
const indexById: Map<Ref<Doc>, number> = new Map()
let docs: Doc[] = [] let docs: Doc[] = []
$: groupByKey = viewOptions.groupBy ?? noCategory
$: orderBy = viewOptions.orderBy $: orderBy = viewOptions.orderBy
$: groupedDocs = groupBy(docs, groupByKey)
let categories: any[] = []
$: getCategories(client, _class, docs, groupByKey).then((p) => {
categories = p
})
const docsQuery = createQuery() const docsQuery = createQuery()
$: lookup = options?.lookup ?? buildConfigLookup(client.getHierarchy(), _class, config)
$: resultOptions = { lookup, ...options, sort: { [orderBy[0]]: orderBy[1] } } $: resultOptions = { lookup, ...options, sort: { [orderBy[0]]: orderBy[1] } }
let resultQuery: DocumentQuery<Doc> = query let resultQuery: DocumentQuery<Doc> = query
@ -86,13 +76,6 @@
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
$: lookup = options?.lookup ?? buildConfigLookup(client.getHierarchy(), _class, config)
const spaceQuery = createQuery()
let currentSpace: Space | undefined
let itemModels: AttributeModel[]
async function getResultQuery ( async function getResultQuery (
query: DocumentQuery<Doc>, query: DocumentQuery<Doc>,
viewOptions: ViewOptionModel[] | undefined, viewOptions: ViewOptionModel[] | undefined,
@ -109,6 +92,34 @@
return result return result
} }
function uncheckAll () {
dispatch('check', { docs, value: false })
selectedObjectIds = []
}
export function select (offset: 1 | -1 | 0, of?: Doc): void {
let pos = (of !== undefined ? indexById.get(of._id) : -1) ?? -1
pos += offset
if (pos < 0) {
pos = 0
}
if (pos >= docs.length) {
pos = docs.length - 1
}
const target = docByIndex.get(pos)
if (target !== undefined) {
onRow(target)
}
const r = elementByIndex.get(pos)
if (r !== undefined) {
r.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}
function onRow (object: Doc): void {
dispatch('row-focus', object)
}
const getLoadingElementsLength = (props: LoadingProps | undefined, options?: FindOptions<Doc>) => { const getLoadingElementsLength = (props: LoadingProps | undefined, options?: FindOptions<Doc>) => {
if (!props) return undefined if (!props) return undefined
if (options?.limit && options.limit > 0) { if (options?.limit && options.limit > 0) {
@ -117,104 +128,34 @@
return props.length return props.length
} }
$: spaceQuery.query(core.class.Space, { _id: space }, (res) => {
;[currentSpace] = res
})
function getHeader (_class: Ref<Class<Doc>>, groupByKey: string): void {
if (groupByKey === noCategory) {
headerComponent = undefined
} else {
getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => (headerComponent = p))
}
}
let headerComponent: AttributeModel | undefined
$: getHeader(_class, groupByKey)
$: buildModel({ client, _class, keys: config, lookup }).then((res) => {
itemModels = res
})
function getInitIndex (categories: any, i: number): number {
let res = 0
for (let index = 0; index < i; index++) {
const cat = categories[index]
res += groupedDocs[cat].length
}
return res
}
function uncheckAll () {
dispatch('check', { docs, value: false })
selectedObjectIds = []
}
$: extraHeaders = getAdditionalHeader(_class)
function getAdditionalHeader (_class: Ref<Class<Doc>>): AnyComponent[] | undefined {
const clazz = hierarchy.getClass(_class)
let mixinClazz = hierarchy.getClass(_class)
let presenterMixin = hierarchy.as(clazz, view.mixin.ListHeaderExtra)
while (presenterMixin.presenters === undefined && mixinClazz.extends !== undefined) {
presenterMixin = hierarchy.as(mixinClazz, view.mixin.ListHeaderExtra)
mixinClazz = hierarchy.getClass(mixinClazz.extends)
}
return presenterMixin.presenters
}
$: flat = Object.values(groupedDocs).flat(1)
export function select (offset: 1 | -1 | 0, of?: Doc): void {
let pos = (of !== undefined ? flat.findIndex((it) => it._id === of._id) : selectedRowIndex) ?? -1
pos += offset
if (pos < 0) {
pos = 0
}
if (pos >= flat.length) {
pos = flat.length - 1
}
const r = objectRefs[pos]
selectedRowIndex = pos
onRow(flat[pos])
if (r !== undefined) {
r?.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}
function onRow (object: Doc): void {
dispatch('row-focus', object)
}
$: objectRefs.length = flat.length
</script> </script>
<div class="list-container"> <div class="list-container">
{#each categories as category, i} <ListCategories
{@const items = groupedDocs[category] ?? []} newObjectProps={space ? { space } : {}}
<ListCategory {elementByIndex}
bind:selectedRowIndex {indexById}
{extraHeaders} {docs}
{_class}
{space} {space}
{selectedObjectIds} {lookup}
{headerComponent} loadingPropsLength={getLoadingElementsLength(loadingProps, options)}
initIndex={getInitIndex(categories, i)}
{baseMenuClass} {baseMenuClass}
{groupByKey} {config}
{itemModels} {viewOptions}
singleCat={categories.length === 1} {docByIndex}
{category} {viewOptionsConfig}
{items} {selectedObjectIds}
level={0}
{createItemDialog} {createItemDialog}
{createItemLabel} {createItemLabel}
loadingPropsLength={getLoadingElementsLength(loadingProps, options)} {loadingProps}
on:check on:check
on:uncheckAll={uncheckAll} on:uncheckAll={uncheckAll}
on:row-focus on:row-focus
{flatHeaders} {flatHeaders}
{props} {props}
/> />
{/each}
</div> </div>
<style lang="scss"> <style lang="scss">

View File

@ -0,0 +1,125 @@
<!--
// Copyright © 2023 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, Lookup, Ref, Space } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { AnyComponent } from '@hcengineering/ui'
import view, { AttributeModel, BuildModelKey, ViewOptions } from '@hcengineering/view'
import { buildModel, getCategories, getPresenter, groupBy } from '../../utils'
import { noCategory } from '../../viewOptions'
import ListCategory from './ListCategory.svelte'
export let elementByIndex: Map<number, HTMLDivElement>
export let indexById: Map<Ref<Doc>, number>
export let docs: Doc[]
export let _class: Ref<Class<Doc>>
export let space: Ref<Space> | undefined
export let lookup: Lookup<Doc>
export let loadingPropsLength: number | undefined
export let baseMenuClass: Ref<Class<Doc>> | undefined
export let config: (string | BuildModelKey)[]
export let selectedObjectIds: Doc[] = []
export let createItemDialog: AnyComponent | undefined
export let createItemLabel: IntlString | undefined
export let viewOptions: ViewOptions
export let flatHeaders = false
export let props: Record<string, any> = {}
export let level: number
export let initIndex = 0
export let newObjectProps: Record<string, any>
export let docByIndex: Map<number, Doc>
$: groupByKey = viewOptions.groupBy[level] ?? noCategory
$: groupedDocs = groupBy(docs, groupByKey)
let categories: any[] = []
$: getCategories(client, _class, docs, groupByKey).then((p) => {
categories = p
})
const client = getClient()
const hierarchy = client.getHierarchy()
let itemModels: AttributeModel[]
function getHeader (_class: Ref<Class<Doc>>, groupByKey: string): void {
if (groupByKey === noCategory) {
headerComponent = undefined
} else {
getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => (headerComponent = p))
}
}
let headerComponent: AttributeModel | undefined
$: getHeader(_class, groupByKey)
$: buildModel({ client, _class, keys: config, lookup }).then((res) => {
itemModels = res
})
function getInitIndex (categories: any, i: number): number {
let res = initIndex
for (let index = 0; index < i; index++) {
const cat = categories[index]
res += groupedDocs[cat]?.length
}
return res
}
$: extraHeaders = getAdditionalHeader(_class)
function getAdditionalHeader (_class: Ref<Class<Doc>>): AnyComponent[] | undefined {
const clazz = hierarchy.getClass(_class)
let mixinClazz = hierarchy.getClass(_class)
let presenterMixin = hierarchy.as(clazz, view.mixin.ListHeaderExtra)
while (presenterMixin.presenters === undefined && mixinClazz.extends !== undefined) {
presenterMixin = hierarchy.as(mixinClazz, view.mixin.ListHeaderExtra)
mixinClazz = hierarchy.getClass(mixinClazz.extends)
}
return presenterMixin.presenters
}
</script>
{#each categories as category, i}
{@const items = groupedDocs[category] ?? []}
<ListCategory
{elementByIndex}
{indexById}
{extraHeaders}
{space}
{selectedObjectIds}
{headerComponent}
initIndex={getInitIndex(categories, i)}
{baseMenuClass}
{level}
{viewOptions}
{groupByKey}
{config}
{docByIndex}
{itemModels}
{_class}
singleCat={level === 0 && categories.length === 1}
{category}
{items}
{newObjectProps}
{createItemDialog}
{createItemLabel}
{loadingPropsLength}
on:check
on:uncheckAll
on:row-focus
{flatHeaders}
{props}
/>
{/each}

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Class, Doc, Ref, Space } from '@hcengineering/core' import { Class, Doc, Lookup, Ref, Space } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform' import { IntlString } from '@hcengineering/platform'
import { import {
AnyComponent, AnyComponent,
@ -23,9 +23,11 @@
showPopup, showPopup,
Spinner Spinner
} from '@hcengineering/ui' } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view' import { AttributeModel, BuildModelKey, ViewOptions } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { FocusSelection, focusStore } from '../../selection'
import Menu from '../Menu.svelte' import Menu from '../Menu.svelte'
import ListCategories from './ListCategories.svelte'
import ListHeader from './ListHeader.svelte' import ListHeader from './ListHeader.svelte'
import ListItem from './ListItem.svelte' import ListItem from './ListItem.svelte'
@ -42,17 +44,26 @@
export let loadingPropsLength: number | undefined export let loadingPropsLength: number | undefined
export let selectedObjectIds: Doc[] export let selectedObjectIds: Doc[]
export let itemModels: AttributeModel[] export let itemModels: AttributeModel[]
export let selectedRowIndex: number | undefined
export let extraHeaders: AnyComponent[] | undefined export let extraHeaders: AnyComponent[] | undefined
export let objectRefs: HTMLElement[] = []
export let flatHeaders = false export let flatHeaders = false
export let props: Record<string, any> = {} export let props: Record<string, any> = {}
export let level: number
export let elementByIndex: Map<number, HTMLDivElement>
export let indexById: Map<Ref<Doc>, number>
export let lookup: Lookup<Doc>
export let _class: Ref<Class<Doc>>
export let config: (string | BuildModelKey)[]
export let viewOptions: ViewOptions
export let newObjectProps: Record<string, any>
export let docByIndex: Map<number, Doc>
$: 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 = singleCat ? singleCategoryLimit : defaultLimit $: initialLimit = singleCat ? singleCategoryLimit : defaultLimit
$: limit = initialLimit $: limit = !lastLevel ? items.length : initialLimit
let collapsed = true let collapsed = true
@ -62,11 +73,11 @@
return items.slice(0, limit) return items.slice(0, limit)
} }
function initCollapsed (singleCat: boolean, category: any): void { function initCollapsed (singleCat: boolean, lastLevel: boolean, category: any): void {
collapsed = !singleCat && items.length > autoFoldLimit collapsed = !singleCat && items.length > (lastLevel ? autoFoldLimit : singleCategoryLimit)
} }
$: initCollapsed(singleCat, category) $: initCollapsed(singleCat, lastLevel, category)
const handleRowFocused = (object: Doc) => { const handleRowFocused = (object: Doc) => {
dispatch('row-focus', object) dispatch('row-focus', object)
@ -74,7 +85,7 @@
const handleMenuOpened = async (event: MouseEvent, object: Doc, rowIndex: number) => { const handleMenuOpened = async (event: MouseEvent, object: Doc, rowIndex: number) => {
event.preventDefault() event.preventDefault()
selectedRowIndex = rowIndex handleRowFocused(object)
if (!selectedObjectIdsSet.has(object._id)) { if (!selectedObjectIdsSet.has(object._id)) {
dispatch('uncheckAll') dispatch('uncheckAll')
@ -83,24 +94,32 @@
const items = selectedObjectIds.length > 0 ? selectedObjectIds : object const items = selectedObjectIds.length > 0 ? selectedObjectIds : object
showPopup(Menu, { object: items, baseMenuClass }, getEventPositionElement(event), () => { showPopup(Menu, { object: items, baseMenuClass }, getEventPositionElement(event), () => {
selectedRowIndex = undefined dispatch('row-focus')
}) })
} }
$: limited = limitGroup(items, limit) $: limited = limitGroup(items, limit)
$: selectedObjectIdsSet = new Set<Ref<Doc>>(selectedObjectIds.map((it) => it._id)) $: selectedObjectIdsSet = new Set<Ref<Doc>>(selectedObjectIds.map((it) => it._id))
$: newObjectProps = { [groupByKey]: category, ...newObjectProps }
function isSelected (doc: Doc, focusStore: FocusSelection): boolean {
return focusStore.focus?._id === doc._id
}
</script> </script>
<ListHeader <ListHeader
{groupByKey} {groupByKey}
{category} {category}
{space} {space}
{level}
limited={limited.length} limited={limited.length}
{items} {items}
{headerComponent} {headerComponent}
{createItemDialog} {createItemDialog}
{createItemLabel} {createItemLabel}
{extraHeaders} {extraHeaders}
{newObjectProps}
flat={flatHeaders} flat={flatHeaders}
{props} {props}
on:more={() => { on:more={() => {
@ -111,15 +130,45 @@
}} }}
/> />
<ExpandCollapse isExpanded={!collapsed} duration={400}> <ExpandCollapse isExpanded={!collapsed} duration={400}>
{#if itemModels} {#if !lastLevel}
<div class="p-2">
<ListCategories
{elementByIndex}
{indexById}
docs={items}
{_class}
{space}
{lookup}
{loadingPropsLength}
{baseMenuClass}
{config}
{selectedObjectIds}
{createItemDialog}
{createItemLabel}
{viewOptions}
{newObjectProps}
{flatHeaders}
{props}
level={level + 1}
{initIndex}
{docByIndex}
on:check
on:uncheckAll
on:row-focus
/>
</div>
{:else if itemModels}
{#if limited} {#if limited}
{#each limited as docObject, i (docObject._id)} {#each limited as docObject, i (docObject._id)}
<ListItem <ListItem
bind:use={objectRefs[initIndex + i]}
{docObject} {docObject}
{elementByIndex}
{docByIndex}
{indexById}
model={itemModels} model={itemModels}
index={initIndex + i}
{groupByKey} {groupByKey}
selected={selectedRowIndex === initIndex + i} selected={isSelected(docObject, $focusStore)}
checked={selectedObjectIdsSet.has(docObject._id)} checked={selectedObjectIdsSet.has(docObject._id)}
on:check={(ev) => dispatch('check', { docs: ev.detail.docs, value: ev.detail.value })} on:check={(ev) => dispatch('check', { docs: ev.detail.docs, value: ev.detail.value })}
on:contextmenu={(event) => handleMenuOpened(event, docObject, initIndex + i)} on:contextmenu={(event) => handleMenuOpened(event, docObject, initIndex + i)}
@ -131,7 +180,7 @@
{/if} {/if}
{:else if loadingPropsLength !== undefined} {:else if loadingPropsLength !== undefined}
{#each Array(Math.max(loadingPropsLength, limit)) as _, rowIndex} {#each Array(Math.max(loadingPropsLength, limit)) as _, rowIndex}
<div class="listGrid row" class:fixed={rowIndex === selectedRowIndex}> <div class="listGrid row">
<div class="flex-center clear-mins h-full"> <div class="flex-center clear-mins h-full">
<div class="gridElement"> <div class="gridElement">
<CheckBox checked={false} /> <CheckBox checked={false} />

View File

@ -43,18 +43,26 @@
export let extraHeaders: AnyComponent[] | undefined export let extraHeaders: AnyComponent[] | undefined
export let flat = false export let flat = false
export let props: Record<string, any> = {} export let props: Record<string, any> = {}
export let level: number
export let newObjectProps: Record<string, any>
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const handleCreateItem = (event: MouseEvent, category: string) => { const handleCreateItem = (event: MouseEvent) => {
if (createItemDialog === undefined) return if (createItemDialog === undefined) return
showPopup(createItemDialog, { space, ...(groupByKey ? { [groupByKey]: category } : {}) }, eventToHTMLElement(event)) showPopup(createItemDialog, newObjectProps, eventToHTMLElement(event))
} }
</script> </script>
{#if headerComponent || groupByKey === noCategory} {#if headerComponent || groupByKey === noCategory}
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="flex-between categoryHeader row" class:flat on:click={() => dispatch('collapse')}> <div
class="flex-between categoryHeader row"
style:z-index={10 - level}
class:flat
class:subLevel={level !== 0}
on:click={() => dispatch('collapse')}
>
<div class="flex-row-center gap-2 clear-mins caption-color"> <div class="flex-row-center gap-2 clear-mins caption-color">
<FixedColumn key={`list_groupBy_${groupByKey}`} justify={'left'}> <FixedColumn key={`list_groupBy_${groupByKey}`} justify={'left'}>
{#if groupByKey === noCategory} {#if groupByKey === noCategory}
@ -95,7 +103,7 @@
icon={IconAdd} icon={IconAdd}
kind={'transparent'} kind={'transparent'}
showTooltip={{ label: createItemLabel }} showTooltip={{ label: createItemLabel }}
on:click={(event) => handleCreateItem(event, category)} on:click={handleCreateItem}
/> />
{/if} {/if}
</div> </div>
@ -110,7 +118,13 @@
min-height: 3rem; min-height: 3rem;
min-width: 0; min-width: 0;
background: var(--header-bg-color); background: var(--header-bg-color);
z-index: 5;
&.subLevel {
min-height: 2.25rem;
height: 2.25rem;
padding: 0 0.75rem 0 2.25rem;
// here shoul be top 3rem for sticky, but with ExpandCollapse it gives strange behavior
}
&.flat { &.flat {
background: var(--header-bg-color); background: var(--header-bg-color);

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Doc, getObjectValue } from '@hcengineering/core' import { Doc, getObjectValue, Ref } from '@hcengineering/core'
import notification from '@hcengineering/notification' import notification from '@hcengineering/notification'
import { CheckBox, Component, deviceOptionsStore as deviceInfo, tooltip } from '@hcengineering/ui' import { CheckBox, Component, deviceOptionsStore as deviceInfo, tooltip } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view' import { AttributeModel } from '@hcengineering/view'
@ -22,21 +22,38 @@
import view from '../../plugin' import view from '../../plugin'
import Circles from '../icons/Circles.svelte' import Circles from '../icons/Circles.svelte'
export let use: HTMLElement
export let docObject: Doc export let docObject: Doc
export let index: number
export let model: AttributeModel[] export let model: AttributeModel[]
export let groupByKey: string | undefined export let groupByKey: string | undefined
export let checked: boolean export let checked: boolean
export let selected: boolean export let selected: boolean
export let props: Record<string, any> = {} export let props: Record<string, any> = {}
export let elementByIndex: Map<number, HTMLDivElement>
export let indexById: Map<Ref<Doc>, number>
export let docByIndex: Map<number, Doc>
let elem: HTMLDivElement
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export function getDoc () {
return docObject
}
export function getElement () {
return elem
}
$: compactMode = $deviceInfo.twoRows $: compactMode = $deviceInfo.twoRows
$: elem && elementByIndex.set(index, elem)
$: indexById.set(docObject._id, index)
$: docByIndex.set(index, docObject)
</script> </script>
<div <div
bind:this={use} bind:this={elem}
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:mListGridFixed={selected} class:mListGridFixed={selected}

View File

@ -4,14 +4,7 @@
import { AnyComponent, issueSP, Scroller } from '@hcengineering/ui' import { AnyComponent, issueSP, Scroller } from '@hcengineering/ui'
import { BuildModelKey, Viewlet, ViewOptions } from '@hcengineering/view' import { BuildModelKey, Viewlet, ViewOptions } from '@hcengineering/view'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { import { ActionContext, ListSelectionProvider, LoadingProps, SelectDirection, selectionStore } from '../..'
ActionContext,
focusStore,
ListSelectionProvider,
LoadingProps,
SelectDirection,
selectionStore
} from '../..'
import List from './List.svelte' import List from './List.svelte'
@ -63,7 +56,6 @@
{props} {props}
viewOptionsConfig={viewlet.viewOptions?.other} viewOptionsConfig={viewlet.viewOptions?.other}
selectedObjectIds={$selectionStore ?? []} selectedObjectIds={$selectionStore ?? []}
selectedRowIndex={listProvider.current($focusStore)}
on:row-focus={(event) => { on:row-focus={(event) => {
listProvider.updateFocus(event.detail ?? undefined) listProvider.updateFocus(event.detail ?? undefined)
}} }}

View File

@ -60,6 +60,7 @@ export default mergeIds(viewId, view, {
NoGrouping: '' as IntlString, NoGrouping: '' as IntlString,
Grouping: '' as IntlString, Grouping: '' as IntlString,
Ordering: '' as IntlString, Ordering: '' as IntlString,
Manual: '' as IntlString Manual: '' as IntlString,
Than: '' as IntlString
} }
}) })

View File

@ -1,11 +1,18 @@
import { SortingOrder } from '@hcengineering/core' import { SortingOrder } from '@hcengineering/core'
import { getCurrentLocation, locationToUrl } from '@hcengineering/ui' import { getCurrentLocation, locationToUrl } from '@hcengineering/ui'
import { DropdownViewOption, ToggleViewOption, Viewlet, ViewOptionModel, ViewOptions } from '@hcengineering/view' import {
DropdownViewOption,
ToggleViewOption,
Viewlet,
ViewOptionModel,
ViewOptions,
ViewOptionsModel
} from '@hcengineering/view'
export const noCategory = '#no_category' export const noCategory = '#no_category'
export const defaulOptions: ViewOptions = { export const defaulOptions: ViewOptions = {
groupBy: noCategory, groupBy: [noCategory],
orderBy: ['modifiedBy', SortingOrder.Descending] orderBy: ['modifiedBy', SortingOrder.Descending]
} }
@ -41,9 +48,37 @@ function _getViewOptions (viewlet: Viewlet): ViewOptions | null {
return JSON.parse(options) return JSON.parse(options)
} }
function getDefaults (viewOptions: ViewOptionsModel): ViewOptions {
const res: ViewOptions = {
groupBy: [viewOptions.groupBy[0]],
orderBy: viewOptions.orderBy[0]
}
for (const opt of viewOptions.other) {
res[opt.key] = opt.defaultValue
}
return res
}
export function getViewOptions (viewlet: Viewlet | undefined, defaults = defaulOptions): ViewOptions { export function getViewOptions (viewlet: Viewlet | undefined, defaults = defaulOptions): ViewOptions {
if (viewlet === undefined) { if (viewlet === undefined) {
return { ...defaults } return { ...defaults }
} }
return _getViewOptions(viewlet) ?? defaults const res = _getViewOptions(viewlet)
if (res !== null) return res
return viewlet.viewOptions != null ? getDefaults(viewlet.viewOptions) : defaults
}
export function migrateViewOpttions (): void {
for (let index = 0; index < localStorage.length; index++) {
const key = localStorage.key(index)
if (key === null) continue
if (!key.startsWith('viewOptions:')) continue
const options = localStorage.getItem(key)
if (options === null) continue
const res = JSON.parse(options) as ViewOptions
if (!Array.isArray(res.groupBy)) {
res.groupBy = [res.groupBy]
}
localStorage.setItem(key, JSON.stringify(res))
}
} }

View File

@ -445,7 +445,7 @@ export interface ViewletPreference extends Preference {
* @public * @public
*/ */
export type ViewOptions = { export type ViewOptions = {
groupBy: string groupBy: string[]
orderBy: OrderOption orderBy: OrderOption
} & Record<string, any> } & Record<string, any>

View File

@ -42,7 +42,7 @@
TooltipInstance TooltipInstance
} from '@hcengineering/ui' } from '@hcengineering/ui'
import view from '@hcengineering/view' import view from '@hcengineering/view'
import { ActionContext, ActionHandler } from '@hcengineering/view-resources' import { ActionContext, ActionHandler, migrateViewOpttions } from '@hcengineering/view-resources'
import type { Application, NavigatorModel, SpecialNavModel, ViewConfiguration } from '@hcengineering/workbench' import type { Application, NavigatorModel, SpecialNavModel, ViewConfiguration } from '@hcengineering/workbench'
import { getContext, onDestroy, onMount, tick } from 'svelte' import { getContext, onDestroy, onMount, tick } from 'svelte'
import { subscribeMobile } from '../mobile' import { subscribeMobile } from '../mobile'
@ -77,6 +77,7 @@
let createItemLabel: IntlString | undefined let createItemLabel: IntlString | undefined
let apps: Application[] = [] let apps: Application[] = []
migrateViewOpttions()
const excludedApps = getMetadata(workbench.metadata.ExcludedApplications) ?? [] const excludedApps = getMetadata(workbench.metadata.ExcludedApplications) ?? []

View File

@ -141,7 +141,7 @@ test('report-time-from-main-view', async ({ page }) => {
await page.click('text="Issues"') await page.click('text="Issues"')
await page.click('button:has-text("View")') await page.click('button:has-text("View")')
await page.click('.value >> nth=1') await page.click('.ordering >> nth=0')
await page.click('text="Modified"') await page.click('text="Modified"')
await page.keyboard.press('Escape') await page.keyboard.press('Escape')

View File

@ -29,18 +29,18 @@ export async function navigate (page: Page): Promise<void> {
export async function setViewGroup (page: Page, groupName: string): Promise<void> { export async function setViewGroup (page: Page, groupName: string): Promise<void> {
await page.click('button:has-text("View")') await page.click('button:has-text("View")')
await page.click('.antiCard >> button >> nth=0') await page.click('.antiCard >> .grouping >> button >> nth=0')
await page.click(`.menu-item:has-text("${groupName}")`) await page.click(`.menu-item:has-text("${groupName}")`)
await expect(page.locator('.antiCard >> button >> nth=0')).toContainText(groupName) await expect(page.locator('.antiCard >> .grouping >> button >> nth=0')).toContainText(groupName)
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
} }
export async function setViewOrder (page: Page, orderName: string): Promise<void> { export async function setViewOrder (page: Page, orderName: string): Promise<void> {
await page.click('button:has-text("View")') await page.click('button:has-text("View")')
await page.click('.antiCard >> button >> nth=1') await page.click('.antiCard >> .ordering >> button')
await page.click(`.menu-item:has-text("${orderName}")`) await page.click(`.menu-item:has-text("${orderName}")`)
await expect(page.locator('.antiCard >> button >> nth=1')).toContainText(orderName) await expect(page.locator('.antiCard >> .ordering >> button')).toContainText(orderName)
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
} }