Sprints List and Reports view (#2602)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2023-02-08 15:30:54 +07:00
committed by GitHub
parent 9c9b3091dc
commit 769c9bd341
18 changed files with 467 additions and 483 deletions
@@ -66,7 +66,7 @@
<TableBrowser
showFilterBar={false}
_class={tracker.class.TimeSpendReport}
query={{ attachedTo: { $in: [issue._id, ...issue.childInfo.map((it) => it.childId)] } }}
query={{ attachedTo: { $in: [issue._id, ...(issue.childInfo?.map((it) => it.childId) ?? [])] } }}
config={[
'$lookup.attachedTo',
'',
@@ -27,7 +27,7 @@
export let value: Employee | null
export let _class: Ref<Class<Project | Sprint>>
export let size: IconSize = 'x-small'
export let parentId: Ref<Project>
export let parentId: Ref<Doc>
export let defaultClass: Ref<Class<Doc>> | undefined = undefined
export let isEditable: boolean = true
export let shouldShowLabel: boolean = false
@@ -54,7 +54,7 @@
return
}
const currentParent = await client.findOne(_class, { _id: parentId })
const currentParent = await client.findOne(_class, { _id: parentId as Ref<Project> })
if (currentParent === undefined) {
return
@@ -13,62 +13,84 @@
// limitations under the License.
-->
<script lang="ts">
import contact from '@hcengineering/contact'
import { DocumentQuery, FindOptions, SortingOrder, WithLookup } from '@hcengineering/core'
import { DocumentQuery, WithLookup } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
import { getClient } from '@hcengineering/presentation'
import { Sprint } from '@hcengineering/tracker'
import { Button, defaultSP, Icon, IconAdd, Label, Scroller, showPopup } from '@hcengineering/ui'
import { Button, IconAdd, Label, SearchEdit, showPopup } from '@hcengineering/ui'
import view, { Viewlet } from '@hcengineering/view'
import {
FilterBar,
FilterButton,
getActiveViewletId,
getViewOptions,
setActiveViewletId,
ViewletSettingButton
} from '@hcengineering/view-resources'
import tracker from '../../plugin'
import { getIncludedSprintStatuses, sprintTitleMap, SprintViewMode } from '../../utils'
import NewSprint from './NewSprint.svelte'
import SprintDatePresenter from './SprintDatePresenter.svelte'
import SprintListBrowser from './SprintListBrowser.svelte'
import SprintProjectEditor from './SprintProjectEditor.svelte'
import SprintContent from './SprintContent.svelte'
export let label: IntlString
export let query: DocumentQuery<Sprint> = {}
export let search: string = ''
export let mode: SprintViewMode = 'all'
const ENTRIES_LIMIT = 200
const resultSprintsQuery = createQuery()
const sprintOptions: FindOptions<Sprint> = {
sort: { startDate: SortingOrder.Descending },
limit: ENTRIES_LIMIT,
lookup: {
lead: contact.class.Employee,
project: tracker.class.Project
}
const space = typeof query.space === 'string' ? query.space : tracker.team.DefaultTeam
const showCreateDialog = async () => {
showPopup(NewSprint, { space, targetElement: null }, 'top')
}
let resultSprints: WithLookup<Sprint>[] = []
export let panelWidth: number = 0
let viewlet: WithLookup<Viewlet> | undefined = undefined
let searchQuery: DocumentQuery<Sprint> = { ...query }
function updateSearchQuery (search: string): void {
searchQuery = search === '' ? { ...query } : { ...query, $search: search }
}
$: if (query) updateSearchQuery(search)
$: includedSprintStatuses = getIncludedSprintStatuses(mode)
$: title = sprintTitleMap[mode]
$: includedSprintsQuery = { status: { $in: includedSprintStatuses } }
$: baseQuery = {
...includedSprintsQuery,
...query
const client = getClient()
let resultQuery: DocumentQuery<Sprint> = { ...searchQuery }
let viewlets: WithLookup<Viewlet>[] = []
$: update()
async function update (): Promise<void> {
viewlets = await client.findAll(
view.class.Viewlet,
{ attachTo: tracker.class.Sprint },
{
lookup: {
descriptor: view.class.ViewletDescriptor
}
}
)
const _id = getActiveViewletId()
viewlet = viewlets.find((viewlet) => viewlet._id === _id) || viewlets[0]
setActiveViewletId(viewlet._id)
}
$: resultQuery = search === '' ? baseQuery : { $search: search, ...baseQuery }
$: resultSprintsQuery.query<Sprint>(
tracker.class.Sprint,
{ ...resultQuery },
(result) => {
resultSprints = result
},
sprintOptions
)
const space = typeof query.space === 'string' ? query.space : tracker.team.DefaultTeam
const showCreateDialog = async () => {
showPopup(NewSprint, { space, targetElement: null }, 'top')
let asideFloat: boolean = false
let asideShown: boolean = true
$: if (panelWidth < 900 && !asideFloat) asideFloat = true
$: if (panelWidth >= 900 && asideFloat) {
asideFloat = false
asideShown = false
}
let docWidth: number
let docSize: boolean = false
$: if (docWidth <= 900 && !docSize) docSize = true
$: if (docWidth > 900 && docSize) docSize = false
$: viewOptions = getViewOptions(viewlet)
const handleViewModeChanged = (newMode: SprintViewMode) => {
if (newMode === undefined || newMode === mode) {
@@ -77,18 +99,25 @@
mode = newMode
}
const retrieveMembers = (s: Sprint) => s.members
</script>
<div class="fs-title flex-between header">
<div class="flex-center">
<div class="flex-row-center">
<Label {label} />
<div class="projectTitle">
› <Label label={title} />
</div>
<div class="ml-4">
<FilterButton _class={tracker.class.Issue} {space} />
</div>
</div>
<div class="flex-row-center gap-2">
<SearchEdit bind:value={search} on:change={() => {}} />
<Button size="small" icon={IconAdd} label={tracker.string.Sprint} kind={'primary'} on:click={showCreateDialog} />
{#if viewlet}
<ViewletSettingButton bind:viewOptions {viewlet} />
{/if}
</div>
<Button size="small" icon={IconAdd} label={tracker.string.Sprint} kind={'primary'} on:click={showCreateDialog} />
</div>
<div class="itemsContainer">
<div class="flex-center">
@@ -130,66 +159,23 @@
/>
</div>
</div>
<!-- <div class="ml-3 filterButton">
<Button
size="small"
icon={IconAdd}
kind={'link-bordered'}
borderStyle={'dashed'}
label={tracker.string.Filter}
on:click={() => {}}
/>
</div> -->
</div>
<!-- <div class="flex-center">
<div class="flex-center">
<div class="buttonWrapper">
<Button selected size="small" shape="rectangle-right" icon={tracker.icon.ProjectsList} />
</div>
<div class="buttonWrapper">
<Button size="small" shape="rectangle-left" icon={tracker.icon.ProjectsTimeline} />
</div>
</div>
<div class="ml-3">
<Button size="small" icon={IconOptions} />
</div>
</div> -->
</div>
<div class="w-full h-full clear-mins">
<Scroller fade={defaultSP}>
<SprintListBrowser
_class={tracker.class.Sprint}
itemsConfig={[
{ key: '', presenter: Icon, props: { icon: tracker.icon.Sprint, size: 'small' } },
{ key: '', presenter: tracker.component.SprintPresenter, props: { kind: 'list' } },
{ key: '', presenter: SprintProjectEditor, props: { kind: 'list' } },
{
key: '$lookup.lead',
presenter: tracker.component.LeadPresenter,
props: {
_class: tracker.class.Sprint,
defaultClass: contact.class.Employee,
shouldShowLabel: false,
size: 'x-small'
}
},
{
key: '',
presenter: contact.component.MembersPresenter,
props: {
kind: 'link',
intlTitle: tracker.string.SprintMembersTitle,
intlSearchPh: tracker.string.SprintMembersSearchPlaceholder,
retrieveMembers
}
},
{ key: '', presenter: SprintDatePresenter, props: { field: 'startDate' } },
{ key: '', presenter: SprintDatePresenter, props: { field: 'targetDate' } },
{ key: '', presenter: tracker.component.SprintStatusPresenter }
]}
sprints={resultSprints}
/>
</Scroller>
<FilterBar
_class={tracker.class.Sprint}
query={searchQuery}
{viewOptions}
on:change={(e) => (resultQuery = e.detail)}
/>
<div class="flex w-full h-full clear-mins">
{#if viewlet}
<SprintContent {viewlet} query={{ ...resultQuery, ...includedSprintsQuery }} {space} {viewOptions} />
{/if}
{#if $$slots.aside !== undefined && asideShown}
<div class="popupPanel-body__aside flex" class:float={asideFloat} class:shown={asideShown}>
<slot name="aside" />
</div>
{/if}
</div>
<style lang="scss">
@@ -220,8 +206,4 @@
margin-right: 0;
}
}
// .filterButton {
// color: var(--caption-color);
// }
</style>
@@ -0,0 +1,51 @@
<script lang="ts">
import contact from '@hcengineering/contact'
import { DocumentQuery, Ref, Space, WithLookup } from '@hcengineering/core'
import { Sprint } from '@hcengineering/tracker'
import { Component } from '@hcengineering/ui'
import { BuildModelKey, Viewlet, ViewOptions } from '@hcengineering/view'
import tracker from '../../plugin'
import NewSprint from './NewSprint.svelte'
export let viewlet: WithLookup<Viewlet>
export let query: DocumentQuery<Sprint> = {}
export let space: Ref<Space> | undefined
// Extra properties
export let viewOptions: ViewOptions
const createItemDialog = NewSprint
const createItemLabel = tracker.string.CreateSprint
const retrieveMembers = (s: Sprint) => s.members
function updateConfig (config: (string | BuildModelKey)[]): (string | BuildModelKey)[] {
return config.map((it) => {
if (typeof it === 'string') {
return it
}
return it.presenter === contact.component.MembersPresenter
? { ...it, props: { ...it.props, retrieveMembers } }
: it
})
}
</script>
{#if viewlet?.$lookup?.descriptor?.component}
<Component
is={viewlet.$lookup.descriptor.component}
props={{
_class: tracker.class.Sprint,
config: updateConfig(viewlet.config),
options: viewlet.options,
createItemDialog,
createItemLabel,
viewlet,
viewOptions,
viewOptionsConfig: viewlet.viewOptions?.other,
space,
query,
props: {}
}}
/>
{/if}
@@ -0,0 +1,105 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import contact, { Employee } from '@hcengineering/contact'
import { Class, Doc, Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { getClient, UsersPopup } from '@hcengineering/presentation'
import { Sprint } from '@hcengineering/tracker'
import { eventToHTMLElement, IconSize, showPopup } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import { getObjectPresenter } from '@hcengineering/view-resources'
import tracker from '../../plugin'
import LeadPopup from '../projects/LeadPopup.svelte'
export let value: Employee | null
export let size: IconSize = 'x-small'
export let object: Sprint
export let defaultClass: Ref<Class<Doc>> | undefined = undefined
export let isEditable: boolean = true
export let shouldShowLabel: boolean = false
export let defaultName: IntlString | undefined = undefined
const client = getClient()
let presenter: AttributeModel | undefined
$: if (value || defaultClass) {
if (value) {
getObjectPresenter(client, value._class, { key: '' }).then((p) => {
presenter = p
})
} else if (defaultClass) {
getObjectPresenter(client, defaultClass, { key: '' }).then((p) => {
presenter = p
})
}
}
const handleLeadChanged = async (result: Employee | null | undefined) => {
if (!isEditable || result === undefined) {
return
}
const newLead = result === null ? null : result._id
await client.update(object, { lead: newLead })
}
const handleLeadEditorOpened = async (event: MouseEvent) => {
if (!isEditable) {
return
}
showPopup(
UsersPopup,
{
_class: contact.class.Employee,
selected: value?._id,
docQuery: {
active: true
},
allowDeselect: true,
placeholder: tracker.string.ProjectLeadSearchPlaceholder
},
eventToHTMLElement(event),
handleLeadChanged
)
}
</script>
{#if value && presenter}
<svelte:component
this={presenter.presenter}
{value}
{defaultName}
avatarSize={size}
isInteractive={true}
shouldShowPlaceholder={true}
shouldShowName={shouldShowLabel}
onEmployeeEdit={handleLeadEditorOpened}
tooltipLabels={{ component: LeadPopup, props: { lead: value } }}
/>
{:else if presenter}
<svelte:component
this={presenter.presenter}
{value}
{defaultName}
avatarSize={size}
isInteractive={true}
shouldShowPlaceholder={true}
shouldShowName={shouldShowLabel}
onEmployeeEdit={handleLeadEditorOpened}
tooltipLabels={{ personLabel: tracker.string.AssignedTo, placeholderLabel: tracker.string.AssignTo }}
/>
{/if}
@@ -1,292 +0,0 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import contact from '@hcengineering/contact'
import { Class, Doc, FindOptions, getObjectValue, Ref, WithLookup } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Issue, Project, Sprint } from '@hcengineering/tracker'
import { CheckBox, ExpandCollapse, Spinner, tooltip } from '@hcengineering/ui'
import { BuildModelKey } from '@hcengineering/view'
import { buildModel, LoadingProps } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import SprintProjectEditor from './SprintProjectEditor.svelte'
export let _class: Ref<Class<Doc>>
export let itemsConfig: (BuildModelKey | string)[]
export let selectedObjectIds: Doc[] = []
export let selectedRowIndex: number | undefined = undefined
export let sprints: WithLookup<Sprint>[] | undefined = undefined
export let loadingProps: LoadingProps | undefined = undefined
const dispatch = createEventDispatcher()
const client = getClient()
const objectRefs: HTMLElement[] = []
const baseOptions: FindOptions<Issue> = {
lookup: {
assignee: contact.class.Employee,
status: tracker.class.IssueStatus
}
}
$: options = { ...baseOptions } as FindOptions<Sprint>
$: selectedObjectIdsSet = new Set<Ref<Doc>>(selectedObjectIds.map((it) => it._id))
$: objectRefs.length = sprints?.length ?? 0
$: byProject = sprints?.reduce((s, cur) => {
const pid = cur.project ?? ''
s.set(pid, [...(s.get(pid) ?? []), cur])
return s
}, new Map<Ref<Project> | '', WithLookup<Sprint>[]>())
export const onObjectChecked = (docs: Doc[], value: boolean) => {
dispatch('check', { docs, value })
}
const handleRowFocused = (object: Doc) => {
dispatch('row-focus', object)
}
export const onElementSelected = (offset: 1 | -1 | 0, docObject?: Doc) => {
if (!sprints) {
return
}
let position =
(docObject !== undefined ? sprints?.findIndex((x) => x._id === docObject?._id) : selectedRowIndex) ?? -1
position += offset
if (position < 0) {
position = 0
}
if (position >= sprints.length) {
position = sprints.length - 1
}
const objectRef = objectRefs[position]
selectedRowIndex = position
handleRowFocused(sprints[position])
if (objectRef) {
objectRef.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}
const getLoadingElementsLength = (props: LoadingProps, options?: FindOptions<Doc>) => {
if (options?.limit && options?.limit > 0) {
return Math.min(options.limit, props.length)
}
return props.length
}
const isCollapsedMap: Record<any, boolean> = {}
$: {
const exkeys = new Set(Object.keys(isCollapsedMap))
for (const c of byProject?.keys() ?? []) {
if (!exkeys.delete(c)) {
isCollapsedMap[c] = false
}
}
for (const k of exkeys) {
delete isCollapsedMap[k]
}
}
const handleCollapseCategory = (category: any) => (isCollapsedMap[category] = !isCollapsedMap[category])
</script>
{#await buildModel({ client, _class, keys: itemsConfig, lookup: options.lookup }) then itemModels}
<div class="listRoot">
{#if sprints}
{#each Array.from(byProject?.entries() ?? []) as e}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="flex-between categoryHeader row" on:click={() => handleCollapseCategory(e[0])}>
<div class="flex-row-center gap-2 clear-mins">
<SprintProjectEditor
isEditable={false}
value={e[1][0]}
enlargedText={true}
kind={'list-header'}
shouldShowPlaceholder={false}
/>
</div>
</div>
<ExpandCollapse isExpanded={!isCollapsedMap[e[0]]} duration={400}>
{#each e[1] as docObject (docObject._id)}
<div
bind:this={objectRefs[sprints.findIndex((x) => x === docObject)]}
class="listGrid"
class:mListGridChecked={selectedObjectIdsSet.has(docObject._id)}
class:mListGridFixed={selectedRowIndex === sprints.findIndex((x) => x === docObject)}
class:mListGridSelected={selectedRowIndex === sprints.findIndex((x) => x === docObject)}
on:focus={() => {}}
on:mouseover={() => handleRowFocused(docObject)}
>
<div class="contentWrapper">
{#each itemModels as attributeModel, attributeModelIndex}
{#if attributeModelIndex === 0}
<div class="gridElement">
<div
class="eListGridCheckBox"
use:tooltip={{ direction: 'bottom', label: tracker.string.SelectIssue }}
>
<CheckBox
checked={selectedObjectIdsSet.has(docObject._id)}
on:value={(event) => {
onObjectChecked([docObject], event.detail)
}}
/>
</div>
<div class="iconPresenter">
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
{...attributeModel.props}
/>
</div>
</div>
{:else if attributeModelIndex === 1}
<div class="projectPresenter flex-grow">
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
{...attributeModel.props}
/>
</div>
<div class="filler" />
{:else}
<div class="gridElement">
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
parentId={docObject._id}
sprintId={docObject._id}
{...attributeModel.props}
/>
</div>
{/if}
{/each}
</div>
</div>
{/each}
</ExpandCollapse>
{/each}
{:else if loadingProps !== undefined}
{#each Array(getLoadingElementsLength(loadingProps, options)) as _, rowIndex}
<div class="listGrid" class:fixed={rowIndex === selectedRowIndex}>
<div class="contentWrapper">
<div class="gridElement">
<CheckBox checked={false} />
<div class="ml-4">
<Spinner size="small" />
</div>
</div>
</div>
</div>
{/each}
{/if}
</div>
{/await}
<style lang="scss">
.listRoot {
width: 100%;
}
.categoryHeader {
position: sticky;
top: 0;
padding: 0 1.5rem 0 2.25rem;
height: 3rem;
min-height: 3rem;
min-width: 0;
background-color: var(--accent-bg-color);
z-index: 5;
}
.contentWrapper {
display: flex;
align-items: center;
height: 100%;
padding-left: 0.75rem;
padding-right: 1.15rem;
}
.listGrid {
width: 100%;
height: 3.25rem;
color: var(--theme-caption-color);
border-bottom: 1px solid var(--theme-button-border-hovered);
&.mListGridChecked {
background-color: var(--theme-table-bg-hover);
.eListGridCheckBox {
opacity: 1;
}
}
&.mListGridSelected {
background-color: var(--menu-bg-select);
}
.eListGridCheckBox {
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
&:hover {
opacity: 1;
}
}
}
.filler {
display: flex;
flex-grow: 1;
}
.gridElement {
display: flex;
align-items: center;
justify-content: flex-start;
margin-left: 0.5rem;
&:first-child {
margin-left: 0;
}
}
.iconPresenter {
padding-left: 0.45rem;
}
.projectPresenter {
display: flex;
align-items: center;
flex-shrink: 0;
width: 5.5rem;
margin-left: 0.5rem;
}
</style>
@@ -1,72 +0,0 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import type { Class, Doc, Ref, WithLookup } from '@hcengineering/core'
import { Sprint } from '@hcengineering/tracker'
import { BuildModelKey } from '@hcengineering/view'
import {
ActionContext,
focusStore,
ListSelectionProvider,
LoadingProps,
SelectDirection,
selectionStore
} from '@hcengineering/view-resources'
import { onMount } from 'svelte'
import SprintList from './SprintList.svelte'
export let _class: Ref<Class<Doc>>
export let itemsConfig: (BuildModelKey | string)[]
export let loadingProps: LoadingProps | undefined = undefined
export let sprints: WithLookup<Sprint>[] = []
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') {
sprintsList.onElementSelected(offset, of)
}
})
let sprintsList: SprintList
$: if (sprintsList !== undefined) {
listProvider.update(sprints)
}
onMount(() => {
;(document.activeElement as HTMLElement)?.blur()
})
</script>
<ActionContext
context={{
mode: 'browser'
}}
/>
<SprintList
bind:this={sprintsList}
{_class}
{itemsConfig}
{loadingProps}
{sprints}
selectedObjectIds={$selectionStore ?? []}
selectedRowIndex={listProvider.current($focusStore)}
on:row-focus={(event) => {
listProvider.updateFocus(event.detail ?? undefined)
}}
on:check={(event) => {
listProvider.updateSelection(event.detail.docs, event.detail.value)
}}
/>