mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-06 17:57:42 +02:00
Add Timeline component (#2535)
Signed-off-by: Alexander Platov <sas_lord@mail.ru>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user