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

View File

@ -15,8 +15,9 @@
import { Ref } from '@hcengineering/core'
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 view from './plugin'
async function migrateViewletPreference (client: MigrationClient): Promise<void> {
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 = {
async migrate (client: MigrationClient): Promise<void> {
await migrateViewletPreference(client)
await migrateSavedFilters(client)
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {}
}

View File

@ -31,5 +31,9 @@
"@hcengineering/ui": "^0.6.3",
"@hcengineering/contact": "^0.6.9",
"@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
$: currentSpace = space || tracker.team.DefaultTeam
$: groupBy = (viewOptions.groupBy ?? noCategory) as IssuesGrouping
$: groupBy = (viewOptions.groupBy[0] ?? noCategory) as IssuesGrouping
$: orderBy = viewOptions.orderBy
$: sort = { [orderBy[0]]: orderBy[1] }
$: dontUpdateRank = orderBy[0] !== IssuesOrdering.Manual

View File

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

View File

@ -14,8 +14,8 @@
-->
<script lang="ts">
import { Doc, DocumentQuery, Ref, SortingOrder, WithLookup } from '@hcengineering/core'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { calcRank, Issue, IssueStatus, Team } from '@hcengineering/tracker'
import presentation, { createQuery } from '@hcengineering/presentation'
import { Issue, IssueStatus, Team } from '@hcengineering/tracker'
import { Label, Spinner } from '@hcengineering/ui'
import { Viewlet, ViewOptions } from '@hcengineering/view'
import tracker from '../../../plugin'
@ -29,23 +29,11 @@
$: query = { 'relations._id': object._id, 'relations._class': object._class }
const subIssuesQuery = createQuery()
const client = getClient()
let subIssues: Issue[] = []
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), {
sort: { rank: SortingOrder.Ascending }
})
@ -80,7 +68,7 @@
<div class="mt-1">
{#if subIssues !== undefined && viewlet !== undefined}
{#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}
<div class="p-1">
<Label label={presentation.string.NoMatchesFound} />

View File

@ -35,7 +35,7 @@
const statuses = createQuery()
let issueStatuses: IdMap<IssueStatus> = new Map()
$: 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)
})
} else {

View File

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

View File

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

View File

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

View File

@ -33,26 +33,47 @@
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>
<div class="antiCard">
<div class="antiCard-group grid">
<span class="label"><Label label={view.string.Grouping} /></span>
<div class="value">
<DropdownLabelsIntl
label={view.string.Grouping}
items={groupBy}
selected={viewOptions.groupBy}
width="10rem"
justify="left"
on:selected={(e) => {
viewOptions.groupBy = e.detail
dispatch('update', { key: 'groupBy', value: e.detail })
}}
/>
</div>
{#each groups as group, i}
<span class="label"><Label label={i === 0 ? view.string.Grouping : view.string.Than} /></span>
<div class="value grouping">
<DropdownLabelsIntl
label={view.string.Grouping}
items={groupBy.filter((p) => !viewOptions.groupBy.includes(p.id) || [group, noCategory].includes(p.id))}
selected={group}
width="10rem"
justify="left"
on:selected={(e) => selectGrouping(e.detail, i)}
/>
</div>
{/each}
<span class="label"><Label label={view.string.Ordering} /></span>
<div class="value">
<div class="value ordering">
<DropdownLabelsIntl
label={view.string.Ordering}
items={orderBy}

View File

@ -13,21 +13,14 @@
// limitations under the License.
-->
<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 { createQuery, getClient } from '@hcengineering/presentation'
import { AnyComponent } from '@hcengineering/ui'
import view, {
AttributeModel,
BuildModelKey,
ViewOptionModel,
ViewOptions,
ViewQueryOption
} from '@hcengineering/view'
import { BuildModelKey, ViewOptionModel, ViewOptions, ViewQueryOption } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import { buildConfigLookup, buildModel, getCategories, getPresenter, groupBy, LoadingProps } from '../../utils'
import { noCategory } from '../../viewOptions'
import ListCategory from './ListCategory.svelte'
import { buildConfigLookup, LoadingProps } from '../../utils'
import ListCategories from './ListCategories.svelte'
export let _class: Ref<Class<Doc>>
export let space: Ref<Space> | undefined = undefined
@ -36,7 +29,6 @@
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
export let config: (string | BuildModelKey)[]
export let selectedObjectIds: Doc[] = []
export let selectedRowIndex: number | undefined = undefined
export let loadingProps: LoadingProps | undefined = undefined
export let createItemDialog: AnyComponent | undefined = undefined
export let createItemLabel: IntlString | undefined = undefined
@ -47,18 +39,16 @@
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[] = []
$: groupByKey = viewOptions.groupBy ?? noCategory
$: orderBy = viewOptions.orderBy
$: groupedDocs = groupBy(docs, groupByKey)
let categories: any[] = []
$: getCategories(client, _class, docs, groupByKey).then((p) => {
categories = p
})
const docsQuery = createQuery()
$: lookup = options?.lookup ?? buildConfigLookup(client.getHierarchy(), _class, config)
$: resultOptions = { lookup, ...options, sort: { [orderBy[0]]: orderBy[1] } }
let resultQuery: DocumentQuery<Doc> = query
@ -86,13 +76,6 @@
const client = getClient()
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 (
query: DocumentQuery<Doc>,
viewOptions: ViewOptionModel[] | undefined,
@ -109,6 +92,34 @@
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>) => {
if (!props) return undefined
if (options?.limit && options.limit > 0) {
@ -117,104 +128,34 @@
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>
<div class="list-container">
{#each categories as category, i}
{@const items = groupedDocs[category] ?? []}
<ListCategory
bind:selectedRowIndex
{extraHeaders}
{space}
{selectedObjectIds}
{headerComponent}
initIndex={getInitIndex(categories, i)}
{baseMenuClass}
{groupByKey}
{itemModels}
singleCat={categories.length === 1}
{category}
{items}
{createItemDialog}
{createItemLabel}
loadingPropsLength={getLoadingElementsLength(loadingProps, options)}
on:check
on:uncheckAll={uncheckAll}
on:row-focus
{flatHeaders}
{props}
/>
{/each}
<ListCategories
newObjectProps={space ? { space } : {}}
{elementByIndex}
{indexById}
{docs}
{_class}
{space}
{lookup}
loadingPropsLength={getLoadingElementsLength(loadingProps, options)}
{baseMenuClass}
{config}
{viewOptions}
{docByIndex}
{viewOptionsConfig}
{selectedObjectIds}
level={0}
{createItemDialog}
{createItemLabel}
{loadingProps}
on:check
on:uncheckAll={uncheckAll}
on:row-focus
{flatHeaders}
{props}
/>
</div>
<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.
-->
<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 {
AnyComponent,
@ -23,9 +23,11 @@
showPopup,
Spinner
} from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import { AttributeModel, BuildModelKey, ViewOptions } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import { FocusSelection, focusStore } from '../../selection'
import Menu from '../Menu.svelte'
import ListCategories from './ListCategories.svelte'
import ListHeader from './ListHeader.svelte'
import ListItem from './ListItem.svelte'
@ -42,17 +44,26 @@
export let loadingPropsLength: number | undefined
export let selectedObjectIds: Doc[]
export let itemModels: AttributeModel[]
export let selectedRowIndex: number | undefined
export let extraHeaders: AnyComponent[] | undefined
export let objectRefs: HTMLElement[] = []
export let flatHeaders = false
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 defaultLimit = 20
const singleCategoryLimit = 200
$: initialLimit = singleCat ? singleCategoryLimit : defaultLimit
$: limit = initialLimit
$: limit = !lastLevel ? items.length : initialLimit
let collapsed = true
@ -62,11 +73,11 @@
return items.slice(0, limit)
}
function initCollapsed (singleCat: boolean, category: any): void {
collapsed = !singleCat && items.length > autoFoldLimit
function initCollapsed (singleCat: boolean, lastLevel: boolean, category: any): void {
collapsed = !singleCat && items.length > (lastLevel ? autoFoldLimit : singleCategoryLimit)
}
$: initCollapsed(singleCat, category)
$: initCollapsed(singleCat, lastLevel, category)
const handleRowFocused = (object: Doc) => {
dispatch('row-focus', object)
@ -74,7 +85,7 @@
const handleMenuOpened = async (event: MouseEvent, object: Doc, rowIndex: number) => {
event.preventDefault()
selectedRowIndex = rowIndex
handleRowFocused(object)
if (!selectedObjectIdsSet.has(object._id)) {
dispatch('uncheckAll')
@ -83,24 +94,32 @@
const items = selectedObjectIds.length > 0 ? selectedObjectIds : object
showPopup(Menu, { object: items, baseMenuClass }, getEventPositionElement(event), () => {
selectedRowIndex = undefined
dispatch('row-focus')
})
}
$: limited = limitGroup(items, limit)
$: 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>
<ListHeader
{groupByKey}
{category}
{space}
{level}
limited={limited.length}
{items}
{headerComponent}
{createItemDialog}
{createItemLabel}
{extraHeaders}
{newObjectProps}
flat={flatHeaders}
{props}
on:more={() => {
@ -111,15 +130,45 @@
}}
/>
<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}
{#each limited as docObject, i (docObject._id)}
<ListItem
bind:use={objectRefs[initIndex + i]}
{docObject}
{elementByIndex}
{docByIndex}
{indexById}
model={itemModels}
index={initIndex + i}
{groupByKey}
selected={selectedRowIndex === initIndex + i}
selected={isSelected(docObject, $focusStore)}
checked={selectedObjectIdsSet.has(docObject._id)}
on:check={(ev) => dispatch('check', { docs: ev.detail.docs, value: ev.detail.value })}
on:contextmenu={(event) => handleMenuOpened(event, docObject, initIndex + i)}
@ -131,7 +180,7 @@
{/if}
{:else if loadingPropsLength !== undefined}
{#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="gridElement">
<CheckBox checked={false} />

View File

@ -43,18 +43,26 @@
export let extraHeaders: AnyComponent[] | undefined
export let flat = false
export let props: Record<string, any> = {}
export let level: number
export let newObjectProps: Record<string, any>
const dispatch = createEventDispatcher()
const handleCreateItem = (event: MouseEvent, category: string) => {
const handleCreateItem = (event: MouseEvent) => {
if (createItemDialog === undefined) return
showPopup(createItemDialog, { space, ...(groupByKey ? { [groupByKey]: category } : {}) }, eventToHTMLElement(event))
showPopup(createItemDialog, newObjectProps, eventToHTMLElement(event))
}
</script>
{#if headerComponent || groupByKey === noCategory}
<!-- 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">
<FixedColumn key={`list_groupBy_${groupByKey}`} justify={'left'}>
{#if groupByKey === noCategory}
@ -95,7 +103,7 @@
icon={IconAdd}
kind={'transparent'}
showTooltip={{ label: createItemLabel }}
on:click={(event) => handleCreateItem(event, category)}
on:click={handleCreateItem}
/>
{/if}
</div>
@ -110,7 +118,13 @@
min-height: 3rem;
min-width: 0;
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 {
background: var(--header-bg-color);

View File

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

View File

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

View File

@ -60,6 +60,7 @@ export default mergeIds(viewId, view, {
NoGrouping: '' as IntlString,
Grouping: '' 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 { 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 defaulOptions: ViewOptions = {
groupBy: noCategory,
groupBy: [noCategory],
orderBy: ['modifiedBy', SortingOrder.Descending]
}
@ -41,9 +48,37 @@ function _getViewOptions (viewlet: Viewlet): ViewOptions | null {
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 {
if (viewlet === undefined) {
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
*/
export type ViewOptions = {
groupBy: string
groupBy: string[]
orderBy: OrderOption
} & Record<string, any>

View File

@ -42,7 +42,7 @@
TooltipInstance
} from '@hcengineering/ui'
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 { getContext, onDestroy, onMount, tick } from 'svelte'
import { subscribeMobile } from '../mobile'
@ -77,6 +77,7 @@
let createItemLabel: IntlString | undefined
let apps: Application[] = []
migrateViewOpttions()
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('button:has-text("View")')
await page.click('.value >> nth=1')
await page.click('.ordering >> nth=0')
await page.click('text="Modified"')
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> {
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 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')
}
export async function setViewOrder (page: Page, orderName: string): Promise<void> {
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 expect(page.locator('.antiCard >> button >> nth=1')).toContainText(orderName)
await expect(page.locator('.antiCard >> .ordering >> button')).toContainText(orderName)
await page.keyboard.press('Escape')
}