mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-13 11:50:56 +00:00
Multi group list (#2540)
Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
parent
17f7563d85
commit
aebb215799
@ -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)
|
||||
|
@ -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> {}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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} />
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -60,6 +60,7 @@
|
||||
|
||||
"FilteredViews": "Filtered views",
|
||||
"NewFilteredView": "New filtered view",
|
||||
"FilteredViewName": "Filtered view name"
|
||||
"FilteredViewName": "Filtered view name",
|
||||
"Than": "Than"
|
||||
}
|
||||
}
|
||||
|
@ -57,6 +57,7 @@
|
||||
"Manual": "Пользовательский",
|
||||
"FilteredViews": "Фильтрованные отображения",
|
||||
"NewFilteredView": "Новое фильтрованное отображение",
|
||||
"FilteredViewName": "Имя фильтрованного отображения"
|
||||
"FilteredViewName": "Имя фильтрованного отображения",
|
||||
"Than": "Затем"
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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">
|
||||
|
125
plugins/view-resources/src/components/list/ListCategories.svelte
Normal file
125
plugins/view-resources/src/components/list/ListCategories.svelte
Normal 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}
|
@ -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} />
|
||||
|
@ -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);
|
||||
|
@ -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}
|
||||
|
@ -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)
|
||||
}}
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -445,7 +445,7 @@ export interface ViewletPreference extends Preference {
|
||||
* @public
|
||||
*/
|
||||
export type ViewOptions = {
|
||||
groupBy: string
|
||||
groupBy: string[]
|
||||
orderBy: OrderOption
|
||||
} & Record<string, any>
|
||||
|
||||
|
@ -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) ?? []
|
||||
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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')
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user