diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index ea5e3e99d9..e8678e0cb8 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -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 + ) } diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index 1f69e517be..dd4737dd0d 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -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>>> } }) diff --git a/plugins/hr-resources/src/components/ScheduleRequests.svelte b/plugins/hr-resources/src/components/ScheduleRequests.svelte index 47206a36e4..00adb3d12d 100644 --- a/plugins/hr-resources/src/components/ScheduleRequests.svelte +++ b/plugins/hr-resources/src/components/ScheduleRequests.svelte @@ -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} diff --git a/plugins/hr-resources/src/components/ScheduleView.svelte b/plugins/hr-resources/src/components/ScheduleView.svelte index cfa7637d54..75aa356dc1 100644 --- a/plugins/hr-resources/src/components/ScheduleView.svelte +++ b/plugins/hr-resources/src/components/ScheduleView.svelte @@ -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 } } ) diff --git a/plugins/hr-resources/src/components/schedule/MonthTableView.svelte b/plugins/hr-resources/src/components/schedule/MonthTableView.svelte index 971aaf0d69..5e13b1ae84 100644 --- a/plugins/hr-resources/src/components/schedule/MonthTableView.svelte +++ b/plugins/hr-resources/src/components/schedule/MonthTableView.svelte @@ -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) } ], [ diff --git a/plugins/hr-resources/src/components/schedule/MonthView.svelte b/plugins/hr-resources/src/components/schedule/MonthView.svelte index 73c6d926e5..a2aad648bb 100644 --- a/plugins/hr-resources/src/components/schedule/MonthView.svelte +++ b/plugins/hr-resources/src/components/schedule/MonthView.svelte @@ -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)} diff --git a/plugins/hr-resources/src/components/schedule/ReportsPopup.svelte b/plugins/hr-resources/src/components/schedule/ReportsPopup.svelte new file mode 100644 index 0000000000..95fb875fa1 --- /dev/null +++ b/plugins/hr-resources/src/components/schedule/ReportsPopup.svelte @@ -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> diff --git a/plugins/hr-resources/src/utils.ts b/plugins/hr-resources/src/utils.ts index 3a07d3fd0f..d27bdd122c 100644 --- a/plugins/hr-resources/src/utils.ts +++ b/plugins/hr-resources/src/utils.ts @@ -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 } diff --git a/plugins/tracker-resources/src/components/issues/timereport/ReportsPopup.svelte b/plugins/tracker-resources/src/components/issues/timereport/ReportsPopup.svelte index b43c143604..7328c21f18 100644 --- a/plugins/tracker-resources/src/components/issues/timereport/ReportsPopup.svelte +++ b/plugins/tracker-resources/src/components/issues/timereport/ReportsPopup.svelte @@ -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', '', diff --git a/plugins/tracker-resources/src/components/projects/LeadPresenter.svelte b/plugins/tracker-resources/src/components/projects/LeadPresenter.svelte index c3828480e2..7dc4b509fb 100644 --- a/plugins/tracker-resources/src/components/projects/LeadPresenter.svelte +++ b/plugins/tracker-resources/src/components/projects/LeadPresenter.svelte @@ -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 diff --git a/plugins/tracker-resources/src/components/sprints/SprintBrowser.svelte b/plugins/tracker-resources/src/components/sprints/SprintBrowser.svelte index 8057f8f4de..173b72f911 100644 --- a/plugins/tracker-resources/src/components/sprints/SprintBrowser.svelte +++ b/plugins/tracker-resources/src/components/sprints/SprintBrowser.svelte @@ -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> diff --git a/plugins/tracker-resources/src/components/sprints/SprintContent.svelte b/plugins/tracker-resources/src/components/sprints/SprintContent.svelte new file mode 100644 index 0000000000..f8122b1c33 --- /dev/null +++ b/plugins/tracker-resources/src/components/sprints/SprintContent.svelte @@ -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} diff --git a/plugins/tracker-resources/src/components/sprints/SprintLeadPresenter.svelte b/plugins/tracker-resources/src/components/sprints/SprintLeadPresenter.svelte new file mode 100644 index 0000000000..96d6a5298b --- /dev/null +++ b/plugins/tracker-resources/src/components/sprints/SprintLeadPresenter.svelte @@ -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} diff --git a/plugins/tracker-resources/src/components/sprints/SprintList.svelte b/plugins/tracker-resources/src/components/sprints/SprintList.svelte deleted file mode 100644 index 26bd0320b5..0000000000 --- a/plugins/tracker-resources/src/components/sprints/SprintList.svelte +++ /dev/null @@ -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> diff --git a/plugins/tracker-resources/src/components/sprints/SprintListBrowser.svelte b/plugins/tracker-resources/src/components/sprints/SprintListBrowser.svelte deleted file mode 100644 index f40b1f5478..0000000000 --- a/plugins/tracker-resources/src/components/sprints/SprintListBrowser.svelte +++ /dev/null @@ -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) - }} -/> diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 0a47de049b..95c61a8c82 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -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[] }) => diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index ab5cb6b03b..5376b0f253 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -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, diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index c2a8bc31bc..2b2a9b86cb 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -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'