Add Timeline component (#2535)

Signed-off-by: Alexander Platov <sas_lord@mail.ru>
This commit is contained in:
Alexander Platov
2023-01-24 20:43:06 +07:00
committed by GitHub
parent ba8ab45dc2
commit 8a5b84802c
20 changed files with 1469 additions and 126 deletions
@@ -18,8 +18,10 @@
import { IntlString } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
import { Project } from '@hcengineering/tracker'
import { Button, IconAdd, Label, showPopup } from '@hcengineering/ui'
import { Button, IconAdd, Label, showPopup, TabList } from '@hcengineering/ui'
import type { TabItem } from '@hcengineering/ui'
import tracker from '../../plugin'
import view from '@hcengineering/view'
import { getIncludedProjectStatuses, projectsTitleMap, ProjectsViewMode } from '../../utils'
import NewProject from './NewProject.svelte'
import ProjectsListBrowser from './ProjectsListBrowser.svelte'
@@ -28,6 +30,7 @@
export let query: DocumentQuery<Project> = {}
export let search: string = ''
export let mode: ProjectsViewMode = 'all'
export let viewMode: 'list' | 'timeline' = 'list'
const ENTRIES_LIMIT = 200
const resultProjectsQuery = createQuery()
@@ -72,6 +75,17 @@
mode = newMode
}
const modeList: TabItem[] = [
{ id: 'all', labelIntl: tracker.string.AllProjects, action: () => handleViewModeChanged('all') },
{ id: 'backlog', labelIntl: tracker.string.BacklogProjects, action: () => handleViewModeChanged('backlog') },
{ id: 'active', labelIntl: tracker.string.ActiveProjects, action: () => handleViewModeChanged('active') },
{ id: 'closed', labelIntl: tracker.string.ClosedProjects, action: () => handleViewModeChanged('closed') }
]
const viewList: TabItem[] = [
{ id: 'list', icon: view.icon.List, tooltip: view.string.List },
{ id: 'timeline', icon: view.icon.Timeline, tooltip: view.string.Timeline }
]
</script>
<div class="fs-title flex-between header">
@@ -84,45 +98,15 @@
<Button size="small" icon={IconAdd} label={tracker.string.Project} kind={'primary'} on:click={showCreateDialog} />
</div>
<div class="itemsContainer">
<div class="flex-center">
<div class="flex-center">
<div class="buttonWrapper">
<Button
size="small"
shape="rectangle-right"
selected={mode === 'all'}
label={tracker.string.AllProjects}
on:click={() => handleViewModeChanged('all')}
/>
</div>
<div class="buttonWrapper">
<Button
size="small"
shape="rectangle"
selected={mode === 'backlog'}
label={tracker.string.BacklogProjects}
on:click={() => handleViewModeChanged('backlog')}
/>
</div>
<div class="buttonWrapper">
<Button
size="small"
shape="rectangle"
selected={mode === 'active'}
label={tracker.string.ActiveProjects}
on:click={() => handleViewModeChanged('active')}
/>
</div>
<div class="buttonWrapper">
<Button
size="small"
shape="rectangle-left"
selected={mode === 'closed'}
label={tracker.string.ClosedProjects}
on:click={() => handleViewModeChanged('closed')}
/>
</div>
</div>
<div class="flex-row-center">
<TabList
items={modeList}
selected={mode}
kind={'normal'}
on:select={(result) => {
if (result.detail !== undefined && result.detail.action) result.detail.action()
}}
/>
<!-- <div class="ml-3 filterButton">
<Button
size="small"
@@ -134,19 +118,15 @@
/>
</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> -->
<TabList
items={viewList}
selected={viewMode}
kind={'secondary'}
size={'small'}
on:select={(result) => {
if (result.detail !== undefined && result.detail.id !== viewMode) viewMode = result.detail.id
}}
/>
</div>
<ProjectsListBrowser
_class={tracker.class.Project}
@@ -163,6 +143,7 @@
{ key: '', presenter: tracker.component.ProjectStatusPresenter }
]}
projects={resultProjects}
{viewMode}
/>
<style lang="scss">
@@ -182,16 +163,10 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 1.35rem 0.65rem 2.25rem;
border-bottom: 1px solid var(--theme-button-border-hovered);
}
.buttonWrapper {
margin-right: 1px;
&:last-child {
margin-right: 0;
}
padding: 0.65rem 0.75rem 0.65rem 2.25rem;
background-color: var(--board-bg-color);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
// .filterButton {
@@ -28,20 +28,7 @@
{#if value}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="flex-presenter flex-grow" on:click={navigateToProject}>
<span title={value.label} class="projectLabel flex-grow">{value.label}</span>
</div>
<span title={value.label} class="fs-bold caption-color overflow-label clear-mins" on:click={navigateToProject}>
{value.label}
</span>
{/if}
<style lang="scss">
.projectLabel {
display: block;
min-width: 0;
font-weight: 500;
text-align: left;
color: var(--theme-caption-color);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
@@ -40,28 +40,15 @@
</script>
{#if value}
{#if isEditable}
<ProjectStatusSelector
{kind}
{size}
{width}
{justify}
{isEditable}
{shouldShowLabel}
showTooltip={{ label: tracker.string.SetStatus }}
selectedProjectStatus={value.status}
onProjectStatusChange={handleProjectStatusChanged}
/>
{:else}
<ProjectStatusSelector
{kind}
{size}
{width}
{justify}
{isEditable}
{shouldShowLabel}
selectedProjectStatus={value.status}
onProjectStatusChange={handleProjectStatusChanged}
/>
{/if}
<ProjectStatusSelector
{kind}
{size}
{width}
{justify}
{isEditable}
{shouldShowLabel}
showTooltip={isEditable ? { label: tracker.string.SetStatus } : undefined}
selectedProjectStatus={value.status}
onProjectStatusChange={handleProjectStatusChanged}
/>
{/if}
@@ -26,19 +26,23 @@
import { Project } from '@hcengineering/tracker'
import { onMount } from 'svelte'
import ProjectsList from './ProjectsList.svelte'
import ProjectsTimeline from './ProjectsTimeline.svelte'
export let _class: Ref<Class<Doc>>
export let itemsConfig: (BuildModelKey | string)[]
export let loadingProps: LoadingProps | undefined = undefined
export let projects: Project[] = []
export let viewMode: 'list' | 'timeline' = 'list'
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') {
projectsList.onElementSelected(offset, of)
if (viewMode === 'list') projectsList.onElementSelected(offset, of)
else projectsTimeline.onElementSelected(offset, of)
}
})
let projectsList: ProjectsList
let projectsTimeline: ProjectsTimeline
$: if (projectsList !== undefined) {
listProvider.update(projects)
@@ -55,18 +59,36 @@
}}
/>
<ProjectsList
bind:this={projectsList}
{_class}
{itemsConfig}
{loadingProps}
{projects}
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)
}}
/>
{#if viewMode === 'list'}
<ProjectsList
bind:this={projectsList}
{_class}
{itemsConfig}
{loadingProps}
{projects}
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)
}}
/>
{:else}
<ProjectsTimeline
bind:this={projectsTimeline}
{_class}
{itemsConfig}
{loadingProps}
{projects}
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)
}}
/>
{/if}
@@ -0,0 +1,516 @@
<!--
// 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, Timestamp } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Issue, Project } from '@hcengineering/tracker'
import { CheckBox, Spinner, Timeline, TimelineRow } from '@hcengineering/ui'
import { AttributeModel, BuildModelKey } from '@hcengineering/view'
import { buildModel, getObjectPresenter, LoadingProps } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import ProjectPresenter from './ProjectPresenter.svelte'
export let _class: Ref<Class<Doc>>
export let itemsConfig: (BuildModelKey | string)[]
export let selectedObjectIds: Doc[] = []
export let selectedRowIndex: number | undefined = undefined
export let projects: Project[] | undefined = undefined
export let loadingProps: LoadingProps | undefined = undefined
const dispatch = createEventDispatcher()
const client = getClient()
const baseOptions: FindOptions<Issue> = {
lookup: {
assignee: contact.class.Employee,
status: tracker.class.IssueStatus
}
}
let personPresenter: AttributeModel
$: options = { ...baseOptions } as FindOptions<Project>
$: selectedObjectIdsSet = new Set<Ref<Doc>>(selectedObjectIds.map((it) => it._id))
let selectedRows: number[] = []
$: if (selectedObjectIdsSet.size > 0 && projects !== undefined) {
const tRows: number[] = []
selectedObjectIdsSet.forEach((it) => {
const index = projects?.findIndex((f) => f._id === it)
if (index !== undefined) tRows.push(index)
})
selectedRows = tRows
} else selectedRows = []
$: getObjectPresenter(client, contact.class.Person, { key: '' }).then((p) => {
personPresenter = p
})
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 (!projects) return
let position =
(docObject !== undefined ? projects?.findIndex((x) => x._id === docObject?._id) : selectedRowIndex) ?? -1
position += offset
if (position < 0) position = 0
if (position >= projects.length) position = projects.length - 1
selectedRowIndex = position
handleRowFocused(projects[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
}
let itemModels: AttributeModel[] | undefined = undefined
$: buildModel({ client, _class, keys: itemsConfig, lookup: options.lookup }).then((res) => (itemModels = res))
let lines: TimelineRow[] | undefined
$: lines = projects?.map((proj) => {
const tR: TimelineRow = { items: [] }
tR.items = [
{
icon: proj.icon,
presenter: ProjectPresenter,
props: { value: proj },
startDate: proj.startDate as Timestamp,
targetDate: proj.targetDate as Timestamp
}
]
return tR
})
</script>
{#if projects && itemModels && lines}
<Timeline
{lines}
{selectedRows}
selectedRow={selectedRowIndex}
on:row-focus={(ev) => {
if (ev.detail !== undefined && projects !== undefined) handleRowFocused(projects[ev.detail])
}}
on:check={(ev) => {
if (ev.detail !== undefined && projects !== undefined) onObjectChecked([projects[ev.detail.row]], ev.detail.value)
}}
>
<svelte:fragment let:row>
{#each itemModels as attributeModel, attributeModelIndex}
{#if attributeModelIndex === 0}
<div class="gridElement">
<div class="iconPresenter">
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, projects[row]) ?? ''}
{...attributeModel.props}
/>
</div>
</div>
{:else if attributeModelIndex === 1}
<div class="projectPresenter flex-grow">
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, projects[row]) ?? ''}
{...attributeModel.props}
/>
</div>
<div class="filler" />
{:else}
<div class="gridElement">
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, projects[row]) ?? ''}
parentId={projects[row]._id}
{...attributeModel.props}
/>
</div>
{/if}
{/each}
</svelte:fragment>
</Timeline>
{: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}
<style lang="scss">
.timeline-container {
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
& > * {
overscroll-behavior-x: contain;
}
}
.timeline-header {
display: flex;
align-items: center;
min-height: 4rem;
border-bottom: 1px solid var(--divider-color);
}
.timeline-header__title {
display: flex;
align-items: center;
flex-shrink: 0;
padding: 0 2.25rem;
height: 100%;
background-color: var(--body-accent);
box-shadow: var(--accent-shadow);
// z-index: 2;
}
.timeline-header__time {
// overflow: hidden;
position: relative;
flex-grow: 1;
height: 100%;
background-color: var(--body-color);
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 2rem),
rgba(0, 0, 0, 0) 100%
);
&-content {
width: 100%;
height: 100%;
will-change: transform;
.day,
.month {
position: absolute;
pointer-events: none;
}
.month {
width: max-content;
top: 0.25rem;
font-size: 1rem;
color: var(--accent-color);
&:first-letter {
text-transform: uppercase;
}
}
.day {
bottom: 0.5rem;
font-size: 1rem;
color: var(--content-color);
transform: translateX(-50%);
}
.cursor {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
padding-bottom: 1px;
width: 1.75rem;
height: 1.75rem;
bottom: 0.375rem;
font-size: 1rem;
font-weight: 600;
color: #fff;
background-color: var(--primary-bg-color);
border-radius: 50%;
transform: translateX(-50%);
pointer-events: none;
}
}
}
.todayMarker,
.monthMarker {
position: absolute;
top: 0;
bottom: 0;
width: 0;
height: 100%;
pointer-events: none;
}
.monthMarker {
border-left: 1px dashed var(--highlight-select);
}
.todayMarker {
border-left: 1px solid var(--primary-bg-color);
}
.timeline-background__headers,
.timeline-background__viewbox,
.timeline-foreground__viewbox {
overflow: hidden;
position: absolute;
top: 4rem;
bottom: 0;
height: 100%;
z-index: -1;
}
.timeline-background__headers {
left: 0;
background-color: var(--body-accent);
}
.timeline-background__viewbox,
.timeline-foreground__viewbox {
right: 0;
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 2rem),
rgba(0, 0, 0, 0) 100%
);
}
.timeline-foreground__viewbox {
z-index: 1;
pointer-events: none;
}
.timeline-splitter,
.timeline-splitter::before {
position: absolute;
top: 0;
bottom: 0;
height: 100%;
transform: translateX(-50%);
}
.timeline-splitter {
width: 1px;
background-color: var(--divider-color);
cursor: col-resize;
z-index: 3;
transition-property: width, background-color;
transition-timing-function: var(--timing-main);
transition-duration: 0.1s;
transition-delay: 0s;
&:hover {
width: 3px;
background-color: var(--button-border-hover);
transition-duration: 0.15s;
transition-delay: 0.3s;
}
&::before {
content: '';
width: 10px;
left: 50%;
}
&.moving {
width: 2px;
background-color: var(--primary-edit-border-color);
transition-duration: 0.1s;
transition-delay: 0s;
}
}
.headerWrapper {
display: flex;
align-items: center;
height: 100%;
min-width: 0;
padding-left: 0.75rem;
padding-right: 1.15rem;
// border-bottom: 1px solid var(--accent-bg-color);
}
.contentWrapper {
overflow: hidden;
position: relative;
display: flex;
align-items: center;
flex-grow: 1;
height: 100%;
min-width: 0;
min-height: 0;
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 0) 0,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 2rem),
rgba(0, 0, 0, 0) 100%
);
&.nullRow {
cursor: pointer;
}
}
.timeline-wrapped_content {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
will-change: transform;
}
.timeline-action__button,
.project-item {
position: absolute;
display: flex;
align-items: center;
padding: 0.5rem;
box-shadow: var(--button-shadow);
}
.project-item {
top: 0.25rem;
bottom: 0.25rem;
background-color: var(--button-bg-color);
border: 1px solid var(--button-border-color);
border-radius: 0.75rem;
&:hover {
background-color: var(--button-bg-hover);
border-color: var(--button-border-hover);
}
&.noTarget {
mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 1) 2rem);
border-right-color: transparent;
}
.project-presenter {
display: flex;
align-items: center;
.space {
flex-shrink: 0;
width: 0.25rem;
min-width: 0.25rem;
max-width: 0.25rem;
}
}
}
.timeline-action__button {
top: 0.625rem;
bottom: 0.625rem;
width: 2rem;
color: var(--content-color);
background-color: var(--button-bg-color);
border: 1px solid var(--button-border-color);
border-radius: 0.5rem;
&:hover {
color: var(--accent-color);
background-color: var(--button-bg-hover);
border-color: var(--button-border-hover);
}
&.left {
left: 1rem;
}
&.right {
right: 1rem;
}
&.add {
transform: translateX(-50%);
pointer-events: none;
}
}
.listGrid {
display: flex;
justify-content: stretch;
align-items: center;
flex-shrink: 0;
width: 100%;
height: 3.25rem;
min-height: 0;
color: var(--caption-color);
z-index: 2;
&.mListGridChecked {
.headerWrapper {
background-color: var(--highlight-select);
}
.contentWrapper {
background-color: var(--trans-content-05);
}
.eListGridCheckBox {
opacity: 1;
}
}
&.mListGridSelected {
.headerWrapper {
background-color: var(--highlight-select-hover);
}
.contentWrapper {
background-color: var(--trans-content-10);
}
}
.eListGridCheckBox {
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
&:hover .eListGridCheckBox {
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;
}
}
.projectPresenter {
display: flex;
align-items: center;
flex-shrink: 0;
width: 5.5rem;
margin-left: 0.5rem;
}
</style>