Board: Initial checklist support (#1672)

Signed-off-by: Anna No <anna.no@xored.com>
This commit is contained in:
Anna No 2022-05-12 00:17:38 +07:00 committed by GitHub
parent 1c98ca3c3a
commit fe109a6b30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 447 additions and 86 deletions

View File

@ -194,6 +194,7 @@ input.search {
.justify-end { justify-content: flex-end; }
.justify-center { justify-content: center; }
.items-baseline { align-items: baseline; }
.items-center { align-items: center; }
.flex-gap-3 { gap: .75rem; }
.flex-gap-2 { gap: .5rem; }
@ -456,6 +457,7 @@ input.search {
.min-w-80 { min-width: 20rem; }
.min-w-min { min-width: min-content; }
.min-h-0 { min-height: 0; }
.min-h-7 { min-height: 1.75rem; }
.max-h-125 { max-height: 31.25rem; }
.max-h-60 { max-height: 15rem; }
.max-w-60 { max-width: 15rem; }
@ -540,6 +542,7 @@ a.no-line {
.text-lg { font-size: 1.125rem; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semi-bold { font-weight: 600; }
.fs-bold { font-weight: 500; }
.uppercase { text-transform: uppercase; }
.text-left { text-align: left; }
@ -549,6 +552,8 @@ a.no-line {
&:hover { text-decoration: underline; }
}
.text-line-through { text-decoration: line-through; }
.hidden-text {
position: absolute;
visibility: hidden;
@ -632,6 +637,7 @@ a.no-line {
.background-highlight-red { background-color: var(--highlight-red); }
.background-button-bg-color { background-color: var(--button-bg-color); }
.background-button-bg-enabled { background-color: var(--theme-button-bg-enabled); }
.background-button-noborder-bg-hover { background-color: var(--noborder-bg-hover); }
.background-menu-divider { background-color: var(--theme-menu-divider); }
.background-card-divider { background-color: var(--theme-card-divider); }
.background-primary-color { background-color: var(--primary-button-enabled); }

View File

@ -19,7 +19,7 @@
import Label from './Label.svelte'
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let label: IntlString
export let label: IntlString | undefined = undefined
export let placeholder: IntlString
export let items: ListItem[] = []
export let selected: ListItem | undefined = undefined

View File

@ -68,23 +68,28 @@
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={btns[i]}
class="menu-item flex-between"
class="flex-between menu-item"
disabled={item.isSelectable === false}
on:mouseover={(ev) => ev.currentTarget.focus()}
on:keydown={(ev) => keyDown(ev, i)}
on:click={() => {
dispatch('close', item)
if (item.isSelectable ?? true) {
dispatch('close', item)
}
}}
>
<div class="flex-center img" class:image={item.image}>
{#if item.image}
<img src={item.image} alt={item.label} />
{:else if typeof icon === 'string'}
<Icon {icon} size={'small'} />
{:else}
<svelte:component this={icon} size={'small'} />
{/if}
</div>
<div class="flex-grow caption-color">{item.label}</div>
{#if item.image || icon}
<div class="flex-center img" class:image={item.image}>
{#if item.image}
<img src={item.image} alt={item.label} />
{:else if typeof icon === 'string'}
<Icon {icon} size={'small'} />
{:else}
<svelte:component this={icon} size={'small'} />
{/if}
</div>
{/if}
<div class="flex-grow caption-color font-{item.fontWeight} pl-{item.paddingLeft}">{item.label}</div>
</button>
{/each}
</div>

View File

@ -23,8 +23,8 @@
export let label: IntlString | undefined = undefined
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let maxWidth: string | undefined
export let value: string | number | undefined
export let maxWidth: string | undefined = undefined
export let value: string | number | undefined = undefined
export let placeholder: IntlString = plugin.string.EditBoxPlaceholder
export let placeholderParam: any | undefined = undefined
export let format: 'text' | 'password' | 'number' = 'text'

View File

@ -37,7 +37,9 @@
<div class="container" on:click={click} class:cursor-pointer={editable}>
<div
class="bar"
style="background-color: {getPlatformColor(color)}; width: calc(100% * {Math.round((value - min) / proc)} / 100);"
style="background-color: {getPlatformColor(color)}; width: calc(100% * {proc !== 0
? Math.round((value - min) / proc)
: 0} / 100);"
/>
</div>

View File

@ -12,7 +12,7 @@
export let width: string | undefined = undefined
export let height: string | undefined = undefined
export let submitLabel: IntlString = ui.string.Save
export let placeholder: IntlString | undefined
export let placeholder: IntlString | undefined = undefined
const dispatch = createEventDispatcher()
let isEditing = false

View File

@ -25,6 +25,7 @@ export type {
AnySvelteComponent,
Action,
LabelAndProps,
ListItem,
TooltipAlignment,
AnySvelteComponentWithProps,
Location,

View File

@ -93,6 +93,9 @@ export interface ListItem {
_id: string
label: string
image?: string
isSelectable?: boolean
fontWeight?: 'normal' | 'medium' | 'semi-bold'
paddingLeft?: number
}
export interface DropdownTextItem {

View File

@ -38,6 +38,10 @@
"NoColor": "No color.",
"NoColorInfo": "This won't show up on the front of cards.",
"Checklist": "Checklist",
"AddChecklistItem": "Add an item",
"ChecklistDropdownNone": "(none)",
"ShowDoneChecklistItems": "Show checked items ({done})",
"HideDoneChecklistItems": "Hide checked items",
"Dates": "Dates",
"Attachments": "Attachments",
"AddAttachment": "Add an attachment",

View File

@ -38,6 +38,10 @@
"NoColor": "Без цвета.",
"NoColorInfo": "Не будет показываться на доске.",
"Checklist": "Списки",
"AddChecklistItem": "Добавить",
"ChecklistDropdownNone": "(не выбрано)",
"ShowDoneChecklistItems": "Показать отмеченные ({done})",
"HideDoneChecklistItems": "Скрыть отмеченные",
"Dates": "Дата",
"Attachments": "Прикрепленное",
"AddAttachment": "Прикрепить",

View File

@ -19,7 +19,7 @@
import { Panel } from '@anticrm/panel'
import { getResource } from '@anticrm/platform'
import { createQuery, getClient } from '@anticrm/presentation'
import type { State } from '@anticrm/task'
import type { State, TodoItem } from '@anticrm/task'
import task from '@anticrm/task'
import { StyledTextBox } from '@anticrm/text-editor'
import { Button, EditBox, Icon, Label } from '@anticrm/ui'
@ -30,6 +30,7 @@
import { updateCard } from '../utils/CardUtils'
import CardActions from './editor/CardActions.svelte'
import CardAttachments from './editor/CardAttachments.svelte'
import CardChecklist from './editor/CardChecklist.svelte'
import CardDetails from './editor/CardDetails.svelte'
export let _id: Ref<Card>
@ -38,20 +39,33 @@
const client = getClient()
const cardQuery = createQuery()
const stateQuery = createQuery()
const checklistsQuery = createQuery()
let object: Card | undefined
let state: State | undefined
let handleMove: (e: Event) => void
let checklists: TodoItem[] = []
$: cardQuery.query(_class, { _id }, async (result) => {
function change (field: string, value: any) {
if (object) {
updateCard(client, object, field, value)
}
}
$: cardQuery.query(_class, { _id }, (result) => {
object = result[0]
})
$: object?.state &&
stateQuery.query(task.class.State, { _id: object.state }, async (result) => {
stateQuery.query(task.class.State, { _id: object.state }, (result) => {
state = result[0]
})
$: object &&
checklistsQuery.query(task.class.TodoItem, { space: object.space, attachedTo: object._id }, (result) => {
checklists = result
})
getCardActions(client, { _id: board.cardAction.Move }).then(async (result) => {
if (result[0]?.handler) {
const handler = await getResource(result[0].handler)
@ -63,12 +77,6 @@
}
})
function change (field: string, value: any) {
if (object) {
updateCard(client, object, field, value)
}
}
onMount(() => {
dispatch('open', { ignoreKeys: ['comments', 'number', 'title'] })
})
@ -130,8 +138,9 @@
</div>
</div>
<CardAttachments value={object} />
<!-- TODO checklists -->
<!-- <CardActivity bind:value={object} /> -->
{#each checklists as checklist}
<CardChecklist value={checklist} />
{/each}
</div>
</div>

View File

@ -1,57 +0,0 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 activity from '@anticrm/activity'
import type { Card } from '@anticrm/board'
import chunter from '@anticrm/chunter'
import { Button, Component, Icon, IconActivity, Label } from '@anticrm/ui'
import board from '../../plugin'
export let value: Card | undefined
let isActivityShown: boolean = true
</script>
{#if value !== undefined}
<div class="flex-col-stretch h-full w-full">
<div class="flex-row-stretch mt-4 mb-2">
<div class="w-9">
<Icon icon={IconActivity} size="large" />
</div>
<div class="flex-grow fs-title">
<Label label={activity.string.Activity} />
</div>
<Button
kind="no-border"
label={isActivityShown ? board.string.HideDetails : board.string.ShowDetails}
width="100px"
on:click={() => {
isActivityShown = !isActivityShown
}}
/>
</div>
<div class="flex-row-stretch">
<div class="w-9" />
<div class="w-full">
<Component is={chunter.component.CommentInput} props={{ object: value }} />
</div>
</div>
{#if isActivityShown === true}
<Component is={activity.component.Activity} props={{ object: value, showCommenInput: false, transparent: true }}>
<slot />
</Component>
{/if}
</div>
{/if}

View File

@ -0,0 +1,222 @@
<!--
// 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 { Ref } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation'
import type { TodoItem } from '@anticrm/task'
import task from '@anticrm/task'
import { Button, CheckBox, TextAreaEditor, Icon, IconMoreH, Progress, showPopup } from '@anticrm/ui'
import { ContextMenu, HTMLPresenter } from '@anticrm/view-resources'
import board from '../../plugin'
import { getPopupAlignment } from '../../utils/PopupUtils'
export let value: TodoItem
const client = getClient()
const checklistItemsQuery = createQuery()
let checklistItems: TodoItem[] = []
let done = 0
let isEditingName: boolean = false
let isAddingItem: boolean = false
let hideDoneItems: boolean = false
let newItemName = ''
let editingItemId: Ref<TodoItem> | undefined = undefined
let hovered: Ref<TodoItem> | undefined
function deleteChecklist () {
if (!value) {
return
}
client.removeCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection
)
}
function startAddingItem () {
isAddingItem = true
}
async function addItem (event: CustomEvent<string>) {
newItemName = ''
const item = {
name: event.detail ?? '',
assignee: null,
dueTo: null,
done: false
}
if (item.name.length <= 0) {
return
}
await client.addCollection(task.class.TodoItem, value.space, value._id, value._class, 'items', item)
}
function updateName (event: CustomEvent<string>) {
isEditingName = false
const name = event.detail
if (name !== undefined && name.length > 0 && name !== value.name) {
value.name = name
client.update(value, { name: value.name })
}
}
function updateItemName (item: TodoItem, name: string) {
if (name !== undefined && name.length > 0 && name !== value.name) {
item.name = name
client.update(item, { name: item.name })
}
}
async function setDoneToChecklistItem (item: TodoItem, event: CustomEvent<boolean>) {
const isDone = event.detail
if (!value) {
return
}
await client.update(item, { done: isDone })
}
function showItemMenu (item: TodoItem, e?: Event) {
showPopup(ContextMenu, { object: item }, getPopupAlignment(e))
}
$: checklistItemsQuery.query(task.class.TodoItem, { space: value.space, attachedTo: value._id }, (result) => {
checklistItems = result
done = checklistItems.reduce((result: number, current: TodoItem) => {
return current.done ? result + 1 : result
}, 0)
})
</script>
{#if value !== undefined}
<div class="flex-col w-full">
<div class="flex-row-stretch mt-4 mb-2">
<div class="w-9">
<Icon icon={board.icon.Card} size="large" />
</div>
{#if isEditingName}
<div class="flex-grow">
<TextAreaEditor
value={value.name}
on:submit={updateName}
on:cancel={() => {
isEditingName = false
}}
/>
</div>
{:else}
<div
class="flex-grow fs-title"
on:click={() => {
isEditingName = true
}}
>
{value.name}
</div>
{#if done > 0}
<div class="mr-1">
<Button
label={hideDoneItems ? board.string.ShowDoneChecklistItems : board.string.HideDoneChecklistItems}
labelParams={{ done }}
kind="no-border"
size="small"
on:click={() => {
hideDoneItems = !hideDoneItems
}}
/>
</div>
{/if}
<Button label={board.string.Delete} kind="no-border" size="small" on:click={deleteChecklist} />
{/if}
</div>
<div class="flex-row-stretch mb-2 mt-1">
<div class="w-9 text-sm pl-1 pr-1">
{checklistItems.length > 0 ? Math.round((done / checklistItems.length) * 100) : 0}%
</div>
<div class="flex-center flex-grow w-full">
<Progress min={0} max={checklistItems?.length ?? 0} value={done} />
</div>
</div>
{#each checklistItems.filter((item) => !hideDoneItems || !item.done) as item}
<div
class="flex-row-stretch mb-1 mt-1 pl-1 min-h-7 border-radius-1"
class:background-button-noborder-bg-hover={hovered === item._id && editingItemId !== item._id}
on:mouseover={() => {
hovered = item._id
}}
on:focus={() => {
hovered = item._id
}}
on:mouseout={() => {
hovered = undefined
}}
on:blur={() => {
hovered = undefined
}}
>
<div class="w-9 flex items-center">
<CheckBox bind:checked={item.done} on:value={(event) => setDoneToChecklistItem(item, event)} />
</div>
{#if editingItemId === item._id}
<div class="flex-grow">
<TextAreaEditor
value={item.name}
on:submit={(event) => {
editingItemId = undefined
updateItemName(item, event.detail)
}}
on:cancel={() => {
editingItemId = undefined
}}
/>
</div>
{:else}
<div
class="flex-col justify-center flex-gap-1 w-full"
class:text-line-through={item.done}
on:click={() => {
editingItemId = item._id
}}
>
<HTMLPresenter bind:value={item.name} />
</div>
<div class="flex-center">
<Button icon={IconMoreH} kind="transparent" size="small" on:click={(e) => showItemMenu(item, e)} />
</div>
{/if}
</div>
{/each}
<div class="flex-row-stretch mt-2 mb-2">
<div class="w-9" />
{#if isAddingItem}
<div class="w-full p-1">
<TextAreaEditor
bind:value={newItemName}
on:submit={addItem}
on:cancel={() => {
newItemName = ''
isAddingItem = false
}}
/>
</div>
{:else}
<Button label={board.string.AddChecklistItem} kind="no-border" size="small" on:click={startAddingItem} />
{/if}
</div>
</div>
{/if}

View File

@ -0,0 +1,152 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Card } from '@anticrm/board'
import { WithLookup } from '@anticrm/core'
import task, { TodoItem } from '@anticrm/task'
import { translate } from '@anticrm/platform'
import presentation, { createQuery, getClient } from '@anticrm/presentation'
import { Label, Button, Dropdown, EditBox, IconClose } from '@anticrm/ui'
import type { ListItem } from '@anticrm/ui'
import board from '../../plugin'
export let object: Card
const noneListItem: ListItem = {
_id: 'none',
label: ''
}
let name: string | undefined
let selectedTemplate: ListItem | undefined = undefined
let templateListItems: ListItem[] = [noneListItem]
let templatesMap: Map<string, TodoItem> = new Map()
const client = getClient()
const templatesQuery = createQuery()
const dispatch = createEventDispatcher()
translate(board.string.ChecklistDropdownNone, {}).then((result) => {
noneListItem.label = result
})
function close () {
dispatch('close')
}
async function addChecklist () {
if (!name || name.trim().length <= 0) {
return
}
const template = selectedTemplate ? templatesMap.get(selectedTemplate._id) : undefined
const items: TodoItem[] = template ? await client.findAll(task.class.TodoItem, { attachedTo: template._id }) : []
const checklistRef = await client.addCollection(
task.class.TodoItem,
object.space,
object._id,
object._class,
'todoItems',
{
name,
done: false,
dueTo: null,
assignee: null
}
)
if (items.length > 0) {
await Promise.all(
items.map((item) =>
client.addCollection(task.class.TodoItem, object.space, checklistRef, task.class.TodoItem, 'items', {
name: item.name,
dueTo: item.dueTo,
done: item.done,
assignee: item.assignee
})
)
)
}
dispatch('close')
}
$: templatesQuery.query(
board.class.Card,
{ todoItems: { $gt: 0 } },
(result: WithLookup<Card>[]) => {
templateListItems = [noneListItem]
templatesMap = new Map()
for (const card of result) {
const todoItems = card.$lookup?.todoItems as TodoItem[]
if (!todoItems) {
continue
}
templateListItems.push({
_id: card._id,
label: card.title,
fontWeight: 'semi-bold',
isSelectable: false
})
for (const todoItem of todoItems) {
templateListItems.push({
_id: todoItem._id,
label: todoItem.name,
paddingLeft: 4
})
templatesMap.set(todoItem._id, todoItem)
}
}
},
{ lookup: { _id: { todoItems: task.class.TodoItem } } }
)
</script>
<div class="antiPopup w-85">
<div class="relative flex-row-center w-full ">
<div class="flex-center flex-grow fs-title mt-1 mb-1">
<Label label={board.string.Checklist} />
</div>
<div class="absolute mr-1 mt-1 mb-1" style:top="0" style:right="0">
<Button icon={IconClose} kind="transparent" size="small" on:click={close} />
</div>
</div>
<div class="ap-space bottom-divider" />
<div class="flex-col ml-4 mt-4 mr-4 flex-gap-1">
<div class="text-md font-medium">
<Label label={board.string.Title} />
</div>
<div class="p-2 mt-1 mb-1 border-bg-accent border-radius-1">
<EditBox bind:value={name} maxWidth="100%" focus={true} />
</div>
</div>
<div class="flex-col ml-4 mt-4 mr-4 flex-gap-1">
<div class="text-md font-medium">
<Label label={board.string.Title} />
</div>
<div class="mt-1 mb-1 w-full">
<Dropdown
bind:selected={selectedTemplate}
items={templateListItems}
justify="left"
width="100%"
placeholder={board.string.ChecklistDropdownNone}
/>
</div>
</div>
<div class="ap-footer">
<Button
label={presentation.string.Add}
size="small"
kind="primary"
disabled={(name?.length ?? 0) <= 0}
on:click={addChecklist}
/>
</div>
</div>

View File

@ -27,6 +27,7 @@ import CreateCard from './components/CreateCard.svelte'
import EditCard from './components/EditCard.svelte'
import KanbanCard from './components/KanbanCard.svelte'
import KanbanView from './components/KanbanView.svelte'
import AddChecklist from './components/popups/AddChecklist.svelte'
import AttachmentPicker from './components/popups/AttachmentPicker.svelte'
import CardLabelsPopup from './components/popups/CardLabelsPopup.svelte'
import MoveCard from './components/popups/MoveCard.svelte'
@ -73,6 +74,10 @@ async function showCardLabelsPopup (object: Card, client: Client, e?: Event): Pr
showPopup(CardLabelsPopup, { object }, getPopupAlignment(e))
}
async function showChecklistsPopup (object: Card, client: Client, e?: Event): Promise<void> {
showPopup(AddChecklist, { object }, getPopupAlignment(e))
}
async function showEditMembersPopup (object: Card, client: Client, e?: Event): Promise<void> {
showPopup(
UsersPopup,
@ -126,6 +131,7 @@ export default async (): Promise<Resources> => ({
SendToBoard: unarchiveCard,
Delete: showDeleteCardPopup,
Members: showEditMembersPopup,
Checklist: showChecklistsPopup,
Copy: showCopyCardPopup,
Cover: showCoverPopup
},

View File

@ -59,6 +59,10 @@ export default mergeIds(boardId, board, {
NoColor: '' as IntlString,
NoColorInfo: '' as IntlString,
Checklist: '' as IntlString,
AddChecklistItem: '' as IntlString,
ChecklistDropdownNone: '' as IntlString,
ShowDoneChecklistItems: '' as IntlString,
HideDoneChecklistItems: '' as IntlString,
Dates: '' as IntlString,
Attachments: '' as IntlString,
AddAttachment: '' as IntlString,

View File

@ -55,7 +55,7 @@ export { default as LinkPresenter } from './components/LinkPresenter.svelte'
export * from './context'
export * from './selection'
export { buildModel, getCollectionCounter, getObjectPresenter, LoadingProps } from './utils'
export { Table, TableView, DocAttributeBar, EditDoc, ColorsPopup, Menu, SpacePresenter, UpDownNavigator }
export { HTMLPresenter, Table, TableView, DocAttributeBar, EditDoc, ColorsPopup, Menu, SpacePresenter, UpDownNavigator }
export default async (): Promise<Resources> => ({
actionImpl: actionImpl,