Markup editor updates and few more little fixes (#2502)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-01-13 02:56:35 +07:00 committed by GitHub
parent 8fa7c938da
commit 0bcd74fa25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 957 additions and 590 deletions

2
.vscode/launch.json vendored
View File

@ -21,7 +21,7 @@
"MINIO_SECRET_KEY": "minioadmin",
"SERVER_SECRET": "secret",
"REKONI_URL": "http://localhost:4004",
"OPENAI_TOKEN": "",
// "OPENAI_TOKEN": "",
// "RETRANSLATE_URL": "http://127.0.0.1:4500",
//"RETRANSLATE_URL": "https://208.167.249.201",
// "RETRANSLATE_TOKEN": ""

File diff suppressed because it is too large Load Diff

View File

@ -317,13 +317,15 @@ export function createModel (builder: Builder): void {
classPresenter(
builder,
core.class.TypeMarkup,
view.component.HTMLPresenter,
view.component.StringEditor,
view.component.StringEditorPopup
view.component.MarkupPresenter,
view.component.MarkupEditor,
view.component.MarkupEditorPopup
)
builder.mixin(core.class.TypeMarkup, core.class.Class, view.mixin.InlineAttributEditor, {
editor: view.component.HTMLEditor
})
classPresenter(builder, core.class.TypeBoolean, view.component.BooleanPresenter, view.component.BooleanEditor)
classPresenter(builder, core.class.TypeTimestamp, view.component.TimestampPresenter)
classPresenter(builder, core.class.TypeDate, view.component.DatePresenter, view.component.DateEditor)

View File

@ -47,7 +47,7 @@ export default mergeIds(viewId, view, {
IntlStringPresenter: '' as AnyComponent,
NumberEditor: '' as AnyComponent,
NumberPresenter: '' as AnyComponent,
HTMLPresenter: '' as AnyComponent,
MarkupPresenter: '' as AnyComponent,
BooleanPresenter: '' as AnyComponent,
BooleanEditor: '' as AnyComponent,
TimestampPresenter: '' as AnyComponent,
@ -59,7 +59,9 @@ export default mergeIds(viewId, view, {
GithubPresenter: '' as AnyComponent,
ClassPresenter: '' as AnyComponent,
EnumEditor: '' as AnyComponent,
HTMLEditor: '' as AnyComponent
HTMLEditor: '' as AnyComponent,
MarkupEditor: '' as AnyComponent,
MarkupEditorPopup: '' as AnyComponent
},
string: {
Table: '' as IntlString,

View File

@ -34,6 +34,7 @@
<style lang="scss">
.attributes-bar-container {
flex-shrink: 0;
display: grid;
grid-template-columns: 1fr 2fr;
grid-auto-flow: row;

View File

@ -0,0 +1,126 @@
<!--
// 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 { flip } from 'svelte/animate'
import { Doc } from '@hcengineering/core'
import { IconMoreV } from '@hcengineering/ui'
import { getClient } from '../utils'
type DocWithRank = Doc & { rank: string }
export let objects: Doc[] | DocWithRank[]
export let handleMove: ((fromIndex: number, toIndex: number) => void) | undefined = undefined
export let calcRank: (doc: DocWithRank, next: DocWithRank) => string
export let showContextMenu: ((evt: MouseEvent, doc: Doc) => void) | undefined = undefined
const client = getClient()
let draggingIndex: number | null = null
let hoveringIndex: number | null = null
let dragOverIndex: number = -1
function resetDrag () {
draggingIndex = null
hoveringIndex = null
dragOverIndex = -1
}
function handleDragStart (ev: DragEvent, index: number) {
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move'
ev.dataTransfer.dropEffect = 'move'
draggingIndex = index
}
}
function checkHasRank (objects: Doc[] | (Doc & { rank: string })[]): objects is (Doc & { rank: string })[] {
return 'rank' in objects[0]
}
async function handleDrop (ev: DragEvent, toIndex: number) {
if (ev.dataTransfer && draggingIndex !== null && toIndex !== draggingIndex) {
ev.dataTransfer.dropEffect = 'move'
if (handleMove) {
handleMove(draggingIndex, toIndex)
return
}
if (!checkHasRank(objects)) {
return
}
const [prev, next] = [
objects[draggingIndex < toIndex ? toIndex : toIndex - 1],
objects[draggingIndex < toIndex ? toIndex + 1 : toIndex]
]
const object = objects[draggingIndex]
await client.update(object, { rank: calcRank(prev, next) })
}
resetDrag()
}
</script>
<div class="antiAccordion">
{#each objects as object, index (object._id)}
<div class="description pb-1" animate:flip={{ duration: 400 }}>
<div
class:is-dragging={index === draggingIndex}
class:is-dragged-over-up={draggingIndex !== null && index < draggingIndex && index === hoveringIndex}
class:is-dragged-over-down={draggingIndex !== null && index > draggingIndex && index === hoveringIndex}
class:drag-over-highlight={index === dragOverIndex}
draggable={true}
on:contextmenu|preventDefault={(ev) => showContextMenu?.(ev, object)}
on:dragstart={(ev) => handleDragStart(ev, index)}
on:dragover|preventDefault={() => {
dragOverIndex = index
return false
}}
on:dragenter={() => (hoveringIndex = index)}
on:drop|preventDefault={(ev) => handleDrop(ev, index)}
on:dragend={resetDrag}
>
<div class="draggable-container">
<div class="caption mb-0 flex flex-grow flex-row-center">
<div class="draggable-mark fs-title dark-color whitespace-nowrap mr-2"><IconMoreV size={'small'} /></div>
<div class="fs-title dark-color whitespace-nowrap mr-2">
{`${index + 1}.`}
</div>
<slot name="object" {index} />
</div>
<slot name="object-footer" {index} />
</div>
</div>
</div>
{/each}
</div>
<style lang="scss">
.drag-over-highlight {
opacity: 0.2;
}
.description {
.draggable-container {
cursor: grabbing;
}
&:hover {
.draggable-mark {
opacity: 0.4;
}
}
}
</style>

View File

@ -44,6 +44,7 @@ export { default as IconMembers } from './components/icons/Members.svelte'
export { default as IconMembersOutline } from './components/icons/MembersOutline.svelte'
export { default as ObjectSearchPopup } from './components/ObjectSearchPopup.svelte'
export { default as IndexedDocumentPreview } from './components/IndexedDocumentPreview.svelte'
export { default as DraggableList } from './components/DraggableList.svelte'
export { connect, versionError } from './connect'
export { default } from './plugin'
export * from './types'

View File

@ -37,36 +37,42 @@
"@hcengineering/core": "^0.6.20",
"@hcengineering/ui": "^0.6.3",
"svelte": "^3.47",
"@tiptap/core": "~2.0.0-beta.199",
"@tiptap/starter-kit": "~2.0.0-beta.199",
"@tiptap/extension-highlight": "~2.0.0-beta.199",
"@tiptap/extension-placeholder": "~2.0.0-beta.199",
"@tiptap/extension-mention": "~2.0.0-beta.199",
"@tiptap/extension-typography": "~2.0.0-beta.199",
"@tiptap/extension-link": "~2.0.0-beta.199",
"@tiptap/suggestion": "~2.0.0-beta.199",
"@tiptap/extension-task-list": "~2.0.0-beta.199",
"@tiptap/extension-task-item": "~2.0.0-beta.199",
"@tiptap/extension-collaboration": "~2.0.0-beta.199",
"@tiptap/extension-collaboration-cursor": "~2.0.0-beta.199",
"@tiptap/core": "~2.0.0-beta.209",
"@tiptap/starter-kit": "~2.0.0-beta.209",
"@tiptap/extension-highlight": "~2.0.0-beta.209",
"@tiptap/extension-placeholder": "~2.0.0-beta.209",
"@tiptap/extension-mention": "~2.0.0-beta.209",
"@tiptap/extension-typography": "~2.0.0-beta.209",
"@tiptap/extension-link": "~2.0.0-beta.209",
"@tiptap/suggestion": "~2.0.0-beta.209",
"@tiptap/extension-task-list": "~2.0.0-beta.209",
"@tiptap/extension-task-item": "~2.0.0-beta.209",
"@tiptap/extension-collaboration": "~2.0.0-beta.209",
"@tiptap/extension-collaboration-cursor": "~2.0.0-beta.209",
"@tiptap/prosemirror-tables": "^1.1.4",
"emoji-regex": "^10.1.0",
"prosemirror-collab": "~1.3.0",
"prosemirror-state": "~1.4.1",
"prosemirror-transform": "~1.7.0",
"yjs": "^13.5.42",
"prosemirror-gapcursor": "^1.3.1",
"prosemirror-dropcursor": "^1.6.1",
"prosemirror-collab": "^1.3.0",
"prosemirror-state": "^1.4.2",
"prosemirror-transform": "^1.7.0",
"prosemirror-schema-list": "^1.2.2",
"prosemirror-commands": "^1.5.0",
"yjs": "^13.5.44",
"y-websocket": "^1.4.5",
"y-prosemirror": "1.0.20",
"prosemirror-changeset": "~2.2.0",
"prosemirror-model": "~1.18.1",
"prosemirror-view": "~1.29.0",
"rfc6902": "~5.0.1",
"diff": "~5.1.0",
"@tiptap/extension-code-block": "~2.0.0-beta.200",
"@tiptap/extension-gapcursor": "~2.0.0-beta.200",
"@tiptap/extension-heading": "~2.0.0-beta.200",
"@tiptap/extension-table": "~2.0.0-beta.202",
"@tiptap/extension-table-cell": "~2.0.0-beta.202",
"@tiptap/extension-table-header": "~2.0.0-beta.202",
"@tiptap/extension-table-row": "~2.0.0-beta.202"
"y-prosemirror": "^1.2.0",
"prosemirror-changeset": "^2.2.0",
"prosemirror-model": "^1.18.3",
"prosemirror-view": "^1.29.1",
"prosemirror-history": "^1.3.0",
"rfc6902": "^5.0.1",
"diff": "^5.1.0",
"@tiptap/extension-code-block": "~2.0.0-beta.209",
"@tiptap/extension-gapcursor": "~2.0.0-beta.209",
"@tiptap/extension-heading": "~2.0.0-beta.209",
"@tiptap/extension-table": "~2.0.0-beta.209",
"@tiptap/extension-table-cell": "~2.0.0-beta.209",
"@tiptap/extension-table-header": "~2.0.0-beta.209",
"@tiptap/extension-table-row": "~2.0.0-beta.209"
}
}

View File

@ -45,14 +45,15 @@
setContext(CollaborationIds.Doc, ydoc)
setContext(CollaborationIds.Provider, wsProvider)
wsProvider.on('status', (event: any) => {
console.log(documentId, event.status) // logs "connected" or "disconnected"
console.log('Collaboration:', documentId, event.status) // logs "connected" or "disconnected"
})
wsProvider.on('synched', (event: any) => {
console.log('Collaboration:', event) // logs "connected" or "disconnected"
})
}
onDestroy(() => {
setTimeout(() => {
wsProvider?.disconnect()
}, 100)
})
</script>

View File

@ -30,6 +30,7 @@
export let previewUnlimit: boolean = false
export let focusable: boolean = false
export let enableFormatting = false
export let autofocus = false
const Mode = {
View: 1,
@ -118,6 +119,7 @@
{maxHeight}
{focusable}
{enableFormatting}
{autofocus}
bind:content={rawValue}
bind:this={textEditor}
on:attach

View File

@ -67,6 +67,7 @@
export let maxHeight: 'max' | 'card' | 'limited' | string | undefined = undefined
export let withoutTopBorder = false
export let enableFormatting = false
export let autofocus = false
let textEditor: TextEditor
@ -201,7 +202,7 @@
a.action(evt?.target as HTMLElement, editorHandler)
}
let needFocus = false
let needFocus = autofocus
const focused = false
$: if (textEditor && needFocus) {

View File

@ -419,6 +419,7 @@ input.search {
.mt-9 { margin-top: 2.25rem; }
.mt-10 { margin-top: 2.5rem; }
.mt-14 { margin-top: 3.5rem; }
.mb-0 { margin-bottom: 0 !important; }
.mb-1 { margin-bottom: .25rem; }
.mb-2 { margin-bottom: .5rem; }
.mb-3 { margin-bottom: .75rem; }
@ -452,7 +453,7 @@ input.search {
.pt-3 { padding-top: .75rem; }
.pt-4 { padding-top: 1rem; }
.pt-6 { padding-top: 1.5rem; }
.pb-1 { padding-bottom: .25rem; }
.pb-1 { padding-bottom: .25rem !important; }
.pb-2 { padding-bottom: .5rem; }
.pb-3 { padding-bottom: .75rem; }
.pb-4 { padding-bottom: 1rem; }

View File

@ -33,6 +33,7 @@
export let disabled: boolean = false
export let loading: boolean = false
export let width: string | undefined = undefined
export let height: string | undefined = undefined
export let resetIconSize: boolean = false
export let highlight: boolean = false
export let selected: boolean = false
@ -95,6 +96,7 @@
class:notSelected
disabled={disabled || loading}
style:width
style:height
{title}
type={kind === 'primary' ? 'submit' : 'button'}
on:click|stopPropagation|preventDefault

View File

@ -117,6 +117,7 @@
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="editbox-container"
class:flex-grow={fullSize}

View File

@ -16,6 +16,7 @@
import type { TabModel } from '../types'
import Label from './Label.svelte'
import Component from './Component.svelte'
import { Icon } from '..'
export let model: TabModel
export let selected = 0
@ -23,6 +24,7 @@
<div class="flex-stretch container">
{#each model as tab, i}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="flex-row-center tab"
class:selected={i === selected}
@ -30,6 +32,11 @@
selected = i
}}
>
{#if tab.icon !== undefined}
<div class="mr-2">
<Icon icon={tab.icon} size={'small'} />
</div>
{/if}
<Label label={tab.label} />
</div>
{/each}

View File

@ -48,6 +48,7 @@
"AllFilters": "all filters",
"MatchCriteria": "Match criteria",
"DontMatchCriteria": "Don't match criteria",
"View": "View"
"View": "View",
"MarkupEditor": "Edit of rich content field"
}
}

View File

@ -46,6 +46,7 @@
"AllFilters": "всем фильтрам",
"MatchCriteria": "Соответсвует условию",
"DontMatchCriteria": "Не соответвует условию",
"View": "Вид"
"View": "Вид",
"MarkupEditor": "Изменение форматированного поля"
}
}

View File

@ -0,0 +1,71 @@
<!--
// 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 type { IntlString } from '@hcengineering/platform'
import { MessageViewer } from '@hcengineering/presentation'
import { Button, eventToHTMLElement, Label, showPopup } from '@hcengineering/ui'
import MarkupEditorPopup from './MarkupEditorPopup.svelte'
// export let label: IntlString
export let placeholder: IntlString
export let value: string
export let focus: boolean = false
export let onChange: (value: string) => void
export let kind: 'no-border' | 'link' = 'no-border'
export let readonly = false
// export let size: ButtonSize = 'x-large'
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = 'fit-content'
let shown: boolean = false
</script>
{#if kind === 'link'}
<Button
{kind}
size={'x-large'}
{justify}
{width}
height={value ? 'auto' : undefined}
on:click={(ev) => {
if (!shown && !readonly) {
showPopup(MarkupEditorPopup, { value }, eventToHTMLElement(ev), (res) => {
if (res != null) {
value = res
onChange(value)
}
shown = false
})
}
}}
>
<svelte:fragment slot="content">
<div class="lines-limit-4" style:text-align={'left'} class:dark-color={readonly}>
{#if value}
<MessageViewer message={value} />
{:else}
<span class="dark-color"><Label label={placeholder} /></span>
{/if}
</div>
</svelte:fragment>
</Button>
{:else if readonly}
{#if value}
<span class="overflow-label">{value}</span>
{:else}
<span class="dark-color"><Label label={placeholder} /></span>
{/if}
{/if}

View File

@ -0,0 +1,48 @@
<!--
// 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 { Card } from '@hcengineering/presentation'
import { StyledTextBox } from '@hcengineering/text-editor'
import { createEventDispatcher } from 'svelte'
import view from '../plugin'
export let value: string
const dispatch = createEventDispatcher()
export let maxHeight: string = '40vh'
const checkValue = (evt: CustomEvent): void => {
const res: string | undefined = evt.detail === null ? undefined : evt.detail
if (value !== res && res != null) {
dispatch('change', res)
value = res
}
}
</script>
<Card
label={view.string.MarkupEditor}
canSave={true}
okLabel={view.string.Save}
okAction={() => {
dispatch('close', value)
}}
on:close={() => dispatch('close', null)}
on:changeContent
>
<div class="flex-grow mt-4">
<StyledTextBox autofocus content={value} alwaysEdit mode={2} hideExtraButtons {maxHeight} on:value={checkValue} />
</div>
</Card>

View File

@ -54,6 +54,8 @@ import UpDownNavigator from './components/UpDownNavigator.svelte'
import ViewletSettingButton from './components/ViewletSettingButton.svelte'
import ValueSelector from './components/ValueSelector.svelte'
import HTMLEditor from './components/HTMLEditor.svelte'
import MarkupEditor from './components/MarkupEditor.svelte'
import MarkupEditorPopup from './components/MarkupEditorPopup.svelte'
import SortableList from './components/list/SortableList.svelte'
import SortableListItem from './components/list/SortableListItem.svelte'
import {
@ -118,7 +120,8 @@ export {
NumberPresenter,
TimestampPresenter,
SortableList,
SortableListItem
SortableListItem,
MarkupEditor
}
export default async (): Promise<Resources> => ({
@ -149,6 +152,8 @@ export default async (): Promise<Resources> => ({
YoutubePresenter,
ActionsPopup,
StringEditorPopup: EditBoxPopup,
MarkupEditor,
MarkupEditorPopup,
BooleanTruePresenter,
EnumEditor,
FilterTypePopup,

View File

@ -54,6 +54,7 @@ export default mergeIds(viewId, view, {
AnyFilter: '' as IntlString,
AllFilters: '' as IntlString,
MatchCriteria: '' as IntlString,
DontMatchCriteria: '' as IntlString
DontMatchCriteria: '' as IntlString,
MarkupEditor: '' as IntlString
}
})

View File

@ -47,7 +47,7 @@
"lodash.debounce": "~4.0.8",
"y-protocols": "~1.0.5",
"ws": "^8.10.0",
"yjs": "^13.5.42",
"yjs": "^13.5.44",
"@hcengineering/minio": "^0.6.0"
}
}

View File

@ -271,7 +271,6 @@ async function doIssueUpdate (
)
const updatedProject = newParent !== undefined ? newParent.project : null
const updatedSprint = newParent !== undefined ? newParent.sprint : null
const updatedParents =
newParent !== undefined ? [{ parentId: newParent._id, parentTitle: newParent.title }, ...newParent.parents] : []
@ -282,14 +281,13 @@ async function doIssueUpdate (
? {}
: { parents: [...issue.parents].slice(0, parentInfoIndex + 1).concat(updatedParents) }
return { ...parentsUpdate, project: updatedProject, sprint: updatedSprint }
return { ...parentsUpdate, project: updatedProject }
}
res.push(
control.txFactory.createTxUpdateDoc(updateTx.objectClass, updateTx.objectSpace, updateTx.objectId, {
parents: updatedParents,
project: updatedProject,
sprint: updatedSprint
project: updatedProject
}),
...(await updateSubIssues(updateTx, control, update))
)

View File

@ -172,9 +172,14 @@ export async function backup (transactorUrl: string, workspaceId: WorkspaceId, s
let addedDocuments = 0
// update digest tar
const needRetrieveChunks: Ref<Doc>[][] = []
// Load all digest from collection.
while (true) {
try {
const it = await connection.loadChunk(c, idx)
idx = it.idx
console.log(needRetrieveChunks.length)
const needRetrieve: Ref<Doc>[] = []
@ -194,13 +199,50 @@ export async function backup (transactorUrl: string, workspaceId: WorkspaceId, s
}
}
if (needRetrieve.length > 0) {
const docs = await connection.loadDocs(c, needRetrieve)
needRetrieveChunks.push(needRetrieve)
}
if (it.finished) {
await connection.closeChunk(idx)
break
}
} catch (err: any) {
console.error(err)
if (idx !== undefined) {
await connection.closeChunk(idx)
}
// Try again
idx = undefined
}
}
while (needRetrieveChunks.length > 0) {
const needRetrieve = needRetrieveChunks.shift() as Ref<Doc>[]
console.log('Retrieve chunk:', needRetrieve.length)
let docs: Doc[] = []
try {
docs = await connection.loadDocs(c, needRetrieve)
} catch (err: any) {
console.log(err)
// Put back.
needRetrieveChunks.push(needRetrieve)
continue
}
// Chunk data into small pieces
if (addedDocuments > dataBlobSize && _pack !== undefined) {
_pack.finalize()
_pack = undefined
addedDocuments = 0
if (changed > 0) {
snapshot.domains[c] = domainInfo
domainInfo.added = Object.keys(changes.added).length
domainInfo.updated = Object.keys(changes.updated).length
domainInfo.removed = changes.removed.length
await storage.writeFile(domainInfo.snapshot, gzipSync(JSON.stringify(changes)))
// This will allow to retry in case of critical error.
await storage.writeFile(infoFile, gzipSync(JSON.stringify(backupInfo, undefined, 2)))
}
}
if (_pack === undefined) {
_pack = pack()
@ -238,11 +280,6 @@ export async function backup (transactorUrl: string, workspaceId: WorkspaceId, s
}
}
}
if (it.finished) {
break
}
}
changes.removed = Array.from(digest.keys())
if (changes.removed.length > 0) {
changed++
@ -255,6 +292,8 @@ export async function backup (transactorUrl: string, workspaceId: WorkspaceId, s
domainInfo.removed = changes.removed.length
await storage.writeFile(domainInfo.snapshot, gzipSync(JSON.stringify(changes)))
_pack?.finalize()
// This will allow to retry in case of critical error.
await storage.writeFile(infoFile, gzipSync(JSON.stringify(backupInfo, undefined, 2)))
}
}

View File

@ -307,17 +307,13 @@ export function start (
perMessageDeflate: {
zlibDeflateOptions: {
// See zlib defaults.
chunkSize: 1024,
chunkSize: 10 * 1024,
memLevel: 7,
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
// Below options specified as default values.
concurrencyLimit: 10, // Limits zlib concurrency for perf.
threshold: 1024 // Size (in bytes) below which messages
// should not be compressed if context takeover is disabled.
}
}
})
// eslint-disable-next-line @typescript-eslint/no-misused-promises