UBER-619: StatusPopup for creating/renaming (#3536)

Signed-off-by: Vyacheslav Tumanov <me@slavatumanov.me>
This commit is contained in:
Vyacheslav Tumanov 2023-08-04 23:07:04 +05:00 committed by GitHub
parent 161d26ebeb
commit cd52b8e684
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 244 additions and 153 deletions

View File

@ -73,6 +73,9 @@
"TodoItems": "Todos",
"Dashboard": "Dashboard",
"AllTime": "All time",
"RelatedIssues": "Related processes"
"RelatedIssues": "Related processes",
"StatusName": "Status name",
"StatusPopupTitle": "Create new status or edit name for existing",
"NameAlreadyExists": "This name already exists for this status type"
}
}

View File

@ -73,6 +73,9 @@
"TodoItems": "Todos",
"Dashboard": "Дашборд",
"AllTime": "Все время",
"RelatedIssues": "Связанные процессы"
"RelatedIssues": "Связанные процессы",
"StatusName": "Имя статуса",
"StatusPopupTitle": "Создание статуса и изменение имени существующего",
"NameAlreadyExists": "Данное имя уже исползутеся другим статусом этого типа"
}
}

View File

@ -0,0 +1,87 @@
<!--
// Copyright © 2023 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 { EditBox, Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import task from '../plugin'
import presentation, { Card, getClient } from '@hcengineering/presentation'
import { calcRank, DoneState, Kanban, KanbanTemplate, KanbanTemplateSpace, State } from '@hcengineering/task'
import { Class, Data, generateId, Ref, SortingOrder } from '@hcengineering/core'
const dispatch = createEventDispatcher()
const client = getClient()
const hierarchy = client.getHierarchy()
export let status: State | undefined = undefined
export let _class: Ref<Class<State | DoneState>> | undefined = status?._class
export let template: KanbanTemplate | undefined = undefined
export let value = status?.name ?? ''
const isTemplate = template !== undefined
export let space: Kanban | KanbanTemplateSpace | undefined
let canSave = true
async function save () {
if (space === undefined && template === undefined && status?.space === undefined) return
const attachedTo = isTemplate && template?._id ? { attachedTo: template._id } : {}
const kanban = space as Kanban
if (_class !== undefined && status === undefined) {
const query = isTemplate ? { ...attachedTo } : kanban?.attachedTo ? { space: kanban.attachedTo } : {}
const lastOne = await client.findOne(_class, query, { sort: { rank: SortingOrder.Descending } })
let newDoc: Data<State> = {
ofAttribute: task.attribute.State,
name: value.trim(),
rank: calcRank(lastOne, undefined),
...attachedTo
}
if (!hierarchy.isDerived(_class, task.class.DoneState)) {
newDoc = {
ofAttribute: task.attribute.State,
name: value.trim(),
color: 9,
rank: calcRank(lastOne, undefined),
...attachedTo
}
}
const ops = client.apply(template?.space ?? kanban?.attachedTo ?? generateId()).notMatch(_class, {
space: isTemplate && template ? template.space : kanban?.attachedTo,
name: value.trim(),
...attachedTo
})
await ops.createDoc(_class, isTemplate && template ? template.space : kanban?.attachedTo, newDoc)
canSave = await ops.commit()
}
if (status !== undefined && _class !== undefined) {
const ops = client.apply(status._id).notMatch(_class, { space: status.space, name: value.trim(), ...attachedTo })
await ops.update(status, { name: value.trim() })
canSave = await ops.commit()
}
if (canSave) dispatch('close')
}
</script>
<Card
label={task.string.StatusPopupTitle}
okAction={save}
canSave
okLabel={presentation.string.Save}
on:changeContent
onCancel={() => dispatch('close')}
>
<EditBox focusIndex={1} bind:value placeholder={task.string.StatusName} kind={'large-style'} autoFocus fullSize />
<svelte:fragment slot="error">
{#if !canSave}
<Label label={task.string.NameAlreadyExists} />
{/if}
</svelte:fragment>
</Card>

View File

@ -14,7 +14,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Ref, SortingOrder } from '@hcengineering/core'
import { Ref, SortingOrder } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import type { DoneState, Kanban, State } from '@hcengineering/task'
import task, { calcRank } from '@hcengineering/task'
@ -31,7 +31,6 @@
$: lostStates = doneStates.filter((x) => x._class === task.class.LostState)
const client = getClient()
const hierarchy = client.getHierarchy()
const statesQ = createQuery()
$: statesQ.query(
@ -73,33 +72,6 @@
rank: calcRank(prev, next)
})
}
async function onAdd (_class: Ref<Class<State | DoneState>>) {
const lastOne = await client.findOne(_class, {}, { sort: { rank: SortingOrder.Descending } })
if (hierarchy.isDerived(_class, task.class.DoneState)) {
await client.createDoc(_class, kanban.space, {
ofAttribute: task.attribute.State,
name: 'New Done State',
rank: calcRank(lastOne, undefined)
})
} else {
await client.createDoc(task.class.State, kanban.space, {
ofAttribute: task.attribute.State,
name: 'New State',
color: 9,
rank: calcRank(lastOne, undefined)
})
}
}
</script>
<StatesEditor
{states}
{wonStates}
{lostStates}
on:add={(e) => {
onAdd(e.detail)
}}
on:delete
on:move={onMove}
/>
<StatesEditor {states} {wonStates} {lostStates} space={kanban} on:delete on:move={onMove} />

View File

@ -15,7 +15,7 @@
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Ref, Space, SortingOrder, Class } from '@hcengineering/core'
import { Ref, Space, SortingOrder } from '@hcengineering/core'
import core from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import type {
@ -42,7 +42,6 @@
const dispatch = createEventDispatcher()
const client = getClient()
const hierarchy = client.getHierarchy()
const statesQ = createQuery()
$: statesQ.query(
@ -95,32 +94,6 @@
})
}
async function onAdd (_class: Ref<Class<State | DoneState>>) {
const lastOne = await client.findOne(
task.class.StateTemplate,
{ attachedTo: kanban._id },
{ sort: { rank: SortingOrder.Descending } }
)
if (hierarchy.isDerived(_class, task.class.DoneState)) {
const targetClass = _class === task.class.WonState ? task.class.WonStateTemplate : task.class.LostStateTemplate
await client.createDoc(targetClass, kanban.space, {
ofAttribute: task.attribute.DoneState,
name: 'New Done State',
rank: calcRank(lastOne, undefined),
attachedTo: kanban._id
})
} else {
await client.createDoc(task.class.StateTemplate, kanban.space, {
name: 'New State',
ofAttribute: task.attribute.DoneState,
color: 9,
rank: calcRank(lastOne, undefined),
attachedTo: kanban._id
})
}
}
function onDelete ({ detail: { state } }: { detail: { state: State | DoneState } }) {
if (space === undefined) {
return
@ -136,9 +109,6 @@
{states}
{wonStates}
{lostStates}
on:add={(e) => {
onAdd(e.detail)
}}
on:delete={onDelete}
on:move={onMove}
/>

View File

@ -14,9 +14,9 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Ref } from '@hcengineering/core'
import { AttributeEditor, getClient } from '@hcengineering/presentation'
import type { DoneState, KanbanTemplate, KanbanTemplateSpace, State } from '@hcengineering/task'
import { Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import type { DoneState, Kanban, KanbanTemplate, KanbanTemplateSpace, State } from '@hcengineering/task'
import {
CircleButton,
Component,
@ -32,7 +32,7 @@
showPopup,
themeStore
} from '@hcengineering/ui'
import { ColorsPopup } from '@hcengineering/view-resources'
import { ColorsPopup, StringPresenter } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import task from '../../plugin'
import Lost from '../icons/Lost.svelte'
@ -40,7 +40,7 @@
import StatusesPopup from './StatusesPopup.svelte'
export let template: KanbanTemplate | undefined = undefined
export let space: KanbanTemplateSpace | undefined = undefined
export let space: KanbanTemplateSpace | Kanban | undefined = undefined
export let states: State[] = []
export let wonStates: DoneState[] = []
export let lostStates: DoneState[] = []
@ -86,14 +86,11 @@
await client.updateDoc(state._class, state.space, state._id, { color })
}
async function onAdd (_class: Ref<Class<State | DoneState>>) {
dispatch('add', _class)
}
const spaceEditor = (space as KanbanTemplateSpace)?.editor
</script>
{#if space?.editor}
<Component is={space.editor} props={{ template }} />
{#if spaceEditor}
<Component is={spaceEditor} props={{ template }} />
{/if}
<div class="flex-no-shrink flex-between trans-title uppercase">
<Label label={task.string.ActiveStates} />
@ -101,7 +98,15 @@
icon={IconAdd}
size={'medium'}
on:click={() => {
onAdd(task.class.State)
showPopup(
task.component.CreateStatePopup,
{
space,
template,
_class: template !== undefined ? task.class.StateTemplate : task.class.State
},
undefined
)
}}
/>
</div>
@ -138,24 +143,28 @@
}}
/>
<div class="flex-grow caption-color">
<AttributeEditor maxWidth={'20rem'} _class={state._class} object={state} key="name" />
<StringPresenter value={state.name} oneLine />
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="tool hover-trans"
on:click={(ev) => {
showPopup(
StatusesPopup,
{
onDelete: () => dispatch('delete', { state }),
showDelete: states.length > 1,
onUpdate: () => {
showPopup(task.component.CreateStatePopup, { status: state, template }, undefined)
}
},
eventToHTMLElement(ev),
() => {}
)
}}
>
<IconMoreH size={'medium'} />
</div>
{#if states.length > 1}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="tool hover-trans"
on:click={(ev) => {
showPopup(
StatusesPopup,
{ onDelete: () => dispatch('delete', { state }) },
eventToHTMLElement(ev),
() => {}
)
}}
>
<IconMoreH size={'medium'} />
</div>
{/if}
</div>
{/if}
{/each}
@ -167,7 +176,15 @@
icon={IconAdd}
size={'medium'}
on:click={() => {
onAdd(task.class.WonState)
showPopup(
task.component.CreateStatePopup,
{
space,
template,
_class: template !== undefined ? task.class.WonStateTemplate : task.class.WonState
},
undefined
)
}}
/>
</div>
@ -185,24 +202,29 @@
<Won size={'medium'} />
</div>
<div class="flex-grow caption-color">
<AttributeEditor maxWidth={'13rem'} _class={state._class} object={state} key="name" />
<StringPresenter value={state.name} oneLine />
<!-- <AttributeEditor maxWidth={'13rem'} _class={state._class} object={state} key="name" />-->
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="tool hover-trans"
on:click={(ev) => {
showPopup(
StatusesPopup,
{
onDelete: () => dispatch('delete', { state }),
showDelete: wonStates.length > 1,
onUpdate: () => {
showPopup(task.component.CreateStatePopup, { status: state, template }, undefined)
}
},
eventToHTMLElement(ev),
() => {}
)
}}
>
<IconMoreH size={'medium'} />
</div>
{#if wonStates.length > 1}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="tool hover-trans"
on:click={(ev) => {
showPopup(
StatusesPopup,
{ onDelete: () => dispatch('delete', { state }) },
eventToHTMLElement(ev),
() => {}
)
}}
>
<IconMoreH size={'medium'} />
</div>
{/if}
</div>
{/if}
{/each}
@ -215,7 +237,15 @@
icon={IconAdd}
size={'medium'}
on:click={() => {
onAdd(task.class.LostState)
showPopup(
task.component.CreateStatePopup,
{
space,
template,
_class: template !== undefined ? task.class.LostStateTemplate : task.class.LostState
},
undefined
)
}}
/>
</div>
@ -233,24 +263,29 @@
<Lost size={'medium'} />
</div>
<div class="flex-grow caption-color">
<AttributeEditor maxWidth={'13rem'} _class={state._class} object={state} key="name" />
<StringPresenter value={state.name} oneLine />
<!-- <AttributeEditor maxWidth={'13rem'} _class={state._class} object={state} key="name" />-->
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="tool hover-trans"
on:click={(ev) => {
showPopup(
StatusesPopup,
{
onDelete: () => dispatch('delete', { state }),
showDelete: lostStates.length > 1,
onUpdate: () => {
showPopup(task.component.CreateStatePopup, { status: state, template }, undefined)
}
},
eventToHTMLElement(ev),
() => {}
)
}}
>
<IconMoreH size={'medium'} />
</div>
{#if lostStates.length > 1}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="tool hover-trans"
on:click={(ev) => {
showPopup(
StatusesPopup,
{ onDelete: () => dispatch('delete', { state }) },
eventToHTMLElement(ev),
() => {}
)
}}
>
<IconMoreH size={'medium'} />
</div>
{/if}
</div>
{/if}
{/each}

View File

@ -14,28 +14,45 @@
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Label, IconDelete as Delete } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { Label, IconDelete as Delete, IconEdit } from '@hcengineering/ui'
import task from '../../plugin'
export let onDelete: () => void
export let showDelete = true
export let onUpdate: () => void
const dispatch = createEventDispatcher()
</script>
<div class="antiPopup">
<div class="ap-space x2" />
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if showDelete}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="ap-menuItem hoverable flex-row-center redlight"
on:click={() => {
dispatch('close')
onDelete()
}}
>
<div class="mr-2">
<Delete size={'small'} />
</div>
<Label label={task.string.Delete} />
</div>
{/if}
<div
class="ap-menuItem hoverable flex-row-center redlight"
class="ap-menuItem hoverable flex-row-center"
on:click={() => {
dispatch('close')
onDelete()
onUpdate()
}}
>
<div class="mr-2">
<Delete size={'small'} />
<IconEdit size={'small'} />
</div>
<Label label={task.string.Delete} />
<Label label={view.string.Rename} />
</div>
<div class="ap-space x2" />
</div>

View File

@ -38,6 +38,7 @@ import Dashboard from './components/Dashboard.svelte'
import DoneStateRefPresenter from './components/state/DoneStateRefPresenter.svelte'
import StateRefPresenter from './components/state/StateRefPresenter.svelte'
import DueDateEditor from './components/DueDateEditor.svelte'
import CreateStatePopup from './components/CreateStatePopup.svelte'
export { default as AssigneePresenter } from './components/AssigneePresenter.svelte'
export { StateRefPresenter }
@ -69,7 +70,8 @@ export default async (): Promise<Resources> => ({
DoneStateRefPresenter,
StateRefPresenter,
TodoItemsPopup,
DueDateEditor
DueDateEditor,
CreateStatePopup
},
actionImpl: {
EditStatuses: editStatuses

View File

@ -69,7 +69,10 @@ export default mergeIds(taskId, task, {
Tasks: '' as IntlString,
Task: '' as IntlString,
AllTime: '' as IntlString
AllTime: '' as IntlString,
StatusName: '' as IntlString,
StatusPopupTitle: '' as IntlString,
NameAlreadyExists: '' as IntlString
},
status: {
AssigneeRequired: '' as IntlString

View File

@ -279,7 +279,8 @@ const task = plugin(taskId, {
component: {
KanbanTemplateEditor: '' as AnyComponent,
KanbanTemplateSelector: '' as AnyComponent,
TodoItemsPopup: '' as AnyComponent
TodoItemsPopup: '' as AnyComponent,
CreateStatePopup: '' as AnyComponent
},
ids: {
AssigneedNotification: '' as Ref<NotificationType>

View File

@ -70,7 +70,7 @@ export async function OnTemplateStateCreate (tx: Tx, control: TriggerControl): P
await control.findAll(task.class.KanbanTemplate, { _id: actualTx.attributes.attachedTo })
)[0] as KanbanTemplate
const classToChange = getClassToChangeOrCreate(actualTx.objectClass)
const objectWithStatesToChange = await control.findAll(templateSpace.attachedToClass, { templateId: template._id })
const objectWithStatesToChange = await control.findAll(templateSpace.attachedToClass, { templateId: template?._id })
const ids = Array.from(objectWithStatesToChange.map((x) => x._id)) as Array<Ref<Space>>
const doc = TxProcessor.createDoc2Doc(actualTx)
const ofAttribute = classToChange === task.class.State ? task.attribute.State : task.attribute.DoneState

View File

@ -196,7 +196,7 @@ class TServerStorage implements ServerStorage {
return result[0]
}
if (result.length === 0) {
return [{}, false]
return false
}
return result
}

View File

@ -737,7 +737,7 @@ class MongoAdapter extends MongoAdapterBase {
return (await this.getOperations(txes[0])?.raw()) ?? {}
}
if (result.length === 0) {
return {}
return false
}
if (result.length === 1) {
return result[0]

View File

@ -91,20 +91,18 @@ test.describe('contact tests', () => {
await t.locator('input').fill(tid)
// await page.locator(`#templates >> .container:has-text("${tid}")`).type('Enter')
// Click text=Active statuses >> div
await page.locator('.states >> svg >> nth=1').click()
await page.locator('text=Rename').click()
await page.locator('.box > .editbox-container input').fill('State1')
await page.locator('button:has-text("Save")').click()
await page.waitForSelector('form.antiCard', { state: 'detached' })
await page.click('text=Active statuses >> div')
const s1 = page.locator('.states:has-text("New State")').first()
await s1.click()
await s1.locator('input').fill('State1')
await page.locator('.box > .editbox-container input').fill('State2')
await page.locator('button:has-text("Save")').click()
await page.waitForSelector('form.antiCard', { state: 'detached' })
await page.click('text=Active statuses >> div')
const s2 = page.locator('.states:has-text("New State")').first()
await s2.click()
await s2.locator('input').fill('State2')
await page.click('text=Active statuses >> div')
const s3 = page.locator('.states:has-text("New State")').first()
await s3.click()
await s3.locator('input').fill('State3')
await page.locator('.box > .editbox-container input').fill('State3')
await page.locator('button:has-text("Save")').click()
await page.waitForSelector('form.antiCard', { state: 'detached' })
})
})