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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 467 additions and 483 deletions

View File

@ -1241,6 +1241,10 @@ export function createModel (builder: Builder): void {
filters: ['priority', 'assignee', 'project', 'sprint', 'modifiedOn']
})
builder.mixin(tracker.class.Sprint, core.class.Class, view.mixin.ClassFilters, {
filters: ['status', 'project', 'lead', 'startDate', 'targetDate', 'modifiedOn', 'capacity']
})
builder.createDoc(
presentation.class.ObjectSearchCategory,
core.space.Model,
@ -1600,4 +1604,78 @@ export function createModel (builder: Builder): void {
view.component.NumberPresenter,
tracker.component.ReportedTimeEditor
)
const sprintOptions: ViewOptionsModel = {
groupBy: ['project', 'lead'],
orderBy: [
['startDate', SortingOrder.Descending],
['modifiedOn', SortingOrder.Descending],
['targetDate', SortingOrder.Descending],
['capacity', SortingOrder.Ascending]
],
other: []
}
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: tracker.class.Sprint,
descriptor: view.viewlet.List,
viewOptions: sprintOptions,
config: [
{
key: '',
presenter: tracker.component.SprintStatusPresenter,
props: { width: '1rem', kind: 'list', size: 'small', justify: 'center' }
},
{ key: '', presenter: tracker.component.SprintPresenter, props: { shouldUseMargin: true } },
{ key: '', presenter: view.component.GrowPresenter, props: { type: 'grow' } },
{ key: '', presenter: tracker.component.SprintProjectEditor, props: { kind: 'list' } },
{
key: '',
presenter: contact.component.MembersPresenter,
props: {
kind: 'link',
intlTitle: tracker.string.SprintMembersTitle,
intlSearchPh: tracker.string.SprintMembersSearchPlaceholder
}
},
{ key: '', presenter: tracker.component.SprintDatePresenter, props: { field: 'startDate' } },
{ key: '', presenter: tracker.component.SprintDatePresenter, props: { field: 'targetDate' } },
{
key: '$lookup.lead',
presenter: tracker.component.SprintLeadPresenter,
props: {
_class: tracker.class.Sprint,
defaultClass: contact.class.Employee,
shouldShowLabel: false,
size: 'x-small'
}
}
]
})
createAction(
builder,
{
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
actionProps: {
attribute: 'lead',
_class: contact.class.Employee,
query: {},
placeholder: tracker.string.SprintLead
},
label: tracker.string.SprintLead,
icon: contact.icon.Person,
keyBinding: [],
input: 'none',
category: tracker.category.Tracker,
target: tracker.class.Sprint,
context: {
mode: ['context'],
application: tracker.app.Tracker,
group: 'edit'
}
},
tracker.action.SetSprintLead
)
}

View File

@ -62,6 +62,7 @@ export default mergeIds(trackerId, tracker, {
},
action: {
NewRelatedIssue: '' as Ref<Action<Doc, Record<string, any>>>,
DeleteSprint: '' as Ref<Action<Doc, Record<string, any>>>
DeleteSprint: '' as Ref<Action<Doc, Record<string, any>>>,
SetSprintLead: '' as Ref<Action<Doc, Record<string, any>>>
}
})

View File

@ -51,6 +51,7 @@
{#each requests as request}
{#await getType(request) then type}
{#if type}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="request flex-center"
class:cursor-pointer={editable}

View File

@ -148,7 +148,7 @@
const reportQuery = createQuery()
import tracker from '@hcengineering/tracker'
import tracker, { Issue } from '@hcengineering/tracker'
import { EmployeeReports, fromTzDate, getEndDate, getStartDate } from '../utils'
let timeReports: Map<Ref<Employee>, EmployeeReports> = new Map()
@ -163,17 +163,24 @@
const newMap = new Map<Ref<Employee>, EmployeeReports>()
for (const r of res) {
if (r.employee != null) {
const or = newMap.get(r.employee)
newMap.set(r.employee, { value: (or?.value ?? 0) + r.value, reports: [...(or?.reports ?? []), r] })
const or = newMap.get(r.employee) ?? {
value: 0,
reports: [],
tasks: new Map()
}
const tsk = r.$lookup?.attachedTo as Issue
newMap.set(r.employee, {
value: or.value + r.value,
reports: [...or.reports, r],
tasks: or.tasks.set(tsk._id, tsk)
})
}
}
timeReports = newMap
},
{
lookup: {
_id: {
attachedTo: tracker.class.Issue
}
attachedTo: tracker.class.Issue
}
}
)

View File

@ -83,6 +83,18 @@
function getOverrideConfig (startDate: Date): Map<string, BuildModelKey> {
const typevals = getTypeVals(startDate)
const endDate = getEndDate(startDate.getFullYear(), startDate.getMonth())
const getReport = (id: Ref<Doc>): EmployeeReports => {
return timeReports.get(id as Ref<Employee>) ?? { value: 0, reports: [], tasks: new Map() }
}
const getTPD = (id: Ref<Doc>): number => {
const rr = getReport(id)
if (rr.value === 0) {
return 0
}
return rr.tasks.size / rr.value
}
return new Map<string, BuildModelKey>([
[
'@wdCount',
@ -109,12 +121,38 @@
presenter: ReportPresenter,
props: {
month: startDate ?? getStartDate(currentDate.getFullYear(), currentDate.getMonth()),
display: (staff: Staff) => (timeReports.get(staff._id) ?? { value: 0 }).value
display: (staff: Staff) => getReport(staff._id).value
},
sortingKey: '@wdCount',
sortingFunction: (a: Doc, b: Doc) =>
getTotal(getStatRequests(b._id as Ref<Staff>, startDate), startDate, endDate, types) -
getTotal(getStatRequests(a._id as Ref<Staff>, startDate), startDate, endDate, types)
sortingKey: '@wdCountReported',
sortingFunction: (a: Doc, b: Doc) => getReport(b._id).value - getReport(a._id).value
}
],
[
'@wdTaskCountReported',
{
key: '',
label: getEmbeddedLabel('Tasks'),
presenter: ReportPresenter,
props: {
month: startDate ?? getStartDate(currentDate.getFullYear(), currentDate.getMonth()),
display: (staff: Staff) => getReport(staff._id).tasks.size
},
sortingKey: '@wdTaskCountReported',
sortingFunction: (a: Doc, b: Doc) => getReport(b._id).tasks.size - getReport(a._id).tasks.size
}
],
[
'@wdTaskPerDayReported',
{
key: '',
label: getEmbeddedLabel('TPD'),
presenter: ReportPresenter,
props: {
month: startDate ?? getStartDate(currentDate.getFullYear(), currentDate.getMonth()),
display: (staff: Staff) => getTPD(staff._id)
},
sortingKey: '@wdTaskPerDayReported',
sortingFunction: (a: Doc, b: Doc) => getTPD(b._id) - getTPD(a._id)
}
],
[

View File

@ -39,6 +39,7 @@
import CreateRequest from '../CreateRequest.svelte'
import RequestsPopup from '../RequestsPopup.svelte'
import ScheduleRequests from '../ScheduleRequests.svelte'
import ReportsPopup from './ReportsPopup.svelte'
export let currentDate: Date = new Date()
@ -106,6 +107,13 @@
bottom: 3.5
}
}
function showReportInfo (employee: Staff, rTime: EmployeeReports | undefined): void {
if (rTime === undefined) {
return
}
showPopup(ReportsPopup, { employee, reports: rTime.reports }, 'top')
}
</script>
{#if departmentStaff.length}
@ -152,9 +160,14 @@
>
{getTotal(requests, startDate, endDate, types)}
</td>
<td class="p-1 text-center">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<td
class="p-1 text-center whitespace-nowrap cursor-pointer"
on:click={() => showReportInfo(employee, rTime)}
>
{#if rTime !== undefined}
{floorFractionDigits(rTime.value, 3)}
({rTime.tasks.size})
{:else}
0
{/if}
@ -166,6 +179,7 @@
{@const tooltipValue = getTooltip(requests)}
{@const ww = findReports(employee, day, timeReports)}
{#key [tooltipValue, editable]}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<td
class="w-9 max-w-9 min-w-9"
class:today={areDatesEqual(todayDate, day)}

View File

@ -0,0 +1,59 @@
<!--
// 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 { EmployeePresenter } from '@hcengineering/contact-resources'
import { FindOptions } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import presentation, { Card } from '@hcengineering/presentation'
import tracker, { TimeSpendReport } from '@hcengineering/tracker'
import { TableBrowser } from '@hcengineering/view-resources'
export let reports: TimeSpendReport[]
export let employee: Employee
export function canClose (): boolean {
return true
}
const options: FindOptions<TimeSpendReport> = {
lookup: {
attachedTo: tracker.class.Issue,
employee: contact.class.Employee
},
sort: {
date: -1
}
}
</script>
<Card
label={getEmbeddedLabel('Reports')}
canSave={true}
on:close
on:changeContent
okAction={() => {}}
okLabel={presentation.string.Ok}
>
<svelte:fragment slot="header">
<EmployeePresenter value={employee} disableClick />
</svelte:fragment>
<TableBrowser
showFilterBar={false}
_class={tracker.class.TimeSpendReport}
query={{ _id: { $in: reports.map((it) => it._id) } }}
config={['$lookup.attachedTo', '$lookup.attachedTo.title', '', 'employee', 'date']}
{options}
/>
</Card>

View File

@ -2,7 +2,7 @@ import { Employee, formatName } from '@hcengineering/contact'
import { Ref, TxOperations } from '@hcengineering/core'
import { Department, Request, RequestType, Staff, TzDate } from '@hcengineering/hr'
import { MessageBox } from '@hcengineering/presentation'
import { TimeSpendReport } from '@hcengineering/tracker'
import { Issue, TimeSpendReport } from '@hcengineering/tracker'
import { isWeekend, MILLISECONDS_IN_DAY, showPopup } from '@hcengineering/ui'
import hr from './plugin'
@ -235,5 +235,6 @@ export function tableToCSV (tableId: string, separator = ','): string {
export interface EmployeeReports {
reports: TimeSpendReport[]
tasks: Map<Ref<Issue>, Issue>
value: number
}

View File

@ -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',
'',

View File

@ -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

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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)
}}
/>

View File

@ -70,6 +70,10 @@ import Views from './components/views/Views.svelte'
import Statuses from './components/workflow/Statuses.svelte'
import RelatedIssuesSection from './components/issues/related/RelatedIssuesSection.svelte'
import RelatedIssueSelector from './components/issues/related/RelatedIssueSelector.svelte'
import SprintProjectEditor from './components/sprints/SprintProjectEditor.svelte'
import SprintDatePresenter from './components/sprints/SprintDatePresenter.svelte'
import SprintLeadPresenter from './components/sprints/SprintLeadPresenter.svelte'
import {
getIssueId,
getIssueTitle,
@ -380,7 +384,10 @@ export default async (): Promise<Resources> => ({
RelatedIssuesSection,
RelatedIssueSelector,
DeleteProjectPresenter,
TimeSpendReportPopup
TimeSpendReportPopup,
SprintProjectEditor,
SprintDatePresenter,
SprintLeadPresenter
},
completion: {
IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) =>

View File

@ -350,6 +350,9 @@ export default mergeIds(trackerId, tracker, {
SprintPresenter: '' as AnyComponent,
SprintStatusPresenter: '' as AnyComponent,
SprintTitlePresenter: '' as AnyComponent,
SprintProjectEditor: '' as AnyComponent,
SprintDatePresenter: '' as AnyComponent,
SprintLeadPresenter: '' as AnyComponent,
ReportedTimeEditor: '' as AnyComponent,
TimeSpendReport: '' as AnyComponent,
EstimationEditor: '' as AnyComponent,

View File

@ -98,6 +98,7 @@ export { default as ObjectPresenter } from './components/ObjectPresenter.svelte'
export { default as TableBrowser } from './components/TableBrowser.svelte'
export { default as ValueSelector } from './components/ValueSelector.svelte'
export { default as MarkupPreviewPopup } from './components/MarkupPreviewPopup.svelte'
export { default as MarkupPresenter } from './components/MarkupPresenter.svelte'
export * from './context'
export * from './filter'
export * from './selection'